feat(tabs): COD-359 add owner-scoped tab layouts

This commit is contained in:
Aamer Akhter
2026-08-23 14:46:10 -04:00
parent 6b0b6d10ad
commit 74194e4fc0
44 changed files with 5202 additions and 136 deletions
+180 -1
View File
@@ -6,8 +6,10 @@
* These behaviors live in server.ts (preSerialization hook, setNotFoundHandler),
* which the route-test harness does not install — so they need a real WebServer.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { flattenOwnerSessionOrder, type TabLayout } from '../src/tab-layout.js';
import { WebServer } from '../src/web/server.js';
import { SseEvent } from '../src/web/sse-events.js';
const PORT = 3168;
@@ -33,6 +35,183 @@ describe('Stable HTTP contract (live server)', () => {
expect(body.data.version).toBeDefined();
});
it('preserves the legacy global session order in single-user light state without exposing layouts', async () => {
const res = await fetch(`${base}/api/status`);
const body = await res.json();
expect(body.data.sessionOrder).toEqual([]);
expect(body.data).not.toHaveProperty('tabLayouts');
});
it('filters status order for regular users while admins and single-user mode retain the global projection', () => {
type FakeSession = {
id: string;
owner: string;
inputTokens: number;
outputTokens: number;
totalCost: number;
toLightDetailedState(): { id: string; owner: string };
};
type StatusInternals = {
sessions: Map<string, FakeSession>;
store: { getSessionOrder(): string[]; setSessionOrder(order: string[]): void };
cachedLightState: unknown;
cachedSessionsList: unknown;
getLightState(identity?: { username: string; role: 'admin' | 'user' }): Record<string, unknown>;
};
const internals = server as unknown as StatusInternals;
const previousOrder = internals.store.getSessionOrder();
const previousSessions = new Map(internals.sessions);
const fakeSession = (id: string, owner: string): FakeSession => ({
id,
owner,
inputTokens: 0,
outputTokens: 0,
totalCost: 0,
toLightDetailedState: () => ({ id, owner }),
});
try {
internals.sessions.clear();
internals.sessions.set('a1', fakeSession('a1', 'alice'));
internals.sessions.set('b1', fakeSession('b1', 'bob'));
internals.sessions.set('a2', fakeSession('a2', 'alice'));
internals.store.setSessionOrder(['b1', 'a1', 'a2']);
internals.cachedLightState = null;
internals.cachedSessionsList = null;
vi.stubEnv('CODEMAN_MULTIUSER', '1');
expect(internals.getLightState({ username: 'alice', role: 'user' }).sessionOrder).toEqual(['a1', 'a2']);
expect(internals.getLightState({ username: 'root', role: 'admin' }).sessionOrder).toEqual(['b1', 'a1', 'a2']);
vi.stubEnv('CODEMAN_MULTIUSER', '0');
expect(internals.getLightState().sessionOrder).toEqual(['b1', 'a1', 'a2']);
} finally {
vi.unstubAllEnvs();
internals.sessions.clear();
for (const [id, session] of previousSessions) internals.sessions.set(id, session);
internals.store.setSessionOrder(previousOrder);
internals.cachedLightState = null;
internals.cachedSessionsList = null;
}
});
it('keeps the tab layout foundation smoke contract atomic and writable', async () => {
type TabLayoutInternals = {
sessions: Map<string, { id: string; createdAt: number; owner?: string }>;
store: {
getState(): { sessionOrder?: string[]; tabLayouts?: Record<string, TabLayout> };
getSessionOrder(): string[];
getTabLayout(owner: string): TabLayout | null;
getTabLayouts(): Record<string, TabLayout>;
commitTabLayoutProjection: (...args: unknown[]) => unknown;
save(): void;
};
sse: {
addClient(reply: unknown, sessionFilter: Set<string> | null, isRemote: boolean): void;
removeClient(reply: unknown): void;
broadcast: (...args: unknown[]) => void;
broadcastSessionOrder: (...args: unknown[]) => void;
};
};
const internals = server as unknown as TabLayoutInternals;
const commit = vi.spyOn(internals.store, 'commitTabLayoutProjection');
const layoutEvent = vi.spyOn(internals.sse, 'broadcast');
const orderEvent = vi.spyOn(internals.sse, 'broadcastSessionOrder');
const previousSessions = new Map(internals.sessions);
const storeState = internals.store.getState();
const previousSessionOrder = storeState.sessionOrder ? [...storeState.sessionOrder] : undefined;
const previousTabLayouts = storeState.tabLayouts ? structuredClone(storeState.tabLayouts) : undefined;
const recipientWrites: string[] = [];
const recipient = { raw: { write: (chunk: string) => (recipientWrites.push(chunk), true) } };
internals.sse.addClient(recipient, null, false);
const requested = {
version: 0,
groups: [],
ungrouped: [],
updatedAt: '2026-08-23T00:00:00.000Z',
};
try {
expect(internals.store.getTabLayout('@single')).toBeNull();
const direct = await fetch(`${base}/api/tab-layout`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseVersion: 0, layout: requested }),
});
expect(direct.status).toBe(200);
expect((await direct.json()).data.layout.version).toBe(1);
expect(commit).toHaveBeenCalledTimes(1);
expect(layoutEvent).toHaveBeenCalledTimes(1);
expect(layoutEvent).toHaveBeenCalledWith(SseEvent.TabLayoutChanged, { owner: '@single', version: 1 }, undefined);
expect(orderEvent).not.toHaveBeenCalled();
expect(recipientWrites).toEqual(['event: tab:layoutChanged\ndata: {"owner":"@single","version":1}\n\n']);
recipientWrites.length = 0;
const layoutBeforeConflict = internals.store.getTabLayout('@single');
const orderBeforeConflict = internals.store.getSessionOrder();
const writesBeforeConflict = commit.mock.calls.length;
const layoutEventsBeforeConflict = layoutEvent.mock.calls.length;
const orderEventsBeforeConflict = orderEvent.mock.calls.length;
const stale = await fetch(`${base}/api/tab-layout`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseVersion: 0, layout: requested }),
});
expect(stale.status).toBe(409);
expect((await stale.json()).errorCode).toBe('CONFLICT');
expect(internals.store.getTabLayout('@single')).toEqual(layoutBeforeConflict);
expect(internals.store.getSessionOrder()).toEqual(orderBeforeConflict);
expect(commit).toHaveBeenCalledTimes(writesBeforeConflict);
expect(layoutEvent).toHaveBeenCalledTimes(layoutEventsBeforeConflict);
expect(orderEvent).toHaveBeenCalledTimes(orderEventsBeforeConflict);
expect(recipientWrites).toEqual([]);
const ownerGet = await fetch(`${base}/api/tab-layout`);
expect(ownerGet.status).toBe(200);
expect((await ownerGet.json()).data.layout.version).toBe(1);
const firstId = 'tab-layout-smoke-a';
const secondId = 'tab-layout-smoke-b';
internals.sessions.set(firstId, { id: firstId, createdAt: 1 });
internals.sessions.set(secondId, { id: secondId, createdAt: 2 });
const orderEventsBeforeLegacy = orderEvent.mock.calls.length;
const legacyPut = await fetch(`${base}/api/session-order`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: [secondId, firstId] }),
});
expect(legacyPut.status).toBe(200);
expect((await legacyPut.json()).data.order).toEqual([secondId, firstId]);
expect(internals.store.getSessionOrder()).toEqual([secondId, firstId]);
expect(flattenOwnerSessionOrder(internals.store.getTabLayout('@single')!)).toEqual([secondId, firstId]);
expect(orderEvent).toHaveBeenCalledTimes(orderEventsBeforeLegacy + 1);
expect(orderEvent).toHaveBeenLastCalledWith({
changedOwnerOrders: { '@single': [secondId, firstId] },
globalOrder: [secondId, firstId],
globalChanged: true,
});
expect(recipientWrites.at(-1)).toBe(
`event: session:orderChanged\ndata: {"order":["${secondId}","${firstId}"]}\n\n`
);
} finally {
internals.sse.removeClient(recipient);
internals.sessions.clear();
for (const [id, session] of previousSessions) internals.sessions.set(id, session);
if (previousSessionOrder) storeState.sessionOrder = [...previousSessionOrder];
else delete storeState.sessionOrder;
if (previousTabLayouts) storeState.tabLayouts = structuredClone(previousTabLayouts);
else delete storeState.tabLayouts;
internals.store.save();
commit.mockRestore();
layoutEvent.mockRestore();
orderEvent.mockRestore();
}
expect(internals.store.getSessionOrder()).toEqual(previousSessionOrder ?? []);
expect(internals.store.getTabLayouts()).toEqual(previousTabLayouts ?? {});
});
it('serves the same envelope on the /api/v1 alias', async () => {
const res = await fetch(`${base}/api/v1/status`);
expect(res.status).toBe(200);