mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
feat(tabs): COD-359 add owner-scoped tab layouts
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* @fileoverview Owner-scoped tab-layout persistence and legacy migration tests.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { StateStore } from '../src/state-store.js';
|
||||
import {
|
||||
SINGLE_USER_LAYOUT_OWNER,
|
||||
normalizeOrMigrateOwnerTabLayout,
|
||||
ownerLayoutKey,
|
||||
type TabLayoutMigrationInput,
|
||||
} from '../src/tab-layout-persistence.js';
|
||||
import { MAX_TAB_REFS, TabLayoutValidationError, type TabLayout } from '../src/tab-layout.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const baseInput = (overrides: Partial<TabLayoutMigrationInput> = {}): TabLayoutMigrationInput => ({
|
||||
owner: SINGLE_USER_LAYOUT_OWNER,
|
||||
layouts: {},
|
||||
sessionOrder: [],
|
||||
persistedSessions: [],
|
||||
liveSessions: [],
|
||||
webviews: [],
|
||||
updatedAt: '2026-08-16T12:00:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('owner layout key', () => {
|
||||
it('uses the reserved single-user key or authenticated username', () => {
|
||||
expect(ownerLayoutKey()).toBe('@single');
|
||||
expect(ownerLayoutKey('alice')).toBe('alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeOrMigrateOwnerTabLayout', () => {
|
||||
it('filters global sessionOrder by owner and appends remaining sessions deterministically', () => {
|
||||
const result = normalizeOrMigrateOwnerTabLayout(
|
||||
baseInput({
|
||||
owner: 'alice',
|
||||
sessionOrder: ['foreign', 'late', 'ordered', 'missing'],
|
||||
persistedSessions: [
|
||||
{ id: 'foreign', owner: 'bob', createdAt: 1 },
|
||||
{ id: 'late', owner: 'alice', createdAt: 30 },
|
||||
{ id: 'ordered', owner: 'alice', createdAt: 20 },
|
||||
{ id: 'tie-b', owner: 'alice', createdAt: 10 },
|
||||
{ id: 'tie-a', owner: 'alice', createdAt: 10 },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.layout.ungrouped).toEqual([
|
||||
{ kind: 'session', id: 'late' },
|
||||
{ kind: 'session', id: 'ordered' },
|
||||
{ kind: 'session', id: 'tie-a' },
|
||||
{ kind: 'session', id: 'tie-b' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes persisted stopped/pinned sessions and de-duplicates live records', () => {
|
||||
const result = normalizeOrMigrateOwnerTabLayout(
|
||||
baseInput({
|
||||
persistedSessions: [
|
||||
{ id: 'pinned', createdAt: 1 },
|
||||
{ id: 'shared', createdAt: 5 },
|
||||
],
|
||||
liveSessions: [{ id: 'shared', createdAt: 2 }],
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.layout.ungrouped).toEqual([
|
||||
{ kind: 'session', id: 'pinned' },
|
||||
{ kind: 'session', id: 'shared' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats an ownerless live record as authoritative single-user ownership', () => {
|
||||
const result = normalizeOrMigrateOwnerTabLayout(
|
||||
baseInput({
|
||||
persistedSessions: [{ id: 'moved', owner: 'alice', createdAt: 1 }],
|
||||
liveSessions: [{ id: 'moved', createdAt: 2 }],
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.layout.ungrouped).toEqual([{ kind: 'session', id: 'moved' }]);
|
||||
});
|
||||
|
||||
it('treats an absent live parentSessionId as an authoritative root', () => {
|
||||
const result = normalizeOrMigrateOwnerTabLayout(
|
||||
baseInput({
|
||||
sessionOrder: ['child', 'former-parent'],
|
||||
persistedSessions: [
|
||||
{ id: 'child', parentSessionId: 'former-parent', createdAt: 1 },
|
||||
{ id: 'former-parent', createdAt: 2 },
|
||||
],
|
||||
liveSessions: [
|
||||
{ id: 'child', createdAt: 1 },
|
||||
{ id: 'former-parent', createdAt: 2 },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.layout.ungrouped).toEqual([
|
||||
{ kind: 'session', id: 'child' },
|
||||
{ kind: 'session', id: 'former-parent' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks migrated children manual when their parent is live and same-owner', () => {
|
||||
const result = normalizeOrMigrateOwnerTabLayout(
|
||||
baseInput({
|
||||
owner: 'alice',
|
||||
sessionOrder: ['child', 'parent', 'foreign-child'],
|
||||
persistedSessions: [
|
||||
{ id: 'child', owner: 'alice', parentSessionId: 'parent', createdAt: 2 },
|
||||
{ id: 'parent', owner: 'alice', createdAt: 1 },
|
||||
{ id: 'foreign-child', owner: 'alice', parentSessionId: 'foreign-parent', createdAt: 3 },
|
||||
],
|
||||
liveSessions: [
|
||||
{ id: 'parent', owner: 'alice', createdAt: 1 },
|
||||
{ id: 'foreign-parent', owner: 'bob', createdAt: 1 },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.layout.ungrouped).toEqual([
|
||||
{ kind: 'session', id: 'child', placement: 'manual' },
|
||||
{ kind: 'session', id: 'parent' },
|
||||
{ kind: 'session', id: 'foreign-child' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends owner webviews in server store order after sessions', () => {
|
||||
const result = normalizeOrMigrateOwnerTabLayout(
|
||||
baseInput({
|
||||
owner: 'alice',
|
||||
persistedSessions: [{ id: 'session', owner: 'alice', createdAt: 1 }],
|
||||
webviews: [
|
||||
{ id: 'second', owner: 'alice' },
|
||||
{ id: 'foreign', owner: 'bob' },
|
||||
{ id: 'first', owner: 'alice' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.layout.ungrouped).toEqual([
|
||||
{ kind: 'session', id: 'session' },
|
||||
{ kind: 'webview', id: 'second' },
|
||||
{ kind: 'webview', id: 'first' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes an existing layout idempotently without pruning unknown refs', () => {
|
||||
const existing: TabLayout = {
|
||||
version: 7,
|
||||
groups: [
|
||||
{
|
||||
id: 'g',
|
||||
name: ' Work ',
|
||||
refs: [
|
||||
{ kind: 'session', id: 'unknown' },
|
||||
{ kind: 'session', id: 'foreign' },
|
||||
],
|
||||
},
|
||||
],
|
||||
ungrouped: [{ kind: 'session', id: 'known' }],
|
||||
updatedAt: 'old',
|
||||
};
|
||||
const input = baseInput({
|
||||
layouts: { '@single': existing },
|
||||
persistedSessions: [
|
||||
{ id: 'known', createdAt: 1 },
|
||||
{ id: 'foreign', owner: 'alice', createdAt: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
const once = normalizeOrMigrateOwnerTabLayout(input);
|
||||
const twice = normalizeOrMigrateOwnerTabLayout({ ...input, layouts: once.layouts });
|
||||
|
||||
expect(once.created).toBe(false);
|
||||
expect(once.layout.groups).toEqual([{ id: 'g', name: 'Work', refs: [{ kind: 'session', id: 'unknown' }] }]);
|
||||
expect(twice.layout).toEqual(once.layout);
|
||||
});
|
||||
|
||||
it('migrates an owner named constructor instead of reading the inherited prototype key', () => {
|
||||
const result = normalizeOrMigrateOwnerTabLayout(
|
||||
baseInput({
|
||||
owner: 'constructor',
|
||||
layouts: {
|
||||
alice: {
|
||||
version: 1,
|
||||
groups: [],
|
||||
ungrouped: [],
|
||||
updatedAt: 'old',
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.layouts.constructor).toBeDefined();
|
||||
expect(result.layout.version).toBe(0);
|
||||
});
|
||||
|
||||
it('accepts exactly 512 refs and rejects 513 atomically without truncation', () => {
|
||||
const sessions = Array.from({ length: MAX_TAB_REFS }, (_, i) => ({ id: `s-${i}`, createdAt: i }));
|
||||
const accepted = normalizeOrMigrateOwnerTabLayout(baseInput({ persistedSessions: sessions }));
|
||||
expect(accepted.layout.ungrouped).toHaveLength(MAX_TAB_REFS);
|
||||
|
||||
const layouts = { untouched: accepted.layout };
|
||||
expect(() =>
|
||||
normalizeOrMigrateOwnerTabLayout(
|
||||
baseInput({ layouts, persistedSessions: [...sessions, { id: 'overflow', createdAt: MAX_TAB_REFS }] })
|
||||
)
|
||||
).toThrow(TabLayoutValidationError);
|
||||
expect(layouts).toEqual({ untouched: accepted.layout });
|
||||
});
|
||||
});
|
||||
|
||||
describe('StateStore tabLayouts allowlist', () => {
|
||||
it('persists and reloads full owner layouts across a restart', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-tab-layout-'));
|
||||
tempDirs.push(dir);
|
||||
const file = join(dir, 'state.json');
|
||||
const layout: TabLayout = {
|
||||
version: 3,
|
||||
groups: [{ id: 'g', name: 'Work', refs: [{ kind: 'session', id: 's', placement: 'manual' }] }],
|
||||
ungrouped: [{ kind: 'webview', id: 'w' }],
|
||||
updatedAt: '2026-08-16T12:00:00.000Z',
|
||||
};
|
||||
|
||||
const store = new StateStore(file);
|
||||
store.setTabLayout('alice', layout);
|
||||
store.saveNow();
|
||||
|
||||
expect(JSON.parse(readFileSync(file, 'utf8')).tabLayouts).toEqual({ alice: layout });
|
||||
expect(new StateStore(file).getTabLayout('alice')).toEqual(layout);
|
||||
});
|
||||
|
||||
it('does not treat an inherited constructor property as a persisted owner layout', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-tab-layout-'));
|
||||
tempDirs.push(dir);
|
||||
const file = join(dir, 'state.json');
|
||||
const layout: TabLayout = {
|
||||
version: 3,
|
||||
groups: [],
|
||||
ungrouped: [],
|
||||
updatedAt: '2026-08-16T12:00:00.000Z',
|
||||
};
|
||||
|
||||
const store = new StateStore(file);
|
||||
store.setTabLayout('alice', layout);
|
||||
store.saveNow();
|
||||
|
||||
expect(store.getTabLayout('constructor')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user