mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(tabs): COD-359 add owner-scoped tab layouts
This commit is contained in:
+180
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user