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:
@@ -434,6 +434,46 @@ describe('CronService', () => {
|
||||
});
|
||||
|
||||
describe('runNow', () => {
|
||||
it('awaits layout insertion and stops lifecycle work when registration rejects', async () => {
|
||||
const store = makeStore();
|
||||
const sessions = new Map<string, FakeSession>();
|
||||
let rejectRegistration!: (error: Error) => void;
|
||||
const addSession = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectRegistration = reject;
|
||||
})
|
||||
);
|
||||
const persistSessionState = vi.fn();
|
||||
const setupSessionListeners = vi.fn(async () => {});
|
||||
const service = new CronService({
|
||||
store,
|
||||
sessions,
|
||||
addSession,
|
||||
persistSessionState,
|
||||
setupSessionListeners,
|
||||
broadcast: vi.fn(),
|
||||
getGlobalNiceConfig: vi.fn(async () => undefined),
|
||||
getModelConfig: vi.fn(async () => null),
|
||||
getClaudeModeConfig: vi.fn(async () => ({})),
|
||||
getCheckpointDefaultEnabled: vi.fn(async () => true),
|
||||
mux: { backend: 'tmux' },
|
||||
} as unknown as CronDeps);
|
||||
const job = service.createJob(mkInput({ enabled: false }));
|
||||
|
||||
const pending = service.runNow(job.id);
|
||||
await vi.waitFor(() => expect(addSession).toHaveBeenCalledTimes(1));
|
||||
expect(persistSessionState).not.toHaveBeenCalled();
|
||||
expect(setupSessionListeners).not.toHaveBeenCalled();
|
||||
|
||||
rejectRegistration(new Error('layout capacity exceeded'));
|
||||
const run = await pending;
|
||||
expect(run!.status).toBe('failed');
|
||||
expect(run!.errorMessage).toMatch(/layout capacity exceeded/);
|
||||
expect(persistSessionState).not.toHaveBeenCalled();
|
||||
expect(setupSessionListeners).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('launches regardless of enabled/schedule state', async () => {
|
||||
const job = svc.service.createJob(mkInput({ enabled: false }));
|
||||
const run = await svc.service.runNow(job.id);
|
||||
|
||||
+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);
|
||||
|
||||
@@ -33,9 +33,30 @@ export function createMockRouteContext(options?: {
|
||||
return {
|
||||
// -- SessionPort --
|
||||
sessions,
|
||||
addSession: vi.fn((s: MockSession) => {
|
||||
addSession: vi.fn(async (s: MockSession) => {
|
||||
sessions.set(s.id, s);
|
||||
}),
|
||||
tabLayouts: {
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
putLegacyOrder: vi.fn(async (_actor: unknown, order: readonly string[]) => ({
|
||||
order: [...order],
|
||||
changedOwnerOrders: {},
|
||||
globalOrder: [...order],
|
||||
globalChanged: false,
|
||||
})),
|
||||
sessionCreated: vi.fn(),
|
||||
webviewCreated: vi.fn(),
|
||||
sessionsRemoved: vi.fn(async () => {}),
|
||||
webviewDeleted: vi.fn(async () => {}),
|
||||
markRestorationComplete: vi.fn(),
|
||||
markRestorationFailed: vi.fn(),
|
||||
markRestorationSkipped: vi.fn(),
|
||||
assertDeletionReady: vi.fn(),
|
||||
runSessionDeletion: vi.fn(async (_removed, action) => action()),
|
||||
runStaleSessionCleanup: vi.fn(async (_activeIds, action) => action(new Set())),
|
||||
reconcileAfterRestoration: vi.fn(async () => {}),
|
||||
},
|
||||
cleanupSession: vi.fn(async () => {}),
|
||||
setupSessionListeners: vi.fn(async () => {}),
|
||||
persistSessionState: vi.fn(),
|
||||
@@ -82,6 +103,7 @@ export function createMockRouteContext(options?: {
|
||||
getGlobalStats: vi.fn(() => ({ sessionsCreated: 0 })),
|
||||
getDailyStats: vi.fn(() => []),
|
||||
cleanupStaleSessions: vi.fn(() => ({ count: 0, cleaned: [] })),
|
||||
cleanupSessionsByIds: vi.fn(() => ({ count: 0, cleaned: [] })),
|
||||
},
|
||||
port: 3000,
|
||||
https: false,
|
||||
|
||||
@@ -461,6 +461,32 @@ describe('ralph-routes', () => {
|
||||
// ========== POST /api/ralph-loop/start ==========
|
||||
|
||||
describe('POST /api/ralph-loop/start', () => {
|
||||
it('awaits layout insertion and stops lifecycle work when registration rejects', async () => {
|
||||
let rejectRegistration!: (error: Error) => void;
|
||||
harness.ctx.addSession.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectRegistration = reject;
|
||||
})
|
||||
);
|
||||
|
||||
const pending = harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/ralph-loop/start',
|
||||
payload: { taskDescription: 'test task', completionPhrase: 'DONE', caseName: 'registration-order' },
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.ctx.addSession).toHaveBeenCalledTimes(1));
|
||||
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
|
||||
expect(harness.ctx.setupSessionListeners).not.toHaveBeenCalled();
|
||||
|
||||
rejectRegistration(new Error('layout capacity exceeded'));
|
||||
const response = await pending;
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
|
||||
expect(harness.ctx.setupSessionListeners).not.toHaveBeenCalled();
|
||||
expect(harness.ctx.broadcast).not.toHaveBeenCalledWith('session:created', expect.anything());
|
||||
});
|
||||
|
||||
it('rejects invalid request body', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* @fileoverview Tests for PUT /api/session-order (global tab-order sync, COD-131).
|
||||
* @fileoverview Tests for the synchronized legacy PUT /api/session-order endpoint.
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
* Asserts the uniform envelope contract:
|
||||
* SUCCESS -> 2xx, { success: true, data: { order } }
|
||||
* ERROR -> 4xx/5xx, { success: false, error, errorCode }
|
||||
* and that the order is persisted to the (mock) StateStore + broadcast over SSE.
|
||||
* Legacy callers are routed through the authenticated owner-scoped tab-layout service.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
@@ -13,6 +13,7 @@ import fastifyCookie from '@fastify/cookie';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { TabLayoutValidationError } from '../../src/tab-layout.js';
|
||||
|
||||
// registerSessionRoutes pulls in session.js which can shell out; stub the bits
|
||||
// that would touch the OS at import/registration time. None are needed by the
|
||||
@@ -29,11 +30,22 @@ interface LocalHarness {
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
async function buildHarness(): Promise<LocalHarness> {
|
||||
async function buildHarness(authUser = { username: 'alice', role: 'user' as const }): Promise<LocalHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
app.addHook('onRequest', async (req) => {
|
||||
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
|
||||
});
|
||||
|
||||
const ctx = createMockRouteContext();
|
||||
Object.assign(ctx.tabLayouts, {
|
||||
putLegacyOrder: vi.fn(async (_actor: unknown, order: string[]) => ({
|
||||
order: [...order],
|
||||
changedOwnerOrders: {},
|
||||
globalOrder: [...order],
|
||||
globalChanged: true,
|
||||
})),
|
||||
});
|
||||
registerSessionRoutes(app, ctx as unknown as Parameters<typeof registerSessionRoutes>[1]);
|
||||
|
||||
// Mirror production's uniform-envelope preSerialization hook (server.ts).
|
||||
@@ -60,35 +72,22 @@ describe('PUT /api/session-order', () => {
|
||||
let harness: LocalHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubEnv('CODEMAN_MULTIUSER', '1');
|
||||
harness = await buildHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('persists the order and returns it in the envelope', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/session-order',
|
||||
payload: { order: ['a', 'b', 'c'] },
|
||||
it('routes a regular legacy PUT through the authenticated owner layout service', async () => {
|
||||
vi.mocked(harness.ctx.tabLayouts.putLegacyOrder).mockResolvedValueOnce({
|
||||
order: ['a', 'b'],
|
||||
changedOwnerOrders: { alice: ['a', 'b'] },
|
||||
globalOrder: ['a', 'b'],
|
||||
globalChanged: true,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body).toEqual({ success: true, data: { order: ['a', 'b', 'c'] } });
|
||||
// Persisted to the store.
|
||||
expect(harness.ctx.store.setSessionOrder).toHaveBeenCalledWith(['a', 'b', 'c']);
|
||||
expect(harness.ctx.store.getSessionOrder()).toEqual(['a', 'b', 'c']);
|
||||
// Broadcast over SSE.
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['a', 'b', 'c'] });
|
||||
});
|
||||
|
||||
it('preserves a server-only id (unknown to the pushing device) at the end', async () => {
|
||||
// Seed the store with an order containing a server-only id "z".
|
||||
harness.ctx.store.setSessionOrder(['a', 'z', 'b']);
|
||||
(harness.ctx.broadcast as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/session-order',
|
||||
@@ -96,25 +95,24 @@ describe('PUT /api/session-order', () => {
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
// Incoming order wins, server-only "z" falls to the end.
|
||||
expect(body).toEqual({ success: true, data: { order: ['b', 'a', 'z'] } });
|
||||
expect(harness.ctx.store.getSessionOrder()).toEqual(['b', 'a', 'z']);
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['b', 'a', 'z'] });
|
||||
expect(harness.ctx.tabLayouts.putLegacyOrder).toHaveBeenCalledWith({ owner: 'alice', isAdmin: false }, ['b', 'a']);
|
||||
expect(res.json().data.order).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('normalizes junk input (dedup + drop empties) before persisting', async () => {
|
||||
it('uses the machine-wide admin bridge for an admin caller', async () => {
|
||||
await harness.app.close();
|
||||
harness = await buildHarness({ username: 'root', role: 'admin' });
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/session-order',
|
||||
payload: { order: ['a', 'a', '', 'b'] },
|
||||
payload: { order: ['b', 'a'] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ success: true, data: { order: ['a', 'b'] } });
|
||||
expect(harness.ctx.tabLayouts.putLegacyOrder).toHaveBeenCalledWith({ owner: 'root', isAdmin: true }, ['b', 'a']);
|
||||
});
|
||||
|
||||
it('rejects a non-array order with a 4xx envelope', async () => {
|
||||
it('rejects malformed bodies before invoking the service', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/session-order',
|
||||
@@ -124,6 +122,23 @@ describe('PUT /api/session-order', () => {
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(harness.ctx.store.setSessionOrder).not.toHaveBeenCalled();
|
||||
expect(harness.ctx.tabLayouts.putLegacyOrder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps owner-boundary validation failures to INVALID_INPUT', async () => {
|
||||
vi.mocked(harness.ctx.tabLayouts.putLegacyOrder).mockRejectedValueOnce(
|
||||
new TabLayoutValidationError('session is not owned by layout owner: foreign')
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/session-order',
|
||||
payload: { order: ['foreign'] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json()).toMatchObject({
|
||||
success: false,
|
||||
errorCode: ApiErrorCode.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -322,11 +322,16 @@ describe('system-routes', () => {
|
||||
|
||||
describe('POST /api/cleanup-state', () => {
|
||||
it('cleans up stale session state', async () => {
|
||||
const runStaleSessionCleanup = vi.fn(
|
||||
async (_activeIds: Set<string>, action: (ids: ReadonlySet<string>) => unknown) => action(new Set())
|
||||
);
|
||||
harness.ctx.tabLayouts.runStaleSessionCleanup = runStaleSessionCleanup;
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.cleanedSessions).toBe(0);
|
||||
expect(harness.ctx.store.cleanupStaleSessions).toHaveBeenCalled();
|
||||
expect(harness.ctx.store.cleanupSessionsByIds).toHaveBeenCalledWith(new Set());
|
||||
expect(runStaleSessionCleanup).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @fileoverview Owner-scoped tab-layout HTTP concurrency and validation contract.
|
||||
*/
|
||||
import Fastify from 'fastify';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { registerTabLayoutRoutes } from '../../src/web/routes/tab-layout-routes.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import type { TabLayout } from '../../src/tab-layout.js';
|
||||
|
||||
const layout = (version = 3): TabLayout => ({
|
||||
version,
|
||||
groups: [],
|
||||
ungrouped: [{ kind: 'session', id: 'mine' }],
|
||||
updatedAt: '2026-08-16T00:00:00.000Z',
|
||||
});
|
||||
|
||||
async function harness(username?: string, role: 'admin' | 'user' = 'user') {
|
||||
const app = Fastify({ logger: false });
|
||||
if (username) {
|
||||
app.addHook('onRequest', async (req) => {
|
||||
(req as unknown as { authUser: { username: string; role: 'admin' | 'user' } }).authUser = { username, role };
|
||||
});
|
||||
}
|
||||
const service = {
|
||||
get: vi.fn(async () => layout()),
|
||||
put: vi.fn(async (_owner: string, desired: unknown, baseVersion: number) => ({
|
||||
status: 'updated' as const,
|
||||
layout: { ...(desired as TabLayout), version: baseVersion + 1 },
|
||||
})),
|
||||
};
|
||||
registerTabLayoutRoutes(app, { tabLayouts: service } as never);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, service };
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
describe('tab layout routes', () => {
|
||||
it('maps single-user requests to @single and never accepts an owner override', async () => {
|
||||
vi.stubEnv('CODEMAN_MULTIUSER', '0');
|
||||
const { app, service } = await harness();
|
||||
const response = await app.inject({ method: 'GET', url: '/api/tab-layout?owner=foreign' });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(service.get).toHaveBeenCalledWith('@single');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('uses the authenticated username in multi-user mode, including for admins', async () => {
|
||||
vi.stubEnv('CODEMAN_MULTIUSER', '1');
|
||||
const { app, service } = await harness('admin-a', 'admin');
|
||||
await app.inject({ method: 'GET', url: '/api/tab-layout?owner=someone-else' });
|
||||
expect(service.get).toHaveBeenCalledWith('admin-a');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it.each([2, 4])('returns 409 with the authoritative prepared layout when baseVersion=%s', async (baseVersion) => {
|
||||
vi.stubEnv('CODEMAN_MULTIUSER', '1');
|
||||
const { app, service } = await harness('alice');
|
||||
service.put.mockResolvedValueOnce({ status: 'conflict', layout: layout(3) });
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/tab-layout',
|
||||
payload: { baseVersion, layout: layout(baseVersion) },
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().success).toBe(false);
|
||||
expect(response.json().errorCode).toBe('CONFLICT');
|
||||
expect(response.json().data.layout).toEqual(layout(3));
|
||||
expect(service.put).toHaveBeenCalledWith('alice', layout(baseVersion), baseVersion);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects malformed writes before invoking the service', async () => {
|
||||
const { app, service } = await harness();
|
||||
const response = await app.inject({ method: 'PUT', url: '/api/tab-layout', payload: { baseVersion: -1 } });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(service.put).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects extra write keys before invoking the service', async () => {
|
||||
const { app, service } = await harness();
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/tab-layout',
|
||||
payload: { baseVersion: 3, layout: layout(), owner: 'foreign' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().errorCode).toBe('INVALID_INPUT');
|
||||
expect(service.put).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('has an explicit conservative body limit', async () => {
|
||||
const { app } = await harness();
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/tab-layout',
|
||||
payload: { baseVersion: 3, layout: layout(), padding: 'x'.repeat(140 * 1024) },
|
||||
});
|
||||
expect(response.statusCode).toBe(413);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
* the developer's real ~/.codeman/webviews.json.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
@@ -16,17 +16,23 @@ import { registerWebviewRoutes } from '../../src/web/routes/webview-routes.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { webviewCapabilities } from '../../src/webview-capabilities.js';
|
||||
import { capabilityFromProxyPath } from '../../src/web/webview-proxy.js';
|
||||
import { TabLayoutService } from '../../src/tab-layout-service.js';
|
||||
import type { TabLayout } from '../../src/tab-layout.js';
|
||||
|
||||
let app: FastifyInstance;
|
||||
let tmpDir: string;
|
||||
let savedDataDir: string | undefined;
|
||||
const broadcasts: Array<{ event: string; data: unknown }> = [];
|
||||
const webviewCreated = vi.fn(async () => {});
|
||||
const webviewDeleted = vi.fn(async () => {});
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-webviews-'));
|
||||
savedDataDir = process.env.CODEMAN_DATA_DIR;
|
||||
process.env.CODEMAN_DATA_DIR = tmpDir;
|
||||
broadcasts.length = 0;
|
||||
webviewCreated.mockClear();
|
||||
webviewDeleted.mockClear();
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
@@ -34,6 +40,7 @@ beforeEach(async () => {
|
||||
await app.register(fastifyWebsocket);
|
||||
registerWebviewRoutes(app, {
|
||||
broadcast: (event: string, data: unknown) => broadcasts.push({ event, data }),
|
||||
tabLayouts: { webviewCreated, webviewDeleted },
|
||||
} as never);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
@@ -90,6 +97,58 @@ describe('POST /api/webviews', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('rolls back webview persistence and emits nothing when layout capacity rejects insertion', async () => {
|
||||
const refs = Array.from({ length: 512 }, (_, index) => ({ kind: 'session' as const, id: `s-${index}` }));
|
||||
const original: TabLayout = {
|
||||
version: 9,
|
||||
groups: [],
|
||||
ungrouped: refs,
|
||||
updatedAt: '2026-08-16T00:00:00.000Z',
|
||||
};
|
||||
let stored = original;
|
||||
const live = new Map(refs.map((ref, index) => [ref.id, { id: ref.id, createdAt: index }]));
|
||||
const atomicBroadcast = vi.fn();
|
||||
const service = new TabLayoutService({
|
||||
store: {
|
||||
getTabLayout: () => stored,
|
||||
setTabLayout: (_owner, layout) => {
|
||||
stored = layout;
|
||||
},
|
||||
getSessions: () => ({}),
|
||||
getSessionOrder: () => [],
|
||||
} as never,
|
||||
sessions: live,
|
||||
readWebviews: async () =>
|
||||
(await import('../../src/webview-store.js')).readWebviews(tmpDir) as Promise<
|
||||
Array<{ id: string; owner?: string }>
|
||||
>,
|
||||
broadcast: atomicBroadcast,
|
||||
broadcastSessionOrder: vi.fn(),
|
||||
});
|
||||
const atomicApp = Fastify({ logger: false });
|
||||
await atomicApp.register(fastifyCookie);
|
||||
await atomicApp.register(fastifyWebsocket);
|
||||
registerWebviewRoutes(atomicApp, {
|
||||
broadcast: atomicBroadcast,
|
||||
tabLayouts: service,
|
||||
} as never);
|
||||
installRouteErrorHandler(atomicApp);
|
||||
await atomicApp.ready();
|
||||
|
||||
const response = await atomicApp.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webviews',
|
||||
payload: { name: 'overflow', url: 'https://example.test/' },
|
||||
});
|
||||
const list = (await atomicApp.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(list).toEqual([]);
|
||||
expect(stored).toEqual(original);
|
||||
expect(atomicBroadcast).not.toHaveBeenCalled();
|
||||
await atomicApp.close();
|
||||
});
|
||||
|
||||
it('rejects URLs carrying embedded credentials', async () => {
|
||||
const res = await create({ name: 'bad', url: 'http://user:pass@host:4000/' });
|
||||
expect(res.statusCode).toBe(400);
|
||||
@@ -142,6 +201,19 @@ describe('DELETE /api/webviews/:id', () => {
|
||||
expect(webviewCapabilities.resolve(cap)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the exact saved record and emits nothing when layout deletion fails', async () => {
|
||||
const created = (await create({ name: 'Keep me', url: 'https://keep.example/' })).json().data;
|
||||
broadcasts.length = 0;
|
||||
webviewDeleted.mockRejectedValueOnce(new Error('tab layout restoration failed'));
|
||||
|
||||
const response = await app.inject({ method: 'DELETE', url: `/api/webviews/${created.id}` });
|
||||
const list = (await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(list).toEqual([created]);
|
||||
expect(broadcasts).toEqual([]);
|
||||
});
|
||||
|
||||
it('404s an unknown id', async () => {
|
||||
expect((await app.inject({ method: 'DELETE', url: '/api/webviews/nope' })).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
describe('scheduled session layout registration', () => {
|
||||
it('registers the layout before lifecycle work and rolls back a rejected tentative session', async () => {
|
||||
const sessions = new Map<string, { id: string; owner?: string }>();
|
||||
const session = { id: 'scheduled-session', owner: 'alice' };
|
||||
const sessionCreated = vi.fn(async () => {
|
||||
throw new Error('layout unavailable');
|
||||
});
|
||||
const server = Object.create(WebServer.prototype) as {
|
||||
sessions: typeof sessions;
|
||||
tabLayouts: { sessionCreated: typeof sessionCreated };
|
||||
registerSessionWithLayout(session: typeof session): Promise<void>;
|
||||
};
|
||||
server.sessions = sessions;
|
||||
server.tabLayouts = { sessionCreated };
|
||||
|
||||
await expect(server.registerSessionWithLayout(session)).rejects.toThrow('layout unavailable');
|
||||
|
||||
expect(sessionCreated).toHaveBeenCalledWith('alice');
|
||||
expect(sessions.has(session.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the shared registration helper in the scheduled loop before persistence and listeners', () => {
|
||||
const source = readFileSync(new URL('../src/web/server.ts', import.meta.url), 'utf8');
|
||||
const loop = source.slice(
|
||||
source.indexOf('private async runScheduledLoop'),
|
||||
source.indexOf('private async stopScheduledRun')
|
||||
);
|
||||
|
||||
expect(loop).toContain('await this.registerSessionWithLayout(session);');
|
||||
expect(loop.indexOf('await this.registerSessionWithLayout(session);')).toBeLessThan(
|
||||
loop.indexOf('this.store.incrementSessionsCreated();')
|
||||
);
|
||||
expect(loop.indexOf('await this.registerSessionWithLayout(session);')).toBeLessThan(
|
||||
loop.indexOf('await this.setupSessionListeners(session);')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
|
||||
const TEST_PORT = 3120;
|
||||
const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||
@@ -15,10 +15,12 @@ const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||
describe('Session Cleanup', () => {
|
||||
let server: WebServer;
|
||||
let baseUrl: string;
|
||||
let testWorkingDir: string;
|
||||
const createdCases: string[] = [];
|
||||
const createdSessions: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
testWorkingDir = mkdtempSync(join(tmpdir(), 'codeman-cleanup-test-'));
|
||||
server = new WebServer(TEST_PORT, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TEST_PORT}`;
|
||||
@@ -43,9 +45,36 @@ describe('Session Cleanup', () => {
|
||||
} catch {}
|
||||
}
|
||||
await server.stop();
|
||||
rmSync(testWorkingDir, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
describe('Session Deletion', () => {
|
||||
it('rejects before stopping the session when layout deletion preparation fails', async () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: testWorkingDir }),
|
||||
});
|
||||
const created = await createRes.json();
|
||||
const id = created.data.session.id;
|
||||
createdSessions.push(id);
|
||||
const internals = server as unknown as {
|
||||
tabLayouts: { runSessionDeletion: (...args: unknown[]) => Promise<unknown> };
|
||||
};
|
||||
const deletionSpy = vi
|
||||
.spyOn(internals.tabLayouts, 'runSessionDeletion')
|
||||
.mockRejectedValueOnce(new Error('layout prune unavailable'));
|
||||
try {
|
||||
const deleteRes = await fetch(`${baseUrl}/api/sessions/${id}`, { method: 'DELETE' });
|
||||
const getRes = await fetch(`${baseUrl}/api/sessions/${id}`);
|
||||
|
||||
expect(deleteRes.status).toBe(500);
|
||||
expect(getRes.status).toBe(200);
|
||||
} finally {
|
||||
deletionSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should properly stop and cleanup interactive session', async () => {
|
||||
const caseName = `cleanup-test-${Date.now()}`;
|
||||
createdCases.push(caseName);
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/** @fileoverview Trusted recipient selection for legacy session-order SSE compatibility. */
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SessionOrderProjectionChange } from '../src/tab-layout-service.js';
|
||||
import { CleanupManager } from '../src/utils/index.js';
|
||||
import { sessionOrderPayloadFor } from '../src/web/session-order-sse.js';
|
||||
import { SseStreamManager } from '../src/web/sse-stream-manager.js';
|
||||
|
||||
function changeWith(
|
||||
changedOwnerOrders: Record<string, string[]>,
|
||||
globalOrder: string[],
|
||||
globalChanged: boolean
|
||||
): SessionOrderProjectionChange {
|
||||
return { changedOwnerOrders, globalOrder, globalChanged };
|
||||
}
|
||||
|
||||
function client() {
|
||||
const writes: string[] = [];
|
||||
return {
|
||||
writes,
|
||||
reply: { raw: { write: (chunk: string) => (writes.push(chunk), true) } } as unknown as FastifyReply,
|
||||
};
|
||||
}
|
||||
|
||||
function backpressuredClient(options: { throwAfterBackpressure?: boolean } = {}) {
|
||||
const writes: string[] = [];
|
||||
let firstWrite = true;
|
||||
let onDrain: (() => void) | undefined;
|
||||
const raw = {
|
||||
write(chunk: string) {
|
||||
if (!firstWrite && options.throwAfterBackpressure) throw new Error('client disconnected');
|
||||
writes.push(chunk);
|
||||
if (firstWrite) {
|
||||
firstWrite = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
once(event: string, callback: () => void) {
|
||||
if (event === 'drain') onDrain = callback;
|
||||
return raw;
|
||||
},
|
||||
};
|
||||
return {
|
||||
writes,
|
||||
reply: { raw } as unknown as FastifyReply,
|
||||
drain: () => {
|
||||
const callback = onDrain;
|
||||
onDrain = undefined;
|
||||
callback?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('legacy session-order SSE routing', () => {
|
||||
it('selects an owner slice for the matching regular user and nothing for another user', () => {
|
||||
const change = changeWith({ alice: ['a2', 'a1'] }, ['a2', 'b1', 'a1'], true);
|
||||
expect(sessionOrderPayloadFor({ username: 'alice', role: 'user' }, change)).toEqual({ order: ['a2', 'a1'] });
|
||||
expect(sessionOrderPayloadFor({ username: 'bob', role: 'user' }, change)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not treat inherited object properties as changed owner slices', () => {
|
||||
const change = changeWith({ alice: ['a1'] }, ['a1'], true);
|
||||
expect(sessionOrderPayloadFor({ username: 'constructor', role: 'user' }, change)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('selects the global projection for admins even when only interleaving changed', () => {
|
||||
const change = changeWith({}, ['b1', 'a1'], true);
|
||||
expect(sessionOrderPayloadFor({ username: 'admin', role: 'admin' }, change)).toEqual({ order: ['b1', 'a1'] });
|
||||
});
|
||||
|
||||
it('uses the global projection for identity-less single-user clients', () => {
|
||||
const change = changeWith({ '@single': ['s2', 's1'] }, ['s2', 's1'], true);
|
||||
expect(sessionOrderPayloadFor(undefined, change)).toEqual({ order: ['s2', 's1'] });
|
||||
});
|
||||
|
||||
it('delivers owner slices to every matching device, the global order to admins, and nothing to other users', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const alicePhone = client();
|
||||
const aliceDesktop = client();
|
||||
const bob = client();
|
||||
const admin = client();
|
||||
manager.addClient(alicePhone.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(aliceDesktop.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
|
||||
manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'b1', 'a1'], true));
|
||||
|
||||
const ownerFrame = 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n';
|
||||
expect(alicePhone.writes).toEqual([ownerFrame]);
|
||||
expect(aliceDesktop.writes).toEqual([ownerFrame]);
|
||||
expect(bob.writes).toEqual([]);
|
||||
expect(admin.writes).toEqual(['event: session:orderChanged\ndata: {"order":["a2","b1","a1"]}\n\n']);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('delivers a global-only interleaving change to admins only', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const alice = client();
|
||||
const admin = client();
|
||||
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
|
||||
manager.broadcastSessionOrder(changeWith({}, ['b1', 'a1'], true));
|
||||
|
||||
expect(alice.writes).toEqual([]);
|
||||
expect(admin.writes).toEqual(['event: session:orderChanged\ndata: {"order":["b1","a1"]}\n\n']);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'order-only repair',
|
||||
change: changeWith({ alice: ['a', 'b'] }, ['a', 'bob-1', 'b'], true),
|
||||
ownerOrder: ['a', 'b'],
|
||||
globalOrder: ['a', 'bob-1', 'b'],
|
||||
},
|
||||
{
|
||||
label: 'legacy-only deletion',
|
||||
change: changeWith({ alice: [] }, ['bob-1'], true),
|
||||
ownerOrder: [],
|
||||
globalOrder: ['bob-1'],
|
||||
},
|
||||
])('delivers an $label correction to same-owner devices and admins only', ({ change, ownerOrder, globalOrder }) => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const alicePhone = client();
|
||||
const aliceDesktop = client();
|
||||
const bob = client();
|
||||
const admin = client();
|
||||
manager.addClient(alicePhone.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(aliceDesktop.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
|
||||
manager.broadcastSessionOrder(change);
|
||||
|
||||
const ownerFrame = `event: session:orderChanged\ndata: ${JSON.stringify({ order: ownerOrder })}\n\n`;
|
||||
expect(alicePhone.writes).toEqual([ownerFrame]);
|
||||
expect(aliceDesktop.writes).toEqual([ownerFrame]);
|
||||
expect(bob.writes).toEqual([]);
|
||||
expect(admin.writes).toEqual([`event: session:orderChanged\ndata: ${JSON.stringify({ order: globalOrder })}\n\n`]);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('skips an inherited-key username without starving later matching and admin recipients', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const constructorUser = client();
|
||||
const alice = client();
|
||||
const admin = client();
|
||||
manager.addClient(constructorUser.reply, null, false, undefined, { username: 'constructor', role: 'user' });
|
||||
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
|
||||
expect(() => manager.broadcastSessionOrder(changeWith({ alice: ['a1'] }, ['a1'], true))).not.toThrow();
|
||||
|
||||
expect(constructorUser.writes).toEqual([]);
|
||||
expect(alice.writes).toEqual(['event: session:orderChanged\ndata: {"order":["a1"]}\n\n']);
|
||||
expect(admin.writes).toEqual(['event: session:orderChanged\ndata: {"order":["a1"]}\n\n']);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('coalesces the latest filtered owner order while backpressured and flushes it on drain', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const blockedAlice = backpressuredClient();
|
||||
const liveAlice = client();
|
||||
const bob = client();
|
||||
const admin = client();
|
||||
manager.addClient(blockedAlice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(liveAlice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
|
||||
manager.broadcast('test:prime-backpressure', {}, { username: 'alice' });
|
||||
for (const writes of [blockedAlice.writes, liveAlice.writes, bob.writes, admin.writes]) writes.length = 0;
|
||||
|
||||
manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'b1', 'a1'], true));
|
||||
manager.broadcastSessionOrder(changeWith({ alice: ['a1', 'a2'] }, ['b1', 'a1', 'a2'], true));
|
||||
|
||||
expect(blockedAlice.writes).toEqual([]);
|
||||
expect(liveAlice.writes).toEqual([
|
||||
'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n',
|
||||
'event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n',
|
||||
]);
|
||||
expect(bob.writes).toEqual([]);
|
||||
expect(admin.writes).toEqual([
|
||||
'event: session:orderChanged\ndata: {"order":["a2","b1","a1"]}\n\n',
|
||||
'event: session:orderChanged\ndata: {"order":["b1","a1","a2"]}\n\n',
|
||||
]);
|
||||
|
||||
blockedAlice.drain();
|
||||
|
||||
expect(blockedAlice.writes).toEqual([
|
||||
'event: session:needsRefresh\ndata: {}\n\n',
|
||||
'event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n',
|
||||
]);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('clears a queued owner order when a backpressured client disconnects', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const alice = backpressuredClient();
|
||||
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.broadcast('test:prime-backpressure', {}, { username: 'alice' });
|
||||
alice.writes.length = 0;
|
||||
manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'a1'], true));
|
||||
|
||||
manager.removeClient(alice.reply);
|
||||
alice.drain();
|
||||
|
||||
expect(alice.writes).toEqual([]);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('isolates a drain write failure from later healthy recipients', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const brokenAlice = backpressuredClient({ throwAfterBackpressure: true });
|
||||
const liveAlice = client();
|
||||
const admin = client();
|
||||
manager.addClient(brokenAlice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(liveAlice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
manager.broadcast('test:prime-backpressure', {}, { username: 'alice' });
|
||||
brokenAlice.writes.length = 0;
|
||||
liveAlice.writes.length = 0;
|
||||
admin.writes.length = 0;
|
||||
manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'a1'], true));
|
||||
|
||||
expect(() => brokenAlice.drain()).not.toThrow();
|
||||
expect(manager.clientCount).toBe(2);
|
||||
|
||||
manager.broadcastSessionOrder(changeWith({ alice: ['a1', 'a2'] }, ['a1', 'a2'], true));
|
||||
expect(liveAlice.writes.at(-1)).toBe('event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n');
|
||||
expect(admin.writes.at(-1)).toBe('event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n');
|
||||
cleanup.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @fileoverview Atomic StateStore publication tests for tab layouts and their legacy session-order projection.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { StateStore } from '../src/state-store.js';
|
||||
import { recomposeGlobalSessionOrder } from '../src/tab-layout-legacy-order.js';
|
||||
import type { TabLayout } from '../src/tab-layout.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createStore(seed: { sessionOrder?: string[]; tabLayouts?: Record<string, TabLayout> } = {}): StateStore {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-state-tab-layout-'));
|
||||
tempDirs.push(dir);
|
||||
const file = join(dir, 'state.json');
|
||||
writeFileSync(file, JSON.stringify(seed));
|
||||
return new StateStore(file);
|
||||
}
|
||||
|
||||
const ownerLayout = (ids: readonly string[], version = 1): TabLayout => ({
|
||||
version,
|
||||
groups: [],
|
||||
ungrouped: ids.map((id) => ({ kind: 'session', id })),
|
||||
updatedAt: `2026-08-23T12:00:0${version}.000Z`,
|
||||
});
|
||||
|
||||
describe('StateStore.commitTabLayoutProjection', () => {
|
||||
it('returns a defensive snapshot of all stored owner layouts for trusted owner discovery', () => {
|
||||
const alice = ownerLayout(['a1'], 1);
|
||||
const store = createStore({ tabLayouts: { alice, constructor: ownerLayout(['c1'], 2) } });
|
||||
|
||||
const layouts = store.getTabLayouts();
|
||||
|
||||
expect(Object.keys(layouts)).toEqual(['alice', 'constructor']);
|
||||
layouts.alice.ungrouped[0].id = 'caller-mutated';
|
||||
expect(store.getTabLayout('alice')).toEqual(alice);
|
||||
});
|
||||
|
||||
it('validates all layouts and publishes them with a latest-state projection using one save schedule', () => {
|
||||
const originalAlice = ownerLayout(['a1'], 1);
|
||||
const store = createStore({ sessionOrder: ['a1'], tabLayouts: { alice: originalAlice } });
|
||||
const save = vi.spyOn(store, 'save').mockImplementation(() => undefined);
|
||||
const alice = ownerLayout(['a2', 'a1'], 2);
|
||||
const constructorOwner = ownerLayout(['constructor'], 1);
|
||||
const projected = ['a2', 'a1', 'constructor', 'a2'];
|
||||
|
||||
const result = store.commitTabLayoutProjection({ alice, constructor: constructorOwner }, (latest) => {
|
||||
expect(latest).toEqual(['a1']);
|
||||
expect(store.getSessionOrder()).toEqual(['a1']);
|
||||
expect(store.getTabLayout('alice')).toEqual(originalAlice);
|
||||
(latest as string[]).push('projector-local-mutation');
|
||||
return projected;
|
||||
});
|
||||
|
||||
expect(store.getTabLayout('alice')).toEqual(alice);
|
||||
expect(store.getTabLayout('constructor')).toEqual(constructorOwner);
|
||||
expect(store.getSessionOrder()).toEqual(['a2', 'a1', 'constructor']);
|
||||
expect(result).toEqual({
|
||||
layouts: { alice, constructor: constructorOwner },
|
||||
sessionOrder: ['a2', 'a1', 'constructor'],
|
||||
});
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
|
||||
alice.ungrouped[0].id = 'caller-mutated';
|
||||
constructorOwner.ungrouped[0].id = 'caller-mutated';
|
||||
projected[0] = 'caller-mutated';
|
||||
result.layouts.alice.ungrouped[0].id = 'return-mutated';
|
||||
result.layouts.constructor.ungrouped[0].id = 'return-mutated';
|
||||
result.sessionOrder[0] = 'return-mutated';
|
||||
|
||||
expect(store.getTabLayout('alice')?.ungrouped[0].id).toBe('a2');
|
||||
expect(store.getTabLayout('constructor')?.ungrouped[0].id).toBe('constructor');
|
||||
expect(store.getSessionOrder()).toEqual(['a2', 'a1', 'constructor']);
|
||||
});
|
||||
|
||||
it('changes neither representation and does not project or save when any layout is invalid', () => {
|
||||
const original = ownerLayout(['a1'], 1);
|
||||
const store = createStore({ sessionOrder: ['a1'], tabLayouts: { alice: original } });
|
||||
const save = vi.spyOn(store, 'save').mockImplementation(() => undefined);
|
||||
const project = vi.fn(() => ['a2']);
|
||||
const invalid = { ...ownerLayout(['b1'], 2), version: -1 };
|
||||
|
||||
expect(() => store.commitTabLayoutProjection({ bob: ownerLayout(['b1'], 2), alice: invalid }, project)).toThrow(
|
||||
/version/
|
||||
);
|
||||
|
||||
expect(project).not.toHaveBeenCalled();
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(store.getTabLayout('alice')).toEqual(original);
|
||||
expect(store.getTabLayout('bob')).toBeNull();
|
||||
expect(store.getSessionOrder()).toEqual(['a1']);
|
||||
});
|
||||
|
||||
it('changes neither representation and does not save when projection throws', () => {
|
||||
const original = ownerLayout(['a1'], 1);
|
||||
const store = createStore({ sessionOrder: ['a1'], tabLayouts: { alice: original } });
|
||||
const save = vi.spyOn(store, 'save').mockImplementation(() => undefined);
|
||||
|
||||
expect(() =>
|
||||
store.commitTabLayoutProjection({ alice: ownerLayout(['a2'], 2) }, () => {
|
||||
throw new Error('projection rejected');
|
||||
})
|
||||
).toThrow('projection rejected');
|
||||
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(store.getTabLayout('alice')).toEqual(original);
|
||||
expect(store.getSessionOrder()).toEqual(['a1']);
|
||||
});
|
||||
|
||||
it('derives sequential owner projections from the latest committed order', () => {
|
||||
const store = createStore({ sessionOrder: ['a1', 'b1', 'a2', 'b2'] });
|
||||
const save = vi.spyOn(store, 'save').mockImplementation(() => undefined);
|
||||
|
||||
store.commitTabLayoutProjection({ alice: ownerLayout(['a2', 'a1'], 2) }, (latest) =>
|
||||
recomposeGlobalSessionOrder(latest, [{ owner: 'alice', ownedIds: ['a1', 'a2'], order: ['a2', 'a1'] }])
|
||||
);
|
||||
store.commitTabLayoutProjection({ bob: ownerLayout(['b2', 'b1'], 2) }, (latest) =>
|
||||
recomposeGlobalSessionOrder(latest, [{ owner: 'bob', ownedIds: ['b1', 'b2'], order: ['b2', 'b1'] }])
|
||||
);
|
||||
|
||||
expect(store.getSessionOrder()).toEqual(['a2', 'b2', 'a1', 'b1']);
|
||||
expect(store.getTabLayout('alice')).toEqual(ownerLayout(['a2', 'a1'], 2));
|
||||
expect(store.getTabLayout('bob')).toEqual(ownerLayout(['b2', 'b1'], 2));
|
||||
expect(save).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -199,6 +199,20 @@ describe('StateStore', () => {
|
||||
expect(store.getSession('pinned-1')).not.toBeNull();
|
||||
expect(store.getSession('plain-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('cleans only requested unpinned session ids', () => {
|
||||
const store = new StateStore(testFilePath);
|
||||
store.setSession('requested', createMockSessionState('requested'));
|
||||
store.setSession('pinned', { ...createMockSessionState('pinned'), pinned: true, pinnedAt: Date.now() });
|
||||
store.setSession('unrequested', createMockSessionState('unrequested'));
|
||||
|
||||
const result = store.cleanupSessionsByIds(new Set(['requested', 'pinned', 'missing']));
|
||||
|
||||
expect(result.cleaned.map((session) => session.id)).toEqual(['requested']);
|
||||
expect(store.getSession('requested')).toBeNull();
|
||||
expect(store.getSession('pinned')).not.toBeNull();
|
||||
expect(store.getSession('unrequested')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('task operations', () => {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @fileoverview Pure compatibility translation between legacy session order and owner tab layouts.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyLegacySessionRank,
|
||||
recomposeGlobalSessionOrder,
|
||||
type OwnerOrderProjection,
|
||||
} from '../src/tab-layout-legacy-order.js';
|
||||
import { normalizeTabLayout, validateTabLayout, type TabLayout, type TabRefMetadata } from '../src/tab-layout.js';
|
||||
|
||||
const session = (id: string, placement?: 'manual') =>
|
||||
placement ? { kind: 'session' as const, id, placement } : { kind: 'session' as const, id };
|
||||
const webview = (id: string) => ({ kind: 'webview' as const, id });
|
||||
const metadata = (id: string, overrides: Partial<TabRefMetadata> = {}): TabRefMetadata => ({
|
||||
kind: 'session',
|
||||
id,
|
||||
ownerValid: true,
|
||||
visible: true,
|
||||
order: 0,
|
||||
...overrides,
|
||||
});
|
||||
const layout = (overrides: Partial<TabLayout> = {}): TabLayout => ({
|
||||
version: 1,
|
||||
groups: [],
|
||||
ungrouped: [],
|
||||
updatedAt: '2026-08-23T12:00:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('applyLegacySessionRank', () => {
|
||||
it('ranks only authoritative sessions while anchoring webviews and refs absent from metadata', () => {
|
||||
const input = layout({
|
||||
ungrouped: [session('a'), webview('w1'), session('unknown'), session('b')],
|
||||
});
|
||||
|
||||
expect(applyLegacySessionRank(input, ['b', 'a'], [metadata('a'), metadata('b')])).toEqual(
|
||||
layout({
|
||||
ungrouped: [session('b'), webview('w1'), session('unknown'), session('a')],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('ranks sessions only inside their current containers without changing groups or webview slots', () => {
|
||||
const input = layout({
|
||||
groups: [
|
||||
{ id: 'first', name: 'First', refs: [session('a'), webview('w1'), session('d')] },
|
||||
{ id: 'second', name: 'Second', refs: [session('b'), webview('w2'), session('c')] },
|
||||
],
|
||||
ungrouped: [session('e'), webview('w3'), session('f')],
|
||||
});
|
||||
const facts = ['a', 'b', 'c', 'd', 'e', 'f'].map((id, order) => metadata(id, { order }));
|
||||
|
||||
const result = applyLegacySessionRank(input, ['f', 'c', 'd', 'e', 'b', 'a'], facts);
|
||||
|
||||
expect(result.groups).toEqual([
|
||||
{ id: 'first', name: 'First', refs: [session('d'), webview('w1'), session('a')] },
|
||||
{ id: 'second', name: 'Second', refs: [session('c'), webview('w2'), session('b')] },
|
||||
]);
|
||||
expect(result.ungrouped).toEqual([session('f'), webview('w3'), session('e')]);
|
||||
});
|
||||
|
||||
it('materializes ranked children with represented owner-valid parents before normalization', () => {
|
||||
const input = layout({
|
||||
groups: [
|
||||
{
|
||||
id: 'family',
|
||||
name: 'Family',
|
||||
refs: [session('parent'), session('child'), session('other')],
|
||||
},
|
||||
],
|
||||
});
|
||||
const facts = [
|
||||
metadata('parent', { order: 0 }),
|
||||
metadata('child', { order: 1, parentSessionId: 'parent' }),
|
||||
metadata('other', { order: 2 }),
|
||||
];
|
||||
|
||||
const result = applyLegacySessionRank(input, ['child', 'other', 'parent'], facts);
|
||||
|
||||
expect(result.groups[0].refs).toEqual([session('child', 'manual'), session('other'), session('parent')]);
|
||||
expect(normalizeTabLayout(result, facts)).toEqual(result);
|
||||
});
|
||||
|
||||
it('keeps a ranked child manual in its group when normalization materializes its missing parent', () => {
|
||||
const input = layout({
|
||||
groups: [{ id: 'child-group', name: 'Child', refs: [session('child')] }],
|
||||
});
|
||||
const facts = [metadata('parent', { order: 0 }), metadata('child', { order: 1, parentSessionId: 'parent' })];
|
||||
|
||||
const result = applyLegacySessionRank(input, ['child', 'parent'], facts);
|
||||
|
||||
expect(result.groups[0].refs).toEqual([session('child', 'manual')]);
|
||||
expect(result.ungrouped).toEqual([session('parent')]);
|
||||
expect(normalizeTabLayout(result, facts)).toEqual(result);
|
||||
});
|
||||
|
||||
it('does not mutate inputs and returns a validated canonical deep clone', () => {
|
||||
const input = layout({
|
||||
groups: [{ id: 'g', name: ' Work ', refs: [session('a'), webview('w')] }],
|
||||
ungrouped: [session('b')],
|
||||
});
|
||||
const requested = ['b', 'a'];
|
||||
const facts = [metadata('a', { order: 0 }), metadata('b', { order: 1 })];
|
||||
const beforeInput = structuredClone(input);
|
||||
const beforeRequested = [...requested];
|
||||
const beforeFacts = structuredClone(facts);
|
||||
|
||||
const result = applyLegacySessionRank(input, requested, facts);
|
||||
|
||||
expect(input).toEqual(beforeInput);
|
||||
expect(requested).toEqual(beforeRequested);
|
||||
expect(facts).toEqual(beforeFacts);
|
||||
expect(result.groups[0].name).toBe('Work');
|
||||
expect(validateTabLayout(result)).toEqual(result);
|
||||
expect(result).not.toBe(input);
|
||||
expect(result.groups[0]).not.toBe(input.groups[0]);
|
||||
expect(result.groups[0].refs[0]).not.toBe(input.groups[0].refs[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recomposeGlobalSessionOrder', () => {
|
||||
it('replaces only owner slots, preserves interleaving, appends new IDs once, and deduplicates', () => {
|
||||
const current = ['a1', 'foreign', 'b1', 'a2', 'unmapped', 'a1'];
|
||||
const projections: OwnerOrderProjection[] = [
|
||||
{
|
||||
owner: 'alice',
|
||||
ownedIds: ['a1', 'a2', 'a3', 'a3'],
|
||||
order: ['a3', 'a2', 'a2', 'a1', 'foreign'],
|
||||
},
|
||||
];
|
||||
const beforeCurrent = [...current];
|
||||
const beforeProjections = structuredClone(projections);
|
||||
|
||||
const result = recomposeGlobalSessionOrder(current, projections);
|
||||
|
||||
expect(result).toEqual(['a3', 'foreign', 'b1', 'a2', 'unmapped', 'a1']);
|
||||
expect(current).toEqual(beforeCurrent);
|
||||
expect(projections).toEqual(beforeProjections);
|
||||
});
|
||||
|
||||
it('starts from merged preferred order when preserving admin cross-owner intent', () => {
|
||||
expect(
|
||||
recomposeGlobalSessionOrder(
|
||||
['a1', 'b1', 'a2', 'server-only'],
|
||||
[{ owner: 'alice', ownedIds: ['a1', 'a2'], order: ['a2', 'a1'] }],
|
||||
['b1', 'a1', 'a2']
|
||||
)
|
||||
).toEqual(['b1', 'a2', 'a1', 'server-only']);
|
||||
});
|
||||
|
||||
it('derives sequential owner projections from the latest order without a lost update', () => {
|
||||
const initial = ['a1', 'b1', 'a2', 'b2'];
|
||||
const afterAlice = recomposeGlobalSessionOrder(initial, [
|
||||
{ owner: 'alice', ownedIds: ['a1', 'a2'], order: ['a2', 'a1'] },
|
||||
]);
|
||||
const afterBob = recomposeGlobalSessionOrder(afterAlice, [
|
||||
{ owner: 'bob', ownedIds: ['b1', 'b2'], order: ['b2', 'b1'] },
|
||||
]);
|
||||
|
||||
expect(afterAlice).toEqual(['a2', 'b1', 'a1', 'b2']);
|
||||
expect(afterBob).toEqual(['a2', 'b2', 'a1', 'b1']);
|
||||
});
|
||||
|
||||
it('handles prototype-like owner names and IDs without corrupting membership', () => {
|
||||
expect(
|
||||
recomposeGlobalSessionOrder(
|
||||
['__proto__', 'foreign', 'constructor'],
|
||||
[
|
||||
{
|
||||
owner: 'constructor',
|
||||
ownedIds: ['__proto__', 'constructor', 'toString'],
|
||||
order: ['toString', 'constructor', '__proto__'],
|
||||
},
|
||||
]
|
||||
)
|
||||
).toEqual(['toString', 'foreign', 'constructor', '__proto__']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/** @fileoverview Startup restoration must gate destructive tab-layout reconciliation. */
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
describe('tab layout restore gate', () => {
|
||||
it('does not unlock pruning, cleanup stale state, or reconcile after a failed restore', async () => {
|
||||
const markRestorationComplete = vi.fn();
|
||||
const markRestorationFailed = vi.fn();
|
||||
const reconcileAfterRestoration = vi.fn(async () => {});
|
||||
const cleanupStaleSessions = vi.fn(async () => 0);
|
||||
const server = Object.create(WebServer.prototype) as {
|
||||
tabLayouts: {
|
||||
markRestorationComplete: typeof markRestorationComplete;
|
||||
markRestorationFailed: typeof markRestorationFailed;
|
||||
reconcileAfterRestoration: typeof reconcileAfterRestoration;
|
||||
};
|
||||
cleanupStaleSessions: typeof cleanupStaleSessions;
|
||||
mux: { reconcileSessions(): Promise<never> };
|
||||
restoreMuxSessions(): Promise<boolean>;
|
||||
finalizeRestoredState(restored: boolean): Promise<void>;
|
||||
};
|
||||
server.tabLayouts = { markRestorationComplete, markRestorationFailed, reconcileAfterRestoration };
|
||||
server.cleanupStaleSessions = cleanupStaleSessions;
|
||||
server.mux = { reconcileSessions: vi.fn(async () => Promise.reject(new Error('mux unavailable'))) };
|
||||
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const restored = await server.restoreMuxSessions();
|
||||
await server.finalizeRestoredState(restored);
|
||||
|
||||
expect(restored).toBe(false);
|
||||
expect(markRestorationComplete).not.toHaveBeenCalled();
|
||||
expect(markRestorationFailed).toHaveBeenCalledTimes(1);
|
||||
expect(cleanupStaleSessions).not.toHaveBeenCalled();
|
||||
expect(reconcileAfterRestoration).not.toHaveBeenCalled();
|
||||
expect(errorLog).toHaveBeenCalledWith('[Server] Failed to restore mux sessions:', expect.any(Error));
|
||||
});
|
||||
|
||||
it('unlocks cleanup and reconciliation only after successful restoration', async () => {
|
||||
const order: string[] = [];
|
||||
const server = Object.create(WebServer.prototype) as {
|
||||
tabLayouts: {
|
||||
markRestorationComplete(): void;
|
||||
markRestorationFailed(): void;
|
||||
reconcileAfterRestoration(): Promise<void>;
|
||||
};
|
||||
cleanupStaleSessions(): Promise<number>;
|
||||
finalizeRestoredState(restored: boolean): Promise<void>;
|
||||
};
|
||||
server.tabLayouts = {
|
||||
markRestorationComplete: () => order.push('complete'),
|
||||
markRestorationFailed: () => order.push('failed'),
|
||||
reconcileAfterRestoration: async () => {
|
||||
order.push('reconcile');
|
||||
},
|
||||
};
|
||||
server.cleanupStaleSessions = async () => (order.push('cleanup'), 0);
|
||||
|
||||
await server.finalizeRestoredState(true);
|
||||
|
||||
expect(order).toEqual(['complete', 'cleanup', 'reconcile']);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,305 @@
|
||||
/** @fileoverview Minimal tab-layout SSE payload routing to owner plus admins. */
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SessionOrderProjectionChange } from '../src/tab-layout-service.js';
|
||||
import { CleanupManager } from '../src/utils/index.js';
|
||||
import { SseStreamManager } from '../src/web/sse-stream-manager.js';
|
||||
import { deriveTabLayoutSseHint } from '../src/web/tab-layout-sse.js';
|
||||
|
||||
function client() {
|
||||
const writes: string[] = [];
|
||||
return {
|
||||
writes,
|
||||
reply: { raw: { write: (chunk: string) => (writes.push(chunk), true) } } as unknown as FastifyReply,
|
||||
};
|
||||
}
|
||||
|
||||
function backpressuredClient(options: { throwAfterBackpressure?: boolean } = {}) {
|
||||
const writes: string[] = [];
|
||||
let firstWrite = true;
|
||||
let onDrain: (() => void) | undefined;
|
||||
const raw = {
|
||||
write(chunk: string) {
|
||||
if (!firstWrite && options.throwAfterBackpressure) throw new Error('client disconnected');
|
||||
writes.push(chunk);
|
||||
if (firstWrite) {
|
||||
firstWrite = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
once(event: string, callback: () => void) {
|
||||
if (event === 'drain') onDrain = callback;
|
||||
return raw;
|
||||
},
|
||||
};
|
||||
return {
|
||||
writes,
|
||||
reply: { raw } as unknown as FastifyReply,
|
||||
drain: () => {
|
||||
const callback = onDrain;
|
||||
onDrain = undefined;
|
||||
callback?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function scriptedBackpressuredClient(outcomes: Array<boolean | Error>) {
|
||||
const writes: string[] = [];
|
||||
let onDrain: (() => void) | undefined;
|
||||
const raw = {
|
||||
write(chunk: string) {
|
||||
const outcome = outcomes.shift() ?? true;
|
||||
if (outcome instanceof Error) throw outcome;
|
||||
writes.push(chunk);
|
||||
return outcome;
|
||||
},
|
||||
once(event: string, callback: () => void) {
|
||||
if (event === 'drain') onDrain = callback;
|
||||
return raw;
|
||||
},
|
||||
};
|
||||
return {
|
||||
writes,
|
||||
reply: { raw } as unknown as FastifyReply,
|
||||
drain: () => {
|
||||
const callback = onDrain;
|
||||
onDrain = undefined;
|
||||
callback?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const orderChange = (order: string[]): SessionOrderProjectionChange => ({
|
||||
changedOwnerOrders: { alice: order },
|
||||
globalOrder: order,
|
||||
globalChanged: true,
|
||||
});
|
||||
|
||||
describe('tab layout SSE routing', () => {
|
||||
it('derives an exact owner hint with fail-closed session scoping', () => {
|
||||
expect(deriveTabLayoutSseHint({ owner: 'alice', version: 4 })).toEqual({
|
||||
username: 'alice',
|
||||
sessionScoped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('delivers the minimal event to the owner and admins, but not another user', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const alice = client();
|
||||
const bob = client();
|
||||
const admin = client();
|
||||
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
|
||||
const payload = { owner: 'alice', version: 4 };
|
||||
manager.broadcast('tab:layoutChanged', payload, deriveTabLayoutSseHint(payload));
|
||||
|
||||
expect(alice.writes).toEqual(['event: tab:layoutChanged\ndata: {"owner":"alice","version":4}\n\n']);
|
||||
expect(admin.writes).toEqual(alice.writes);
|
||||
expect(bob.writes).toEqual([]);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('coalesces repeated layout invalidations and drains refresh, layout, then legacy order', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const blockedAlice = backpressuredClient();
|
||||
const liveAlice = client();
|
||||
const bob = client();
|
||||
const admin = client();
|
||||
manager.addClient(blockedAlice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(liveAlice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
|
||||
manager.broadcast('test:prime-backpressure', {}, { username: 'alice' });
|
||||
for (const target of [blockedAlice.writes, liveAlice.writes, bob.writes, admin.writes]) target.length = 0;
|
||||
|
||||
const first = { owner: 'alice', version: 4 };
|
||||
const latest = { owner: 'alice', version: 5 };
|
||||
manager.broadcast('tab:layoutChanged', first, deriveTabLayoutSseHint(first));
|
||||
manager.broadcast('tab:layoutChanged', latest, deriveTabLayoutSseHint(latest));
|
||||
manager.broadcastSessionOrder(orderChange(['a2', 'a1']));
|
||||
|
||||
expect(blockedAlice.writes).toEqual([]);
|
||||
expect(liveAlice.writes).toEqual([
|
||||
'event: tab:layoutChanged\ndata: {"owner":"alice","version":4}\n\n',
|
||||
'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n',
|
||||
'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n',
|
||||
]);
|
||||
expect(bob.writes).toEqual([]);
|
||||
expect(admin.writes).toEqual(liveAlice.writes);
|
||||
|
||||
blockedAlice.drain();
|
||||
|
||||
expect(blockedAlice.writes).toEqual([
|
||||
'event: session:needsRefresh\ndata: {}\n\n',
|
||||
'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n',
|
||||
'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n',
|
||||
]);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('retains the latest invalidation for every admin-visible owner while isolating regular users', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const blockedAlice = backpressuredClient();
|
||||
const blockedAdmin = backpressuredClient();
|
||||
const liveAlice = client();
|
||||
const liveAdmin = client();
|
||||
const bob = client();
|
||||
manager.addClient(blockedAlice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(blockedAdmin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
manager.addClient(liveAlice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(liveAdmin.reply, null, false, undefined, { username: 'ops', role: 'admin' });
|
||||
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
|
||||
|
||||
manager.broadcast('test:prime-admin', {}, { adminOnly: true });
|
||||
manager.broadcast('test:prime-alice', {}, { username: 'alice' });
|
||||
for (const target of [blockedAlice.writes, blockedAdmin.writes, liveAlice.writes, liveAdmin.writes, bob.writes]) {
|
||||
target.length = 0;
|
||||
}
|
||||
|
||||
const alice4 = { owner: 'alice', version: 4 };
|
||||
const bob8 = { owner: 'bob', version: 8 };
|
||||
const alice5 = { owner: 'alice', version: 5 };
|
||||
const prototype9 = { owner: 'constructor', version: 9 };
|
||||
for (const payload of [alice4, bob8, alice5, prototype9]) {
|
||||
manager.broadcast('tab:layoutChanged', payload, deriveTabLayoutSseHint(payload));
|
||||
}
|
||||
const change = orderChange(['a2', 'a1']);
|
||||
manager.broadcastSessionOrder(change);
|
||||
|
||||
blockedAdmin.drain();
|
||||
blockedAlice.drain();
|
||||
|
||||
expect(blockedAdmin.writes).toEqual([
|
||||
'event: session:needsRefresh\ndata: {}\n\n',
|
||||
'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n',
|
||||
'event: tab:layoutChanged\ndata: {"owner":"bob","version":8}\n\n',
|
||||
'event: tab:layoutChanged\ndata: {"owner":"constructor","version":9}\n\n',
|
||||
'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n',
|
||||
]);
|
||||
expect(blockedAlice.writes).toEqual([
|
||||
'event: session:needsRefresh\ndata: {}\n\n',
|
||||
'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n',
|
||||
'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n',
|
||||
]);
|
||||
expect(liveAlice.writes).toEqual([
|
||||
'event: tab:layoutChanged\ndata: {"owner":"alice","version":4}\n\n',
|
||||
'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n',
|
||||
'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n',
|
||||
]);
|
||||
expect(bob.writes).toEqual(['event: tab:layoutChanged\ndata: {"owner":"bob","version":8}\n\n']);
|
||||
expect(liveAdmin.writes).toEqual([
|
||||
'event: tab:layoutChanged\ndata: {"owner":"alice","version":4}\n\n',
|
||||
'event: tab:layoutChanged\ndata: {"owner":"bob","version":8}\n\n',
|
||||
'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n',
|
||||
'event: tab:layoutChanged\ndata: {"owner":"constructor","version":9}\n\n',
|
||||
'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n',
|
||||
]);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('retains later owners and the final order when a layout recovery write re-enters backpressure', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const admin = scriptedBackpressuredClient([false, true, false, true, true, true]);
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
manager.broadcast('test:prime-backpressure', {}, { adminOnly: true });
|
||||
admin.writes.length = 0;
|
||||
|
||||
const alice = { owner: 'alice', version: 5 };
|
||||
const bob = { owner: 'bob', version: 8 };
|
||||
manager.broadcast('tab:layoutChanged', alice, deriveTabLayoutSseHint(alice));
|
||||
manager.broadcast('tab:layoutChanged', bob, deriveTabLayoutSseHint(bob));
|
||||
manager.broadcastSessionOrder(orderChange(['a2', 'a1']));
|
||||
|
||||
admin.drain();
|
||||
expect(admin.writes).toEqual([
|
||||
'event: session:needsRefresh\ndata: {}\n\n',
|
||||
'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n',
|
||||
]);
|
||||
|
||||
admin.drain();
|
||||
expect(admin.writes).toEqual([
|
||||
'event: session:needsRefresh\ndata: {}\n\n',
|
||||
'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n',
|
||||
'event: session:needsRefresh\ndata: {}\n\n',
|
||||
'event: tab:layoutChanged\ndata: {"owner":"bob","version":8}\n\n',
|
||||
'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n',
|
||||
]);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('drops remaining recovery state after a partial layout write failure without affecting healthy clients', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const brokenAdmin = scriptedBackpressuredClient([false, true, true, new Error('client disconnected')]);
|
||||
const liveAdmin = client();
|
||||
manager.addClient(brokenAdmin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
manager.addClient(liveAdmin.reply, null, false, undefined, { username: 'ops', role: 'admin' });
|
||||
manager.broadcast('test:prime-backpressure', {}, { adminOnly: true });
|
||||
brokenAdmin.writes.length = 0;
|
||||
liveAdmin.writes.length = 0;
|
||||
|
||||
const alice = { owner: 'alice', version: 5 };
|
||||
const bob = { owner: 'bob', version: 8 };
|
||||
manager.broadcast('tab:layoutChanged', alice, deriveTabLayoutSseHint(alice));
|
||||
manager.broadcast('tab:layoutChanged', bob, deriveTabLayoutSseHint(bob));
|
||||
manager.broadcastSessionOrder(orderChange(['a2', 'a1']));
|
||||
|
||||
expect(() => brokenAdmin.drain()).not.toThrow();
|
||||
|
||||
expect(brokenAdmin.writes).toEqual([
|
||||
'event: session:needsRefresh\ndata: {}\n\n',
|
||||
'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n',
|
||||
]);
|
||||
expect(manager.clientCount).toBe(1);
|
||||
expect(liveAdmin.writes).toEqual([
|
||||
'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n',
|
||||
'event: tab:layoutChanged\ndata: {"owner":"bob","version":8}\n\n',
|
||||
'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n',
|
||||
]);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('clears a queued layout invalidation on disconnect and ignores its stale drain callback', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const alice = backpressuredClient();
|
||||
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.broadcast('test:prime-backpressure', {}, { username: 'alice' });
|
||||
alice.writes.length = 0;
|
||||
|
||||
const payload = { owner: 'alice', version: 4 };
|
||||
manager.broadcast('tab:layoutChanged', payload, deriveTabLayoutSseHint(payload));
|
||||
manager.removeClient(alice.reply);
|
||||
alice.drain();
|
||||
|
||||
expect(alice.writes).toEqual([]);
|
||||
expect(manager.clientCount).toBe(0);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('clears a queued layout invalidation when drain recovery fails', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const alice = backpressuredClient({ throwAfterBackpressure: true });
|
||||
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.broadcast('test:prime-backpressure', {}, { username: 'alice' });
|
||||
alice.writes.length = 0;
|
||||
|
||||
const payload = { owner: 'alice', version: 4 };
|
||||
manager.broadcast('tab:layoutChanged', payload, deriveTabLayoutSseHint(payload));
|
||||
expect(() => alice.drain()).not.toThrow();
|
||||
alice.drain();
|
||||
|
||||
expect(alice.writes).toEqual([]);
|
||||
expect(manager.clientCount).toBe(0);
|
||||
cleanup.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MAX_TAB_GROUP_NAME_LENGTH,
|
||||
MAX_TAB_GROUPS,
|
||||
MAX_TAB_REFS,
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
flattenOwnerSessionOrder,
|
||||
flattenVisibleRefs,
|
||||
followParent,
|
||||
materializeOrphans,
|
||||
moveRef,
|
||||
normalizeTabLayout,
|
||||
renameGroup,
|
||||
reorderGroup,
|
||||
setManualPlacement,
|
||||
validateTabLayout,
|
||||
type TabLayout,
|
||||
type TabRefMetadata,
|
||||
} from '../src/tab-layout.js';
|
||||
|
||||
const now = '2026-08-16T12:00:00.000Z';
|
||||
const ref = (id: string, kind: 'session' | 'webview' = 'session') => ({ kind, id }) as const;
|
||||
const session = (id: string, order: number, parentSessionId?: string): TabRefMetadata => ({
|
||||
kind: 'session',
|
||||
id,
|
||||
order,
|
||||
parentSessionId,
|
||||
ownerValid: true,
|
||||
visible: true,
|
||||
});
|
||||
const webview = (id: string, order: number): TabRefMetadata => ({
|
||||
kind: 'webview',
|
||||
id,
|
||||
order,
|
||||
ownerValid: true,
|
||||
visible: true,
|
||||
});
|
||||
const layout = (groups: TabLayout['groups'] = [], ungrouped: TabLayout['ungrouped'] = []): TabLayout => ({
|
||||
version: 3,
|
||||
groups,
|
||||
ungrouped,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
describe('tab layout validation and normalization', () => {
|
||||
it('exports and enforces group, name, and raw ref ceilings at their boundaries', () => {
|
||||
expect(MAX_TAB_GROUPS).toBe(32);
|
||||
expect(MAX_TAB_GROUP_NAME_LENGTH).toBe(60);
|
||||
expect(MAX_TAB_REFS).toBe(512);
|
||||
|
||||
const groups = Array.from({ length: MAX_TAB_GROUPS }, (_, index) => ({ id: `g${index}`, name: 'x', refs: [] }));
|
||||
expect(validateTabLayout(layout(groups)).groups).toHaveLength(MAX_TAB_GROUPS);
|
||||
expect(() => validateTabLayout(layout([...groups, { id: 'overflow', name: 'x', refs: [] }]))).toThrow(/32/);
|
||||
expect(
|
||||
validateTabLayout(layout([{ id: 'g', name: ` ${'x'.repeat(60)} `, refs: [] }])).groups[0].name
|
||||
).toHaveLength(60);
|
||||
expect(() => validateTabLayout(layout([{ id: 'g', name: 'x'.repeat(61), refs: [] }]))).toThrow(/60/);
|
||||
|
||||
const refs = Array.from({ length: MAX_TAB_REFS }, (_, index) => ref(`s${index}`));
|
||||
expect(validateTabLayout(layout([], refs)).ungrouped).toHaveLength(MAX_TAB_REFS);
|
||||
expect(() => validateTabLayout(layout([], [...refs, ref('overflow')]))).toThrow(/512/);
|
||||
});
|
||||
|
||||
it('rejects malformed input deterministically and trims names', () => {
|
||||
expect(() => validateTabLayout({ ...layout(), version: -1 })).toThrow(/version/);
|
||||
expect(() => validateTabLayout(layout([{ id: 'g', name: ' ', refs: [] }]))).toThrow(/name/);
|
||||
expect(() =>
|
||||
validateTabLayout(
|
||||
layout([
|
||||
{ id: 'g', name: 'x', refs: [] },
|
||||
{ id: 'g', name: 'y', refs: [] },
|
||||
])
|
||||
)
|
||||
).toThrow(/duplicate group/);
|
||||
expect(() => validateTabLayout(layout([], [{ kind: 'other', id: 'x' } as never]))).toThrow(/kind/);
|
||||
expect(() =>
|
||||
validateTabLayout(layout([{ id: 'g', name: 'G', refs: [ref('duplicate')] }], [ref('duplicate')]))
|
||||
).toThrow(/duplicate ref/);
|
||||
expect(
|
||||
validateTabLayout(layout([{ id: 'g', name: 'G', refs: [ref('same')] }], [ref('same', 'webview')])).ungrouped
|
||||
).toEqual([ref('same', 'webview')]);
|
||||
expect(renameGroup(layout([{ id: 'g', name: ' Before ', refs: [] }]), 'g', ' After ').groups[0].name).toBe(
|
||||
'After'
|
||||
);
|
||||
});
|
||||
|
||||
it('deduplicates first occurrence, namespaces kinds, and repairs missing valid refs in stable order', () => {
|
||||
const input = layout(
|
||||
[{ id: 'g', name: 'G', refs: [ref('same'), ref('same'), ref('same', 'webview')] }],
|
||||
[ref('same'), ref('later')]
|
||||
);
|
||||
const normalized = normalizeTabLayout(input, [
|
||||
session('same', 1),
|
||||
webview('same', 2),
|
||||
session('later', 3),
|
||||
session('new', 4),
|
||||
]);
|
||||
expect(normalized.groups[0].refs).toEqual([ref('same'), ref('same', 'webview')]);
|
||||
expect(normalized.ungrouped).toEqual([ref('later'), ref('new')]);
|
||||
});
|
||||
|
||||
it('appends new roots and webviews and ignores metadata that is not owner-valid or visible', () => {
|
||||
const metadata: TabRefMetadata[] = [
|
||||
session('root', 2),
|
||||
webview('dash', 3),
|
||||
{ ...session('foreign', 0), ownerValid: false },
|
||||
{ ...session('hidden-by-owner', 1), visible: false },
|
||||
];
|
||||
expect(normalizeTabLayout(layout(), metadata).ungrouped).toEqual([ref('root'), ref('dash', 'webview')]);
|
||||
});
|
||||
|
||||
it('preserves metadata-unknown stored refs while removing explicitly invalid refs', () => {
|
||||
const input = layout([], [ref('unknown'), ref('unknown-web', 'webview'), ref('foreign')]);
|
||||
const metadata: TabRefMetadata[] = [{ ...session('foreign', 0), ownerValid: false }, session('new', 1)];
|
||||
expect(normalizeTabLayout(input, metadata).ungrouped).toEqual([
|
||||
ref('unknown'),
|
||||
ref('unknown-web', 'webview'),
|
||||
ref('new'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a missing valid ref above the 512-ref ceiling without truncating the stored layout', () => {
|
||||
const storedRefs = Array.from({ length: MAX_TAB_REFS }, (_, index) => ref(`s${index}`));
|
||||
const input = layout([], storedRefs);
|
||||
const snapshot = structuredClone(input);
|
||||
const metadata = [
|
||||
...storedRefs.map((stored, index) => session(stored.id, index)),
|
||||
session('missing-overflow', MAX_TAB_REFS),
|
||||
];
|
||||
|
||||
expect(() => normalizeTabLayout(input, metadata)).toThrow(/512/);
|
||||
expect(input).toEqual(snapshot);
|
||||
expect(input.ungrouped).toHaveLength(MAX_TAB_REFS);
|
||||
});
|
||||
|
||||
it('does not mutate caller input', () => {
|
||||
const input = layout([{ id: 'g', name: ' G ', refs: [ref('child')] }], [ref('parent')]);
|
||||
const snapshot = structuredClone(input);
|
||||
normalizeTabLayout(input, [session('parent', 0), session('child', 1, 'parent')]);
|
||||
moveRef(input, ref('parent'), { groupId: 'g', index: 0 }, [session('parent', 0), session('child', 1, 'parent')]);
|
||||
expect(input).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab group operations', () => {
|
||||
it('creates, renames, reorders, and deletes groups without deleting refs', () => {
|
||||
let value = layout([{ id: 'a', name: 'A', refs: [ref('one')] }], [ref('loose')]);
|
||||
value = createGroup(value, { id: 'b', name: ' Bee ', index: 0 });
|
||||
expect(value.groups.map((group) => [group.id, group.name])).toEqual([
|
||||
['b', 'Bee'],
|
||||
['a', 'A'],
|
||||
]);
|
||||
value = renameGroup(value, 'b', ' B ');
|
||||
value = reorderGroup(value, 'a', 0);
|
||||
expect(value.groups.map((group) => group.id)).toEqual(['a', 'b']);
|
||||
value = deleteGroup(value, 'a');
|
||||
expect(value.groups.map((group) => group.id)).toEqual(['b']);
|
||||
expect(value.ungrouped).toEqual([ref('loose'), ref('one')]);
|
||||
});
|
||||
|
||||
it('moves refs within, between, and into ungrouped', () => {
|
||||
const metadata = [session('a', 0), session('b', 1), session('c', 2)];
|
||||
let value = layout([{ id: 'g', name: 'G', refs: [ref('a'), ref('b')] }], [ref('c')]);
|
||||
value = moveRef(value, ref('b'), { groupId: 'g', index: 0 }, metadata);
|
||||
expect(value.groups[0].refs).toEqual([ref('b'), ref('a')]);
|
||||
value = moveRef(value, ref('a'), { groupId: null, index: 1 }, metadata);
|
||||
expect(value.ungrouped).toEqual([ref('c'), ref('a')]);
|
||||
value = moveRef(value, ref('c'), { groupId: 'g', index: 1 }, metadata);
|
||||
expect(value.groups[0].refs).toEqual([ref('b'), ref('c')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session lineage', () => {
|
||||
it('normalizes nested lineage into preorder and preserves stable sibling order', () => {
|
||||
const metadata = [
|
||||
session('root', 0),
|
||||
session('child-b', 1, 'root'),
|
||||
session('grandchild', 2, 'child-b'),
|
||||
session('child-a', 3, 'root'),
|
||||
];
|
||||
const value = normalizeTabLayout(
|
||||
layout([{ id: 'g', name: 'G', refs: [ref('root')] }], [ref('child-b'), ref('grandchild'), ref('child-a')]),
|
||||
metadata
|
||||
);
|
||||
expect(value.groups[0].refs).toEqual([ref('root'), ref('child-b'), ref('grandchild'), ref('child-a')]);
|
||||
});
|
||||
|
||||
it('inserts a missing new child after the existing descendant subtree', () => {
|
||||
const value = normalizeTabLayout(layout([], [ref('root'), ref('child'), ref('grandchild')]), [
|
||||
session('root', 0),
|
||||
session('child', 1, 'root'),
|
||||
session('grandchild', 2, 'child'),
|
||||
session('new-child', 3, 'root'),
|
||||
]);
|
||||
expect(value.ungrouped).toEqual([ref('root'), ref('child'), ref('grandchild'), ref('new-child')]);
|
||||
});
|
||||
|
||||
it('moves a parent as one preorder block', () => {
|
||||
const metadata = [
|
||||
session('root', 0),
|
||||
session('child', 1, 'root'),
|
||||
session('grandchild', 2, 'child'),
|
||||
session('other', 3),
|
||||
];
|
||||
const input = layout(
|
||||
[{ id: 'g', name: 'G', refs: [ref('other')] }],
|
||||
[ref('root'), ref('child'), ref('grandchild')]
|
||||
);
|
||||
const value = moveRef(input, ref('root'), { groupId: 'g', index: 0 }, metadata);
|
||||
expect(value.groups[0].refs).toEqual([ref('root'), ref('child'), ref('grandchild'), ref('other')]);
|
||||
expect(value.ungrouped).toEqual([]);
|
||||
});
|
||||
|
||||
it('moves a parent subtree within one container without disturbing surrounding sibling order', () => {
|
||||
const metadata = [
|
||||
session('before', 0),
|
||||
session('root', 1),
|
||||
session('child', 2, 'root'),
|
||||
session('grandchild', 3, 'child'),
|
||||
session('after', 4),
|
||||
];
|
||||
const input = layout([
|
||||
{
|
||||
id: 'g',
|
||||
name: 'G',
|
||||
refs: [ref('before'), ref('root'), ref('child'), ref('grandchild'), ref('after')],
|
||||
},
|
||||
]);
|
||||
|
||||
const value = moveRef(input, ref('root'), { groupId: 'g', index: 2 }, metadata);
|
||||
expect(value.groups[0].refs).toEqual([ref('before'), ref('after'), ref('root'), ref('child'), ref('grandchild')]);
|
||||
});
|
||||
|
||||
it('makes an independently moved child manual and followParent restores inheritance', () => {
|
||||
const metadata = [session('root', 0), session('child', 1, 'root'), session('grandchild', 2, 'child')];
|
||||
let value = layout([{ id: 'g', name: 'G', refs: [ref('root'), ref('child'), ref('grandchild')] }]);
|
||||
value = moveRef(value, ref('child'), { groupId: null, index: 0 }, metadata);
|
||||
expect(value.ungrouped).toEqual([{ ...ref('child'), placement: 'manual' }, ref('grandchild')]);
|
||||
value = followParent(value, ref('child'), metadata);
|
||||
expect(value.groups[0].refs).toEqual([ref('root'), ref('child'), ref('grandchild')]);
|
||||
expect(value.ungrouped).toEqual([]);
|
||||
});
|
||||
|
||||
it('sets manual placement but rejects direct clearing outside followParent', () => {
|
||||
const input = layout([], [ref('a')]);
|
||||
expect(setManualPlacement(input, ref('a'), true).ungrouped[0]).toEqual({ ...ref('a'), placement: 'manual' });
|
||||
expect(() => setManualPlacement(setManualPlacement(input, ref('a'), true), ref('a'), false)).toThrow(
|
||||
/followParent/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects stale follow-parent replay and keeps the child manual across later parent ID reuse', () => {
|
||||
const materialized = layout([], [{ ...ref('child'), placement: 'manual' }]);
|
||||
const staleMetadata = [session('child', 1, 'deleted-parent')];
|
||||
|
||||
expect(() => followParent(materialized, ref('child'), staleMetadata)).toThrow(/parent/);
|
||||
expect(materialized.ungrouped).toEqual([{ ...ref('child'), placement: 'manual' }]);
|
||||
|
||||
const afterIdReuse = normalizeTabLayout(materialized, [
|
||||
session('deleted-parent', 2),
|
||||
session('child', 1, 'deleted-parent'),
|
||||
]);
|
||||
expect(afterIdReuse.ungrouped).toEqual([{ ...ref('child'), placement: 'manual' }, ref('deleted-parent')]);
|
||||
});
|
||||
|
||||
it('rejects follow-parent for roots and webviews', () => {
|
||||
expect(() =>
|
||||
followParent(layout([], [{ ...ref('root'), placement: 'manual' }]), ref('root'), [session('root', 0)])
|
||||
).toThrow(/parent/);
|
||||
expect(() =>
|
||||
followParent(layout([], [{ ...ref('dash', 'webview'), placement: 'manual' }]), ref('dash', 'webview'), [
|
||||
webview('dash', 0),
|
||||
])
|
||||
).toThrow(/session/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: 'invalid', parent: { ...session('parent', 0), ownerValid: false } },
|
||||
{ label: 'invisible', parent: { ...session('parent', 0), visible: false } },
|
||||
])('rejects follow-parent when the parent is $label', ({ parent }) => {
|
||||
const input = layout([], [ref('parent'), { ...ref('child'), placement: 'manual' }]);
|
||||
expect(() => followParent(input, ref('child'), [parent, session('child', 1, 'parent')])).toThrow(/parent/);
|
||||
});
|
||||
|
||||
it('breaks the repeated cycle edge deterministically at its current location', () => {
|
||||
const value = normalizeTabLayout(layout([], [ref('a'), ref('b')]), [session('a', 0, 'b'), session('b', 1, 'a')]);
|
||||
expect(value.ungrouped).toEqual([{ ...ref('b'), placement: 'manual' }, ref('a')]);
|
||||
});
|
||||
|
||||
it('breaks a multi-node cross-container cycle deterministically', () => {
|
||||
const metadata = [session('a', 0, 'b'), session('b', 1, 'c'), session('c', 2, 'a'), session('tail', 3)];
|
||||
const input = layout(
|
||||
[
|
||||
{ id: 'one', name: 'One', refs: [ref('a')] },
|
||||
{ id: 'two', name: 'Two', refs: [ref('b')] },
|
||||
],
|
||||
[ref('c'), ref('tail')]
|
||||
);
|
||||
|
||||
const first = normalizeTabLayout(input, metadata);
|
||||
expect(first.groups.map((group) => group.refs)).toEqual([[], []]);
|
||||
expect(first.ungrouped).toEqual([{ ...ref('c'), placement: 'manual' }, ref('b'), ref('a'), ref('tail')]);
|
||||
expect(normalizeTabLayout(first, metadata)).toEqual(first);
|
||||
});
|
||||
|
||||
it('materializes direct orphans and prevents a restored ID from re-adopting them', () => {
|
||||
const metadata = [session('parent', 0), session('child', 1, 'parent'), session('grandchild', 2, 'child')];
|
||||
let value = layout([], [ref('parent'), ref('child'), ref('grandchild')]);
|
||||
value = materializeOrphans(value, ['parent'], metadata);
|
||||
value = normalizeTabLayout(value, metadata);
|
||||
expect(value.ungrouped).toEqual([{ ...ref('child'), placement: 'manual' }, ref('grandchild'), ref('parent')]);
|
||||
});
|
||||
|
||||
it('materializes a direct orphan in its grouped location amid surrounding refs', () => {
|
||||
const metadata = [
|
||||
session('before', 0),
|
||||
session('parent', 1),
|
||||
session('child', 2, 'parent'),
|
||||
session('grandchild', 3, 'child'),
|
||||
session('after', 4),
|
||||
];
|
||||
const input = layout([
|
||||
{
|
||||
id: 'g',
|
||||
name: 'G',
|
||||
refs: [ref('before'), ref('parent'), ref('child'), ref('grandchild'), ref('after')],
|
||||
},
|
||||
]);
|
||||
|
||||
const materialized = materializeOrphans(input, ['parent'], metadata);
|
||||
expect(materialized.groups[0].refs).toEqual([
|
||||
ref('before'),
|
||||
{ ...ref('child'), placement: 'manual' },
|
||||
ref('grandchild'),
|
||||
ref('after'),
|
||||
]);
|
||||
const restored = normalizeTabLayout(materialized, metadata);
|
||||
expect(restored.groups[0].refs).toEqual([
|
||||
ref('before'),
|
||||
{ ...ref('child'), placement: 'manual' },
|
||||
ref('grandchild'),
|
||||
ref('after'),
|
||||
]);
|
||||
expect(restored.ungrouped).toEqual([ref('parent')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flatten projections', () => {
|
||||
const value = layout(
|
||||
[
|
||||
{ id: 'collapsed', name: 'Collapsed', refs: [ref('s1'), ref('closed', 'webview'), ref('s2')] },
|
||||
{ id: 'open', name: 'Open', refs: [ref('s3'), ref('dash', 'webview')] },
|
||||
],
|
||||
[ref('s4')]
|
||||
);
|
||||
|
||||
it('projects every owner session in layout order, including collapse-hidden members', () => {
|
||||
expect(flattenOwnerSessionOrder(value)).toEqual(['s1', 's2', 's3', 's4']);
|
||||
});
|
||||
|
||||
it('projects renderable refs, omits unopened webviews, and keeps a collapsed highlight visible', () => {
|
||||
expect(
|
||||
flattenVisibleRefs(value, {
|
||||
liveSessionIds: new Set(['s1', 's2', 's3', 's4']),
|
||||
openWebviewIds: new Set(['dash']),
|
||||
collapsedGroupIds: new Set(['collapsed']),
|
||||
highlighted: ref('s2'),
|
||||
})
|
||||
).toEqual([ref('s2'), ref('s3'), ref('dash', 'webview'), ref('s4')]);
|
||||
});
|
||||
|
||||
it('keeps a highlighted open webview visible inside a collapsed group', () => {
|
||||
expect(
|
||||
flattenVisibleRefs(value, {
|
||||
liveSessionIds: new Set(['s1', 's2', 's3', 's4']),
|
||||
openWebviewIds: new Set(['closed', 'dash']),
|
||||
collapsedGroupIds: new Set(['collapsed']),
|
||||
highlighted: ref('closed', 'webview'),
|
||||
})
|
||||
).toEqual([ref('closed', 'webview'), ref('s3'), ref('dash', 'webview'), ref('s4')]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user