Merge remote-tracking branch 'origin/master' into worktree-grok-mode

# Conflicts:
#	src/web/public/app.js
This commit is contained in:
Codeman maintainer
2026-08-24 00:32:25 +02:00
70 changed files with 7091 additions and 182 deletions
+40
View File
@@ -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);
+15 -1
View File
@@ -40,13 +40,16 @@ function fakeElement(): any {
* point: if that reuse ever breaks, these tests stop loading rather than
* quietly testing a divergent copy.
*/
function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512) {
function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512, tabOrientation = 'horizontal') {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
console,
window: { innerWidth },
document: {
documentElement: {
getAttribute: (name: string) => (name === 'data-tab-orientation' ? tabOrientation : null),
},
getElementById: () => null,
createElement: () => fakeElement(),
createElementNS: () => fakeElement(),
@@ -198,6 +201,11 @@ describe('home sessions column: gate', () => {
expect(app.shouldShowHomeSessions()).toBe(true);
});
it('yields to the persistent rail when the effective tab orientation is vertical', () => {
expect(loadHomeSessionsApp({}, 1512, 'vertical').shouldShowHomeSessions()).toBe(false);
expect(loadHomeSessionsApp({}, 1512, 'horizontal').shouldShowHomeSessions()).toBe(true);
});
it('stays out of a window too narrow to hold it beside the centered content', () => {
// Absolutely positioned: below the gate it would overlap the search panel
// rather than push it aside.
@@ -238,6 +246,12 @@ describe('home sessions column: wiring', () => {
expect(css).toMatch(/\.home-sessions\[hidden\]\s*\{\s*display:\s*none;/);
});
it('has a CSS backstop that suppresses the homepage rail in vertical mode', () => {
expect(css).toMatch(
/html\[data-tab-orientation='vertical'\]\s+\.home-sessions\s*\{\s*display:\s*none\s*!important;/
);
});
it('reuses the tab-load spinner rather than declaring a second one', () => {
// The working ring is the same motion a tab shows while it loads, on both
// home screens. Re-declaring the keyframes here is how they drift apart.
+180 -1
View File
@@ -6,8 +6,10 @@
* These behaviors live in server.ts (preSerialization hook, setNotFoundHandler),
* which the route-test harness does not install — so they need a real WebServer.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { flattenOwnerSessionOrder, type TabLayout } from '../src/tab-layout.js';
import { WebServer } from '../src/web/server.js';
import { SseEvent } from '../src/web/sse-events.js';
const PORT = 3168;
@@ -33,6 +35,183 @@ describe('Stable HTTP contract (live server)', () => {
expect(body.data.version).toBeDefined();
});
it('preserves the legacy global session order in single-user light state without exposing layouts', async () => {
const res = await fetch(`${base}/api/status`);
const body = await res.json();
expect(body.data.sessionOrder).toEqual([]);
expect(body.data).not.toHaveProperty('tabLayouts');
});
it('filters status order for regular users while admins and single-user mode retain the global projection', () => {
type FakeSession = {
id: string;
owner: string;
inputTokens: number;
outputTokens: number;
totalCost: number;
toLightDetailedState(): { id: string; owner: string };
};
type StatusInternals = {
sessions: Map<string, FakeSession>;
store: { getSessionOrder(): string[]; setSessionOrder(order: string[]): void };
cachedLightState: unknown;
cachedSessionsList: unknown;
getLightState(identity?: { username: string; role: 'admin' | 'user' }): Record<string, unknown>;
};
const internals = server as unknown as StatusInternals;
const previousOrder = internals.store.getSessionOrder();
const previousSessions = new Map(internals.sessions);
const fakeSession = (id: string, owner: string): FakeSession => ({
id,
owner,
inputTokens: 0,
outputTokens: 0,
totalCost: 0,
toLightDetailedState: () => ({ id, owner }),
});
try {
internals.sessions.clear();
internals.sessions.set('a1', fakeSession('a1', 'alice'));
internals.sessions.set('b1', fakeSession('b1', 'bob'));
internals.sessions.set('a2', fakeSession('a2', 'alice'));
internals.store.setSessionOrder(['b1', 'a1', 'a2']);
internals.cachedLightState = null;
internals.cachedSessionsList = null;
vi.stubEnv('CODEMAN_MULTIUSER', '1');
expect(internals.getLightState({ username: 'alice', role: 'user' }).sessionOrder).toEqual(['a1', 'a2']);
expect(internals.getLightState({ username: 'root', role: 'admin' }).sessionOrder).toEqual(['b1', 'a1', 'a2']);
vi.stubEnv('CODEMAN_MULTIUSER', '0');
expect(internals.getLightState().sessionOrder).toEqual(['b1', 'a1', 'a2']);
} finally {
vi.unstubAllEnvs();
internals.sessions.clear();
for (const [id, session] of previousSessions) internals.sessions.set(id, session);
internals.store.setSessionOrder(previousOrder);
internals.cachedLightState = null;
internals.cachedSessionsList = null;
}
});
it('keeps the tab layout foundation smoke contract atomic and writable', async () => {
type TabLayoutInternals = {
sessions: Map<string, { id: string; createdAt: number; owner?: string }>;
store: {
getState(): { sessionOrder?: string[]; tabLayouts?: Record<string, TabLayout> };
getSessionOrder(): string[];
getTabLayout(owner: string): TabLayout | null;
getTabLayouts(): Record<string, TabLayout>;
commitTabLayoutProjection: (...args: unknown[]) => unknown;
save(): void;
};
sse: {
addClient(reply: unknown, sessionFilter: Set<string> | null, isRemote: boolean): void;
removeClient(reply: unknown): void;
broadcast: (...args: unknown[]) => void;
broadcastSessionOrder: (...args: unknown[]) => void;
};
};
const internals = server as unknown as TabLayoutInternals;
const commit = vi.spyOn(internals.store, 'commitTabLayoutProjection');
const layoutEvent = vi.spyOn(internals.sse, 'broadcast');
const orderEvent = vi.spyOn(internals.sse, 'broadcastSessionOrder');
const previousSessions = new Map(internals.sessions);
const storeState = internals.store.getState();
const previousSessionOrder = storeState.sessionOrder ? [...storeState.sessionOrder] : undefined;
const previousTabLayouts = storeState.tabLayouts ? structuredClone(storeState.tabLayouts) : undefined;
const recipientWrites: string[] = [];
const recipient = { raw: { write: (chunk: string) => (recipientWrites.push(chunk), true) } };
internals.sse.addClient(recipient, null, false);
const requested = {
version: 0,
groups: [],
ungrouped: [],
updatedAt: '2026-08-23T00:00:00.000Z',
};
try {
expect(internals.store.getTabLayout('@single')).toBeNull();
const direct = await fetch(`${base}/api/tab-layout`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseVersion: 0, layout: requested }),
});
expect(direct.status).toBe(200);
expect((await direct.json()).data.layout.version).toBe(1);
expect(commit).toHaveBeenCalledTimes(1);
expect(layoutEvent).toHaveBeenCalledTimes(1);
expect(layoutEvent).toHaveBeenCalledWith(SseEvent.TabLayoutChanged, { owner: '@single', version: 1 }, undefined);
expect(orderEvent).not.toHaveBeenCalled();
expect(recipientWrites).toEqual(['event: tab:layoutChanged\ndata: {"owner":"@single","version":1}\n\n']);
recipientWrites.length = 0;
const layoutBeforeConflict = internals.store.getTabLayout('@single');
const orderBeforeConflict = internals.store.getSessionOrder();
const writesBeforeConflict = commit.mock.calls.length;
const layoutEventsBeforeConflict = layoutEvent.mock.calls.length;
const orderEventsBeforeConflict = orderEvent.mock.calls.length;
const stale = await fetch(`${base}/api/tab-layout`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseVersion: 0, layout: requested }),
});
expect(stale.status).toBe(409);
expect((await stale.json()).errorCode).toBe('CONFLICT');
expect(internals.store.getTabLayout('@single')).toEqual(layoutBeforeConflict);
expect(internals.store.getSessionOrder()).toEqual(orderBeforeConflict);
expect(commit).toHaveBeenCalledTimes(writesBeforeConflict);
expect(layoutEvent).toHaveBeenCalledTimes(layoutEventsBeforeConflict);
expect(orderEvent).toHaveBeenCalledTimes(orderEventsBeforeConflict);
expect(recipientWrites).toEqual([]);
const ownerGet = await fetch(`${base}/api/tab-layout`);
expect(ownerGet.status).toBe(200);
expect((await ownerGet.json()).data.layout.version).toBe(1);
const firstId = 'tab-layout-smoke-a';
const secondId = 'tab-layout-smoke-b';
internals.sessions.set(firstId, { id: firstId, createdAt: 1 });
internals.sessions.set(secondId, { id: secondId, createdAt: 2 });
const orderEventsBeforeLegacy = orderEvent.mock.calls.length;
const legacyPut = await fetch(`${base}/api/session-order`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: [secondId, firstId] }),
});
expect(legacyPut.status).toBe(200);
expect((await legacyPut.json()).data.order).toEqual([secondId, firstId]);
expect(internals.store.getSessionOrder()).toEqual([secondId, firstId]);
expect(flattenOwnerSessionOrder(internals.store.getTabLayout('@single')!)).toEqual([secondId, firstId]);
expect(orderEvent).toHaveBeenCalledTimes(orderEventsBeforeLegacy + 1);
expect(orderEvent).toHaveBeenLastCalledWith({
changedOwnerOrders: { '@single': [secondId, firstId] },
globalOrder: [secondId, firstId],
globalChanged: true,
});
expect(recipientWrites.at(-1)).toBe(
`event: session:orderChanged\ndata: {"order":["${secondId}","${firstId}"]}\n\n`
);
} finally {
internals.sse.removeClient(recipient);
internals.sessions.clear();
for (const [id, session] of previousSessions) internals.sessions.set(id, session);
if (previousSessionOrder) storeState.sessionOrder = [...previousSessionOrder];
else delete storeState.sessionOrder;
if (previousTabLayouts) storeState.tabLayouts = structuredClone(previousTabLayouts);
else delete storeState.tabLayouts;
internals.store.save();
commit.mockRestore();
layoutEvent.mockRestore();
orderEvent.mockRestore();
}
expect(internals.store.getSessionOrder()).toEqual(previousSessionOrder ?? []);
expect(internals.store.getTabLayouts()).toEqual(previousTabLayouts ?? {});
});
it('serves the same envelope on the /api/v1 alias', async () => {
const res = await fetch(`${base}/api/v1/status`);
expect(res.status).toBe(200);
+86 -2
View File
@@ -186,6 +186,9 @@ describe('Inline rename input', () => {
renameActiveAfter: !!app._activeRename,
sessionGone: !app.sessions.has('ghost-id'),
fetchFired,
renameClassActive:
document.querySelector('.tab-name[data-session-id="ghost-id"]')?.classList.contains('tab-name-renaming') ??
false,
};
});
@@ -194,6 +197,7 @@ describe('Inline rename input', () => {
expect(result.sessionGone).toBe(true);
// Cancel path skips the API call — deleting a session shouldn't trigger a stale rename PUT.
expect(result.fetchFired).toBe(false);
expect(result.renameClassActive).toBe(false);
});
it('Ghost tab: _cleanupSessionData for a DIFFERENT session does NOT cancel rename', async () => {
@@ -386,10 +390,16 @@ describe('Inline rename input', () => {
await new Promise((r) => setTimeout(r, 60));
window.fetch = origFetch;
return { mapName: app.sessions.get('no-sse')?.name ?? null };
return {
mapName: app.sessions.get('no-sse')?.name ?? null,
renameClassActive:
document.querySelector('.tab-name[data-session-id="no-sse"]')?.classList.contains('tab-name-renaming') ??
false,
};
});
expect(result.mapName).toBe('w9-case: fresh');
expect(result.renameClassActive).toBe(false);
});
it('A rejected rename restores the old label and leaves app.sessions untouched', async () => {
@@ -426,12 +436,16 @@ describe('Inline rename input', () => {
return {
mapName: app.sessions.get('rename-500')?.name ?? null,
label: document.querySelector('.tab-name[data-session-id="rename-500"]')?.textContent ?? null,
renameClassActive:
document.querySelector('.tab-name[data-session-id="rename-500"]')?.classList.contains('tab-name-renaming') ??
false,
toasts,
};
});
expect(result.mapName).toBe('w9-case');
expect(result.label).toBe('w9-case');
expect(result.renameClassActive).toBe(false);
expect(result.toasts).toContain('Failed to rename');
});
@@ -445,6 +459,7 @@ describe('Inline rename input', () => {
app: {
_activeRename: { sessionId: string } | null;
sessions: Map<string, { id: string; name: string }>;
renderSessionTabs: () => void;
startInlineRename: (id: string) => void;
};
}
@@ -461,11 +476,80 @@ describe('Inline rename input', () => {
tabName.textContent = 'Second';
wrap.appendChild(tabName);
document.body.appendChild(wrap);
// Cancelling the first rename is allowed to repaint the tab list. Model
// that synchronously so a target captured before cancel() becomes stale.
const originalRenderSessionTabs = app.renderSessionTabs;
app.renderSessionTabs = () => {
const current = document.querySelector('.tab-name[data-session-id="second-id"]');
current?.replaceWith(current.cloneNode(true));
};
app.startInlineRename('second-id');
return { firstActive, secondActive: app._activeRename?.sessionId };
app.renderSessionTabs = originalRenderSessionTabs;
return {
firstActive,
secondActive: app._activeRename?.sessionId,
secondInputVisible: !!document.querySelector('.tab-name[data-session-id="second-id"] input.tab-rename-input'),
firstRenameClassActive:
document.querySelector('.tab-name[data-session-id="first-id"]')?.classList.contains('tab-name-renaming') ??
false,
};
});
expect(result.firstActive).toBe('first-id');
expect(result.secondActive).toBe('second-id');
expect(result.secondInputVisible).toBe(true);
expect(result.firstRenameClassActive).toBe(false);
});
it('Vertical rail paints typing in an unclamped editor and restores the clamp on cancel', async () => {
await resetState();
const id = 'vertical-live-input';
await page.evaluate((sessionId) => {
const app = (
window as unknown as {
app: {
sessions: Map<string, { id: string; name: string }>;
startInlineRename: (id: string) => void;
};
}
).app;
document.documentElement.dataset.tabOrientation = 'vertical';
const rail = document.getElementById('tabRail') as HTMLElement;
const tab = document.createElement('div');
tab.setAttribute('data-test-tab', '1');
tab.className = 'session-tab';
tab.innerHTML =
`<span class="tab-name" data-session-id="${sessionId}">` +
'<span class="tab-name-prefix">w9-case: </span>old</span>';
rail.appendChild(tab);
app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' });
app.startInlineRename(sessionId);
}, id);
const label = page.locator(`.tab-name[data-session-id="${id}"]`);
const input = label.locator('input.tab-rename-input');
await input.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
await page.keyboard.type('edited title');
expect(await input.inputValue()).toBe('edited title');
expect(await input.evaluate((node) => document.activeElement === node)).toBe(true);
expect(await label.evaluate((node) => node.classList.contains('tab-name-renaming'))).toBe(true);
expect(await label.evaluate((node) => getComputedStyle(node).webkitLineClamp)).toBe('none');
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
const settled = await page.evaluate((sessionId) => {
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
app._activeRename?.cancel();
const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement;
return {
classActive: label.classList.contains('tab-name-renaming'),
inputPresent: !!label.querySelector('input.tab-rename-input'),
webkitLineClamp: getComputedStyle(label).webkitLineClamp,
};
}, id);
expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: '2' });
});
});
+23 -1
View File
@@ -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,
+26
View File
@@ -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',
+49 -34
View File
@@ -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,
});
});
});
+6 -1
View File
@@ -332,11 +332,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();
});
});
+106
View File
@@ -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();
});
});
+73 -1
View File
@@ -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);')
);
});
});
+31 -2
View File
@@ -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);
+86 -2
View File
@@ -12,6 +12,10 @@ import { describe, expect, it } from 'vitest';
type Rect = { left: number; top: number; width: number; height: number };
type LineagePath = { d: string; endX: number; endY: number; sameRow: boolean } | null;
type Orientation = 'horizontal' | 'vertical';
const lineageJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-lineage.js'), 'utf8');
const stylesCss = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
function loadLineageHelper() {
const context = vm.createContext({ window: {}, globalThis: {} });
@@ -20,7 +24,13 @@ function loadLineageHelper() {
return (
context.window as {
CodemanLineage: {
computePath: (input: { parent: Rect | null; child: Rect | null; strip?: Rect; depth?: number }) => LineagePath;
computePath: (input: {
parent: Rect | null;
child: Rect | null;
strip?: Rect;
depth?: number;
orientation?: Orientation;
}) => LineagePath;
DIP_MIN_PX: number;
DIP_MAX_PX: number;
SIBLING_STEP_PX: number;
@@ -48,7 +58,11 @@ function loadLineageApp(): { app: LineageApp; sandbox: LineageSandbox } {
globalThis: {},
CodemanApp,
MobileDetection: { getDeviceType: () => 'desktop' },
document: { getElementById: () => null, createElementNS: () => null },
document: {
documentElement: { getAttribute: () => 'horizontal' },
getElementById: () => null,
createElementNS: () => null,
},
};
const context = vm.createContext(sandbox);
for (const file of ['constants.js', 'session-lineage.js']) {
@@ -258,6 +272,76 @@ describe('lineage line geometry', () => {
).toBeNull();
});
it('routes vertical tabs through the empty left gutter instead of their shared centerline', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 40, width: 260, height: 40 };
const child: Rect = { left: 132, top: 200, width: 260, height: 40 };
const geom = helper.computePath({ parent, child, strip, orientation: 'vertical' })!;
const nums = geom.d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
expect(geom).not.toBeNull();
expect(nums).toHaveLength(5);
expect(nums[0]).toBe(parent.left);
expect(nums[1]).toBe(parent.top + parent.height / 2);
expect(nums[2]).toBeGreaterThan(strip.left);
expect(nums[2]).toBeLessThan(parent.left);
expect(nums[3]).toBe(child.top + child.height / 2);
expect(nums[4]).toBe(child.left);
expect(geom.endX).toBe(child.left);
expect(geom.endY).toBe(child.top + child.height / 2);
});
it('offsets vertical sibling tracks without moving either tab endpoint', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 40, width: 260, height: 40 };
const child: Rect = { left: 132, top: 200, width: 260, height: 40 };
const first = helper.computePath({ parent, child, strip, orientation: 'vertical', depth: 0 })!;
const second = helper.computePath({ parent, child, strip, orientation: 'vertical', depth: 1 })!;
const numbers = (d: string) => d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
expect(numbers(second.d)[2]).toBeGreaterThan(numbers(first.d)[2]);
expect([second.endX, second.endY]).toEqual([first.endX, first.endY]);
});
it('keeps the same gutter shape when the child sits above its parent', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 220, width: 260, height: 40 };
const child: Rect = { left: 132, top: 60, width: 260, height: 40 };
const geom = helper.computePath({ parent, child, strip, orientation: 'vertical' })!;
const nums = geom.d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
expect(nums).toEqual([parent.left, 240, expect.any(Number), 80, child.left]);
expect(nums[2]).toBeGreaterThan(strip.left);
expect(nums[2]).toBeLessThan(parent.left);
expect([geom.endX, geom.endY]).toEqual([child.left, 80]);
});
it('clips vertical lineage by the visible Y range after rail scrolling', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 100, width: 320, height: 300 };
const visible: Rect = { left: 132, top: 160, width: 260, height: 40 };
const above: Rect = { left: 132, top: 20, width: 260, height: 40 };
const below: Rect = { left: 132, top: 460, width: 260, height: 40 };
expect(helper.computePath({ parent: above, child: visible, strip, orientation: 'vertical' })).toBeNull();
expect(helper.computePath({ parent: visible, child: below, strip, orientation: 'vertical' })).toBeNull();
expect(
helper.computePath({ parent: visible, child: { ...visible, top: 300 }, strip, orientation: 'vertical' })
).not.toBeNull();
});
it('passes the resolved DOM orientation into geometry and reserves a vertical gutter', () => {
expect(lineageJs).toContain("getAttribute('data-tab-orientation')");
expect(lineageJs).toMatch(/compute\(\{[\s\S]{0,180}orientation/);
const selector = "html[data-tab-orientation='vertical'] .tab-rail .session-tabs {";
const verticalRailBlock = stylesCss.slice(stylesCss.indexOf(selector), stylesCss.indexOf(selector) + 600);
expect(verticalRailBlock).toContain('--lineage-vertical-gutter');
expect(verticalRailBlock).toContain('padding-left');
});
it('still draws when no strip rect is supplied (clipping is opt-in)', () => {
const helper = loadLineageHelper();
const geom = helper.computePath({ parent: tab(0), child: tab(9000) });
+51
View File
@@ -34,6 +34,7 @@ const SCHEMAS = readFileSync(new URL('../src/web/schemas.ts', import.meta.url),
interface LayoutApp {
soloSessionId: string | null;
isSoloWindow: boolean;
sessions: Map<string, unknown>;
sessionOrder: string[];
_tallTabsEnabled?: boolean;
@@ -44,6 +45,7 @@ interface LayoutApp {
isSessionSidebarActive(): boolean;
isSessionSidebarCollapsed(): boolean;
applySessionListLayout(): void;
applyTabOrientation(): void;
toggleSessionSidebar(): void;
updateSidebarCount(): void;
closeSessionSidebarOnHandheld(): void;
@@ -86,6 +88,7 @@ const SHELL = `
</div>
</header>
<main class="main">
<div class="tab-rail" id="tabRail"></div>
<aside class="session-sidebar" id="sessionSidebar" aria-label="Sessions">
<div class="session-sidebar-head">
<span class="session-sidebar-title">Sessions</span>
@@ -154,6 +157,7 @@ function boot(
// terminal stack. Only the layout surface is under test here.
const app = Object.create(win.__CodemanApp.prototype) as LayoutApp;
app.soloSessionId = options.solo ?? null;
app.isSoloWindow = !!app.soloSessionId;
app.sessions = new Map();
app.sessionOrder = [];
app._elemCache = new Map();
@@ -177,6 +181,39 @@ describe('session list layout', () => {
expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(true);
});
it('preserves vertical rail ownership when the session-list layout reapplies', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'header', tabOrientation: 'vertical' } });
app.applySessionListLayout();
app.applyTabOrientation();
expect(tabsEl(win).parentElement?.id).toBe('tabRail');
app.applySessionListLayout();
expect(tabsEl(win).parentElement?.id).toBe('tabRail');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical');
});
it('keeps aria orientation synchronized when tab orientation moves hosts', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'header', tabOrientation: 'vertical' } });
app.applySessionListLayout();
app.applyTabOrientation();
expect(tabsEl(win).parentElement?.id).toBe('tabRail');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical');
win.localStorage.setItem(
'codeman-app-settings',
JSON.stringify({ sessionListLayout: 'header', tabOrientation: 'horizontal' })
);
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
app.applyTabOrientation();
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('horizontal');
});
it('re-parents the tab list into the sidebar and flips the a11y state', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
expect(app.getSessionListLayout()).toBe('sidebar');
@@ -225,6 +262,20 @@ describe('session list layout', () => {
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
});
it('forces horizontal tabs in a solo window even when vertical orientation is preferred', () => {
const { win, app } = boot({
stored: { sessionListLayout: 'header', tabOrientation: 'vertical' },
solo: 'sess-1',
});
app.applySessionListLayout();
app.applyTabOrientation();
expect(win.document.documentElement.dataset.tabOrientation).toBe('horizontal');
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('horizontal');
});
it('round-trips the collapse state through its own storage key', () => {
// Deliberately NOT in the app-settings blob: saveAppSettings() rebuilds that
// blob from the DOM controls, so a key without a control is wiped on Save.
@@ -0,0 +1,77 @@
/** Real-browser responsive layout coverage for Session Options. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
const mobileStyles = readFileSync(resolve(publicDir, 'mobile.css'), 'utf8');
function sessionOptionsMarkup() {
const start = html.indexOf('<div class="modal" id="sessionOptionsModal">');
const end = html.indexOf('<!-- Close Session Confirmation Modal -->', start);
if (start < 0 || end < 0) throw new Error('Session Options markup not found');
return html.slice(start, end);
}
describe('Session Options responsive layout in Chromium', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
});
afterAll(async () => browser.close());
async function renderAt(width: number) {
await page.setViewportSize({ width, height: 1000 });
await page.setContent(`<!doctype html><html><head><style>${styles}</style>
<style>@media (max-width: 1023px) { ${mobileStyles} }</style></head>
<body>${sessionOptionsMarkup()}</body></html>`);
await page.evaluate(() => {
document.getElementById('sessionOptionsModal')!.classList.add('active');
document
.querySelectorAll('#sessionOptionsModal .set-section')
.forEach((section) => section.classList.add('hidden'));
document.getElementById('context-tab')!.classList.remove('hidden');
});
}
async function metrics() {
return page.evaluate(() => {
const modal = document.querySelector<HTMLElement>('#sessionOptionsModal .modal-content')!;
const doc = document.getElementById('sessionOptionsDoc')!;
const panel = document.getElementById('context-tab')!;
const header = panel.querySelector<HTMLElement>(':scope > .set-section-head')!;
const blurb = panel.querySelector<HTMLElement>(':scope > .set-section-blurb')!;
return {
modalWidth: modal.getBoundingClientRect().width,
docFits: doc.scrollWidth === doc.clientWidth,
panelFits: panel.scrollWidth === panel.clientWidth,
tracks: getComputedStyle(panel).gridTemplateColumns.split(' '),
headerGridColumn: getComputedStyle(header).gridColumn,
blurbGridColumn: getComputedStyle(blurb).gridColumn,
};
});
}
it('uses one fitting column at the tablet-width desktop viewport', async () => {
await renderAt(974);
expect(await metrics()).toMatchObject({ docFits: true, panelFits: true, tracks: [expect.any(String)] });
});
it('uses two fitting columns with a full-width introduction on wide screens', async () => {
await renderAt(1440);
const layout = await metrics();
expect(layout.modalWidth).toBeGreaterThan(1000);
expect(layout.tracks).toHaveLength(2);
expect(layout.headerGridColumn).toBe('1 / -1');
expect(layout.blurbGridColumn).toBe('1 / -1');
expect(layout.docFits).toBe(true);
expect(layout.panelFits).toBe(true);
});
});
+9
View File
@@ -16,6 +16,7 @@ import { resolve } from 'node:path';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const sessionUi = readFileSync(resolve(publicDir, 'session-ui.js'), 'utf8');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
/** The Session Options markup, so assertions can't be satisfied elsewhere. */
function optionsModal(): string {
@@ -94,4 +95,12 @@ describe('Session Options modal structure', () => {
expect(css).toContain(':is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row {');
expect(css).toContain(':is(#sessionOptionsModal, #createCaseModal) .set-section.hidden {');
});
it('uses document-safe context columns and widens only at the desktop breakpoint', () => {
expect(styles).toMatch(/#sessionOptionsModal #context-tab\s*\{[^}]*minmax\(0, 1fr\)/s);
expect(styles).toMatch(
/@media \(min-width: 1200px\)[\s\S]*#sessionOptionsModal #context-tab[^}]*repeat\(2, minmax\(0, 1fr\)\)/
);
expect(styles).not.toMatch(/@media \(min-width: 680px\)[\s\S]{0,1200}#sessionOptionsModal #context-tab/);
});
});
+244
View File
@@ -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();
});
});
+106
View File
@@ -0,0 +1,106 @@
/** Real Chromium visibility and click coverage for nested vertical session actions. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const styles = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
const controller = readFileSync(resolve(import.meta.dirname, '../src/web/public/tab-rail-resize.js'), 'utf8');
function fixture(surface: 'sidebar' | 'rail', active = false) {
const root =
surface === 'sidebar'
? 'data-session-list="sidebar" data-sidebar="expanded" data-tab-orientation="horizontal"'
: 'data-session-list="header" data-sidebar="expanded" data-tab-orientation="vertical"';
const hostClass = surface === 'sidebar' ? 'session-sidebar' : 'tab-rail';
return `<!doctype html><html ${root}><head><style>${styles}</style></head><body>
<div class="${hostClass}"><div class="session-tabs">
<div class="session-tab${active ? ' active' : ''}" tabindex="0">
<span class="tab-info"><span class="tab-name-row"><span class="tab-name">session</span>
<span class="tab-actions"><button class="tab-more" type="button">…</button></span>
</span></span>
</div>
</div></div>
</body></html>`;
}
async function installActionMenuController(page: Page) {
await page.addScriptTag({ content: 'class CodemanApp {}; window.CodemanApp = CodemanApp;' });
await page.addScriptTag({ content: controller });
await page.evaluate(() => {
const app = new (window as any).CodemanApp();
app.loadAppSettingsFromStorage = () => ({ showTabDetachButton: false });
app.openSessionOptions = () => undefined;
app.requestCloseSession = () => undefined;
document
.querySelector('.tab-more')
?.addEventListener('click', (event) => app.openTabRailActionMenu(event, 'session-1'));
(window as any).app = app;
});
}
async function actionState(page: Page) {
return page.locator('.tab-more').evaluate((node) => {
const style = getComputedStyle(node);
return { visibility: style.visibility, pointerEvents: style.pointerEvents };
});
}
describe('expanded vertical session actions in Chromium', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
});
afterAll(async () => {
await browser.close();
});
for (const surface of ['sidebar', 'rail'] as const) {
it(`${surface} hides inactive actions, reveals them contextually, and opens the menu`, async () => {
await page.setContent(fixture(surface));
await installActionMenuController(page);
await page.mouse.move(1200, 760);
const tab = page.locator('.session-tab');
expect(await actionState(page)).toEqual({ visibility: 'hidden', pointerEvents: 'none' });
await tab.hover();
expect(await actionState(page)).toEqual({ visibility: 'visible', pointerEvents: 'auto' });
await page.locator('.tab-more').click();
expect(await page.locator('.tab-rail-action-menu').count()).toBe(1);
await page.setContent(fixture(surface));
await installActionMenuController(page);
await page.locator('.session-tab').focus();
expect(await actionState(page)).toEqual({ visibility: 'visible', pointerEvents: 'auto' });
await page.setContent(fixture(surface, true));
await installActionMenuController(page);
expect(await actionState(page)).toEqual({ visibility: 'visible', pointerEvents: 'auto' });
});
}
it('keeps nested actions reachable for a coarse pointer', async () => {
const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, hasTouch: true });
try {
const touchPage = await context.newPage();
for (const surface of ['sidebar', 'rail'] as const) {
await touchPage.setContent(fixture(surface));
expect(await actionState(touchPage)).toEqual({ visibility: 'visible', pointerEvents: 'auto' });
}
} finally {
await context.close();
}
});
it('closes the real controller menu when viewport geometry changes', async () => {
await page.setContent(fixture('rail', true));
await installActionMenuController(page);
await page.locator('.tab-more').click();
expect(await page.locator('.tab-rail-action-menu').count()).toBe(1);
await page.evaluate(() => window.dispatchEvent(new Event('resize')));
expect(await page.locator('.tab-rail-action-menu').count()).toBe(0);
});
});
+73
View File
@@ -0,0 +1,73 @@
/** Structural and schema coverage for vertical session navigation density and actions. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const app = readFileSync(resolve(publicDir, 'app.js'), 'utf8');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
const i18n = readFileSync(resolve(publicDir, 'i18n.js'), 'utf8');
const railController = readFileSync(resolve(publicDir, 'tab-rail-resize.js'), 'utf8');
describe('vertical session navigation UX contract', () => {
it('accepts only integer session-name sizes from 11 through 18 pixels', () => {
for (const value of [11, 14, 18]) {
expect(SettingsUpdateSchema.safeParse({ sessionSidebarFontSize: value }).success).toBe(true);
}
for (const value of [10, 19, 14.5, '14']) {
expect(SettingsUpdateSchema.safeParse({ sessionSidebarFontSize: value }).success).toBe(false);
}
});
it('renders one existing action cluster through a shared placement resolver', () => {
expect(app).toContain('shouldInlineSessionActions()');
expect(app).toContain('const inlineSessionActions = this.shouldInlineSessionActions();');
expect(app).toContain('const tabActionsHtml =');
expect(app).toContain("${inlineSessionActions ? tabActionsHtml : ''}");
expect(app).toContain("${inlineSessionActions ? '' : tabActionsHtml}");
expect(app.match(/class="tab-actions"/g)).toHaveLength(1);
expect(app).toContain("tab.querySelector(':scope > .tab-actions')");
});
it('limits inline actions to expanded sidebar and expanded non-compact rail', () => {
expect(app).toMatch(/isSessionSidebarActive\(\)[\s\S]{0,100}!this\.isSessionSidebarCollapsed\(\)/);
expect(app).toMatch(/_tabOrientation\(\) === 'vertical'[\s\S]{0,120}tab-rail-compact/);
expect(styles).toContain("html[data-session-list='sidebar'][data-sidebar='expanded']");
expect(styles).toContain("html[data-tab-orientation='vertical']:not(.tab-rail-compact)");
});
it('opens only the existing session actions from the overflow trigger', () => {
expect(railController).toContain('openTabRailActionMenu(event, sessionId)');
expect(railController).toContain("label: 'Session options'");
expect(railController).toContain("label: 'Open in a new window'");
expect(railController).toContain("label: 'Close session'");
expect(railController).not.toContain('Move to group');
});
it('wires the name-only size through first paint, settings, defaults, and both vertical surfaces', () => {
expect(html).toMatch(/id="appSettingsSessionSidebarFontSize"[^>]*min="11"[^>]*max="18"[^>]*step="1"/);
expect(html).toContain('aria-labelledby="appSettingsSessionSidebarFontSizeLabel"');
expect(html).toContain('--session-sidebar-name-font-size');
expect(settingsUi).toContain('sessionSidebarFontSize: this.resolveSessionSidebarFontSize(');
expect(settingsUi).toContain('sessionSidebarFontSize: 14,');
expect(settingsUi).toContain("'sessionSidebarFontSize'");
expect(app).toContain('resolveSessionSidebarFontSize(value)');
expect(app).toContain('applySessionSidebarFontSize(settings = null)');
expect(styles).toMatch(
/\.session-sidebar \.tab-name[^}]*font-size: var\(--session-sidebar-name-font-size, 14px\)/s
);
expect(styles).toMatch(
/\.tab-rail \.session-tab \.tab-name[^}]*font-size: var\(--session-sidebar-name-font-size, 14px\)/s
);
});
it('labels and translates the name-only scope', () => {
expect(html).toContain('Session Name Font Size');
expect(html).toContain('Adjust only session names in the vertical sidebar.');
expect(i18n).toContain("'Session Name Font Size':");
expect(i18n).toContain("'Adjust only session names in the vertical sidebar.':");
});
});
+134
View File
@@ -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);
});
});
+14
View File
@@ -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', () => {
+180
View File
@@ -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__']);
});
});
+264
View File
@@ -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();
});
});
+62
View File
@@ -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
+305
View File
@@ -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();
});
});
+383
View File
@@ -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')]);
});
});
+73
View File
@@ -0,0 +1,73 @@
/** @fileoverview COD-358 vertical session rail orientation policy and wiring. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8');
const indexHtml = read('src/web/public/index.html');
const stylesCss = read('src/web/public/styles.css');
const settingsJs = read('src/web/public/settings-ui.js');
const appJs = read('src/web/public/app.js');
const schemasTs = read('src/web/schemas.ts');
function loadPolicy() {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(read('src/web/public/constants.js'), context, { filename: 'constants.js' });
return (
context.window as {
CodemanTabOverflow: { resolveTabOrientation: (input: unknown) => string };
}
).CodemanTabOverflow;
}
describe('vertical tab orientation policy', () => {
it('honors vertical on desktop and tablet but forces phones horizontal', () => {
const policy = loadPolicy();
expect(policy.resolveTabOrientation({ deviceType: 'desktop', setting: 'vertical' })).toBe('vertical');
expect(policy.resolveTabOrientation({ deviceType: 'tablet', setting: 'vertical' })).toBe('vertical');
expect(policy.resolveTabOrientation({ deviceType: 'mobile', setting: 'vertical' })).toBe('horizontal');
});
it('fails closed to horizontal for absent and unknown values', () => {
const policy = loadPolicy();
expect(policy.resolveTabOrientation(undefined)).toBe('horizontal');
expect(policy.resolveTabOrientation({})).toBe('horizontal');
expect(policy.resolveTabOrientation({ deviceType: 'desktop', setting: 'sideways' })).toBe('horizontal');
});
});
describe('vertical tab rail wiring', () => {
it('ships one rail before the terminal and one orientation control', () => {
expect(indexHtml).toContain('id="tabRail"');
expect(indexHtml.indexOf('id="tabRail"')).toBeLessThan(indexHtml.indexOf('id="terminalContainer"'));
expect(indexHtml).toContain('id="appSettingsTabOrientation"');
expect(indexHtml).toContain('<option value="horizontal">');
expect(indexHtml).toContain('<option value="vertical">');
});
it('moves the existing list between the rail and its header host', () => {
expect(settingsJs).toMatch(/applyTabOrientation\(options = \{\}\)/);
expect(settingsJs).toContain('rail.appendChild(tabsEl)');
expect(settingsJs).toContain('headerHost.appendChild(tabsEl)');
expect(settingsJs).toContain("tabsEl.setAttribute('aria-orientation'");
});
it('applies orientation before horizontal wrapping and suppresses wrap vertically', () => {
expect(appJs.indexOf('this.applyTabOrientation()')).toBeLessThan(appJs.indexOf('this.applyTabWrapSettings()'));
const start = appJs.indexOf('updateTabOverflowMode() {');
const block = appJs.slice(start, start + 3500);
expect(block).toContain('resolveTabOrientation');
expect(block.indexOf('resolveTabOrientation')).toBeLessThan(block.indexOf('shouldAutoWrapTabs'));
});
it('round-trips a device-scoped strict setting and has vertical styles', () => {
expect(settingsJs).toMatch(/tabOrientation:\s*'horizontal'/);
expect(settingsJs).toMatch(/tabOrientation:\s*document\.getElementById\('appSettingsTabOrientation'\)\.value/);
const displayKeys = settingsJs.slice(settingsJs.indexOf('const displayKeys = new Set(['));
expect(displayKeys.slice(0, 1800)).toContain("'tabOrientation'");
expect(schemasTs).toMatch(/tabOrientation:\s*z\.enum\(\['horizontal',\s*'vertical'\]\)\.optional\(\)/);
expect(stylesCss).toContain("html[data-tab-orientation='vertical'] .tab-rail");
});
});
+115
View File
@@ -0,0 +1,115 @@
/** @fileoverview Real Chromium coverage for COD-358 rail resize and label geometry. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
const constants = readFileSync(resolve(publicDir, 'constants.js'), 'utf8');
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
const controller = readFileSync(resolve(publicDir, 'tab-rail-resize.js'), 'utf8');
describe('COD-358 vertical rail in Chromium', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.setContent(`<!doctype html><html data-tab-orientation="vertical"><head><style>${styles}</style></head>
<body><header class="header"><div id="sessionTabsHost"></div></header><main class="main" style="width:100%;height:700px">
<aside class="tab-rail" id="tabRail"><div class="session-tabs" id="sessionTabs"><div class="session-tab active">
<span class="tab-info"><span class="tab-name-row"><span class="tab-name"><span class="tab-name-prefix">w2-codeman: </span>super-long-unbroken-session-name-for-wrapping</span></span></span>
</div></div><div id="tabRailResizeHandle" class="tab-rail-resize-handle" role="separator" tabindex="0" aria-valuemin="208" aria-valuemax="360" aria-valuenow="256"></div></aside>
<div class="terminal-wrap"><iframe title="terminal"></iframe></div></main>
<div id="tabRailResizeShield" class="tab-rail-resize-shield" hidden></div>
<select id="appSettingsTabRailWidth"><option value="custom">Custom</option><option value="208">Compact</option><option value="256">Default</option><option value="320">Wide</option><option value="360">Maximum</option></select>
</body></html>`);
await page.addScriptTag({ content: constants });
await page.addScriptTag({ content: 'class CodemanApp {}; window.CodemanApp = CodemanApp;' });
await page.addScriptTag({ content: settingsUi });
await page.addScriptTag({ content: controller });
await page.evaluate(() => {
const app = new (window as any).CodemanApp();
const settings = { tabOrientation: 'vertical', tabRailWidth: 256 };
(window as any).__settings = settings;
(window as any).__deviceType = 'desktop';
(window as any).MobileDetection = { getDeviceType: () => (window as any).__deviceType };
app.activeSessionId = 'session-1';
app.loadAppSettingsFromStorage = () => settings;
app.saveAppSettingsToStorage = (next: Record<string, unknown>) => Object.assign(settings, next);
app.getDefaultSettings = () => ({});
app.isSessionSidebarActive = () => false;
app.updateTabOverflowMode = () => undefined;
app._fullRenderSessionTabs = () => undefined;
app._updateConnectionLinesImmediate = () => undefined;
app.fitAddon = { proposeDimensions: () => ({ cols: 80, rows: 24 }), fit: () => undefined };
app.sendResize = async () => ((window as any).__resizeCount = ((window as any).__resizeCount || 0) + 1);
app.initTabRailResize();
(window as any).app = app;
});
});
afterAll(async () => browser.close());
it('shows the complete label while clamping it to two lines', async () => {
const metrics = await page.locator('.tab-name').evaluate((element) => {
const style = getComputedStyle(element);
return {
text: element.textContent,
clamp: style.getPropertyValue('-webkit-line-clamp'),
whiteSpace: style.whiteSpace,
overflowWrap: style.overflowWrap,
height: element.getBoundingClientRect().height,
lineHeight: Number.parseFloat(style.lineHeight),
};
});
expect(metrics.text).toBe('w2-codeman: super-long-unbroken-session-name-for-wrapping');
expect(metrics.clamp).toBe('2');
expect(metrics.whiteSpace).toBe('normal');
expect(metrics.overflowWrap).toBe('anywhere');
expect(metrics.height).toBeLessThanOrEqual(metrics.lineHeight * 2 + 1);
});
it('resizes from the keyboard and settles once', async () => {
const handle = page.locator('#tabRailResizeHandle');
await handle.focus();
await handle.press('ArrowRight');
expect(await handle.getAttribute('aria-valuenow')).toBe('264');
await page.waitForTimeout(180);
expect(await page.evaluate(() => (window as any).__resizeCount)).toBe(1);
});
it('preserves 360px while a narrow viewport applies only an effective clamp', async () => {
await page.setViewportSize({ width: 700, height: 800 });
const narrow = await page.evaluate(() => {
(window as any).__settings.tabRailWidth = 360;
return { effective: (window as any).app.applyTabRailWidth(), preferred: (window as any).__settings.tabRailWidth };
});
expect(narrow).toEqual({ effective: 280, preferred: 360 });
await page.setViewportSize({ width: 1280, height: 800 });
expect(await page.evaluate(() => (window as any).app.applyTabRailWidth())).toBe(360);
});
it('reapplies phone fallback in both directions without changing preferences', async () => {
await page.evaluate(() => {
(window as any).__settings.tabRailWidth = 360;
(window as any).__deviceType = 'mobile';
});
await page.setViewportSize({ width: 390, height: 800 });
await page.waitForTimeout(130);
expect(await page.locator('#sessionTabs').evaluate((node) => node.parentElement?.id)).toBe('sessionTabsHost');
expect(await page.evaluate(() => (window as any).__settings)).toEqual({
tabOrientation: 'vertical',
tabRailWidth: 360,
});
await page.evaluate(() => ((window as any).__deviceType = 'desktop'));
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(130);
expect(await page.locator('#sessionTabs').evaluate((node) => node.parentElement?.id)).toBe('tabRail');
expect(await page.evaluate(() => (window as any).app._getCurrentTabRailWidth())).toBe(360);
});
});
+226
View File
@@ -0,0 +1,226 @@
/** @fileoverview COD-358 resizable vertical session rail policy and wiring. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, describe, expect, it, vi } from 'vitest';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const readPublic = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
const indexHtml = readPublic('index.html');
const stylesCss = readPublic('styles.css');
const settingsJs = readPublic('settings-ui.js');
const appJs = readPublic('app.js');
const terminalUiJs = readPublic('terminal-ui.js');
const buildJs = readFileSync(resolve(import.meta.dirname, '../scripts/build.mjs'), 'utf8');
type RailPolicy = {
DEFAULT_WIDTH: number;
MIN_WIDTH: number;
MAX_WIDTH: number;
resolveWidth: (input?: Record<string, unknown>) => number;
resolveKeyboardWidth: (input: Record<string, unknown>) => number | null;
};
function loadRailPolicy(): RailPolicy {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(readPublic('constants.js'), context, { filename: 'constants.js' });
return (context.window as { CodemanTabRail: RailPolicy }).CodemanTabRail;
}
afterEach(() => vi.useRealTimers());
describe('tab rail width policy', () => {
it('uses fixed storage bounds and separate viewport/terminal clamps', () => {
const policy = loadRailPolicy();
expect(policy.DEFAULT_WIDTH).toBe(256);
expect(policy.MIN_WIDTH).toBe(208);
expect(policy.MAX_WIDTH).toBe(360);
expect(policy.resolveWidth()).toBe(256);
expect(policy.resolveWidth({ width: 120 })).toBe(208);
expect(policy.resolveWidth({ width: 900 })).toBe(360);
expect(policy.resolveWidth({ width: 360, viewportWidth: 700 })).toBe(280);
expect(policy.resolveWidth({ width: 360 })).toBe(360);
expect(policy.resolveWidth({ width: 360, mainWidth: 700, minTerminalWidth: 400 })).toBe(300);
});
it('supports accessible keyboard steps and reset keys', () => {
const policy = loadRailPolicy();
const base = { currentWidth: 256, viewportWidth: 1600, mainWidth: 1200, minTerminalWidth: 400 };
expect(policy.resolveKeyboardWidth({ ...base, key: 'ArrowLeft' })).toBe(248);
expect(policy.resolveKeyboardWidth({ ...base, key: 'ArrowRight' })).toBe(264);
expect(policy.resolveKeyboardWidth({ ...base, key: 'ArrowRight', shiftKey: true })).toBe(288);
expect(policy.resolveKeyboardWidth({ ...base, key: 'Home' })).toBe(208);
expect(policy.resolveKeyboardWidth({ ...base, key: 'End' })).toBe(360);
expect(policy.resolveKeyboardWidth({ ...base, key: 'Enter' })).toBe(256);
expect(policy.resolveKeyboardWidth({ ...base, key: 'Escape' })).toBeNull();
});
});
describe('tab rail resize wiring', () => {
it('ships an accessible handle and width presets', () => {
expect(indexHtml).toContain('id="tabRailResizeHandle"');
expect(indexHtml).toContain('role="separator"');
expect(indexHtml).toContain('aria-orientation="vertical"');
expect(indexHtml).toContain('aria-valuemin="208"');
expect(indexHtml).toContain('aria-valuemax="360"');
expect(indexHtml).toContain('id="appSettingsTabRailWidth"');
expect(indexHtml).toContain('<option value="208">Compact');
expect(indexHtml).toContain('<option value="256">Default');
expect(indexHtml).toContain('<option value="320">Wide');
expect(indexHtml).toContain('<option value="360">Maximum');
});
it('keeps preferred width device-local and distinct from effective width', () => {
expect(settingsJs).toMatch(/tabRailWidth:\s*256/);
expect(settingsJs).toMatch(/tabRailWidth:\s*this\.readTabRailWidthSetting/);
const displayKeys = settingsJs.slice(settingsJs.indexOf('const displayKeys = new Set(['));
expect(displayKeys.slice(0, 1900)).toContain("'tabRailWidth'");
const controller = readPublic('tab-rail-resize.js');
expect(controller).toContain('_scheduleTabRailSettle(resolved, preferred)');
expect(controller).toContain('_scheduleTabRailSettle(effective, preferred)');
expect(controller).toContain('applyTabOrientation?.()');
expect(controller).toContain('applyTabRailWidth({ persist: false })');
});
it('installs the controller before terminal creation and owns generic observer churn', () => {
const controller = readPublic('tab-rail-resize.js');
expect(indexHtml).toContain('<script defer src="tab-rail-resize.js"></script>');
expect(appJs).toContain('this.initTabRailResize?.()');
expect(controller).toContain('setPointerCapture');
expect(controller).toContain("addEventListener('lostpointercapture'");
expect(controller).toContain('_tabRailResizeWatchdog');
expect(terminalUiJs).toMatch(/if \(this\._tabRailResizeOwnsObserver\) return/);
expect(buildJs).toContain("run('minify tab-rail-resize.js'");
expect(buildJs).toMatch(/HASHABLE[\s\S]{0,1000}'tab-rail-resize\.js'/);
});
it('coalesces effective changes while persisting the preferred width', async () => {
vi.useFakeTimers();
const controller = readPublic('tab-rail-resize.js');
class FakeCodemanApp {}
const context = vm.createContext({
CodemanApp: FakeCodemanApp,
window: {},
document: { querySelector: () => null },
console,
clearTimeout,
setTimeout,
});
vm.runInContext(controller, context, { filename: 'tab-rail-resize.js' });
const app = new FakeCodemanApp() as FakeCodemanApp & Record<string, any>;
app.activeSessionId = 'session-1';
app.sendResize = vi.fn(async () => true);
app._persistTabRailWidth = vi.fn();
app._tabRailResizeOwnsObserver = true;
app._scheduleTabRailSettle(280, 360);
app._scheduleTabRailSettle(300, 360);
await vi.advanceTimersByTimeAsync(150);
expect(app._persistTabRailWidth).toHaveBeenCalledOnce();
expect(app._persistTabRailWidth).toHaveBeenCalledWith(360);
expect(app.sendResize).toHaveBeenCalledOnce();
expect(app._tabRailResizeOwnsObserver).toBe(false);
});
it('keeps resize-observer ownership for pointer drags longer than the watchdog', async () => {
vi.useFakeTimers();
const controller = readPublic('tab-rail-resize.js');
class FakeCodemanApp {}
let resizing = true;
const context = vm.createContext({
CodemanApp: FakeCodemanApp,
window: {},
document: {
body: { classList: { contains: (name: string) => name === 'tab-rail-resizing' && resizing } },
querySelector: () => null,
},
console,
clearTimeout,
setTimeout,
});
vm.runInContext(controller, context, { filename: 'tab-rail-resize.js' });
const app = new FakeCodemanApp() as FakeCodemanApp & Record<string, any>;
app._claimTabRailResize();
await vi.advanceTimersByTimeAsync(1100);
expect(app._tabRailResizeOwnsObserver).toBe(true);
resizing = false;
await vi.advanceTimersByTimeAsync(1000);
expect(app._tabRailResizeOwnsObserver).toBe(false);
});
it('keeps a viewport-clamped effective width out of the preferred-width control', () => {
const controller = readPublic('tab-rail-resize.js');
class FakeCodemanApp {}
const values = new Map<string, string>();
const rootClasses = new Set<string>();
const custom = { textContent: 'Custom' };
const select = {
value: '360',
dataset: { currentWidth: '360' },
querySelector(selector: string) {
if (selector === 'option[value="360"]') return {};
if (selector === 'option[value="custom"]') return custom;
return null;
},
};
const handle = { setAttribute: vi.fn() };
const document = {
documentElement: {
style: {
getPropertyValue: (name: string) => values.get(name) ?? '',
setProperty: (name: string, value: string) => values.set(name, value),
},
classList: {
contains: (name: string) => rootClasses.has(name),
toggle: (name: string, force: boolean) => (force ? rootClasses.add(name) : rootClasses.delete(name)),
},
},
querySelector: (selector: string) => (selector === '.main' ? { clientWidth: 700 } : null),
getElementById: (id: string) => {
if (id === 'appSettingsTabRailWidth') return select;
if (id === 'tabRailResizeHandle') return handle;
return null;
},
};
const context = vm.createContext({
CodemanApp: FakeCodemanApp,
window: { innerWidth: 700 },
document,
console,
clearTimeout,
setTimeout,
});
vm.runInContext(readPublic('constants.js'), context, { filename: 'constants.js' });
vm.runInContext(controller, context, { filename: 'tab-rail-resize.js' });
const app = new FakeCodemanApp() as FakeCodemanApp & Record<string, any>;
app.loadAppSettingsFromStorage = () => ({ tabRailWidth: 360 });
app.saveAppSettingsToStorage = vi.fn();
expect(app.applyTabRailWidth({ persist: false })).toBe(280);
expect(values.get('--tab-rail-width')).toBe('280px');
expect(select.value).toBe('360');
expect(select.dataset.currentWidth).toBe('360');
expect(app.readTabRailWidthSetting()).toBe(360);
});
it('retains complete labels and clamps only the settled vertical presentation', () => {
expect(appJs).toContain('class="tab-name-prefix"');
const start = stylesCss.indexOf("html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name");
const rule = stylesCss.slice(start, start + 800);
expect(rule).toContain('-webkit-line-clamp: 2');
expect(rule).toContain('overflow-wrap: anywhere');
expect(rule).toContain('white-space: normal');
});
it('closes detached rail menus before tab rebuilds and session cleanup', () => {
const fullRenderStart = appJs.indexOf(' _fullRenderSessionTabs() {');
const fullRender = appJs.slice(fullRenderStart, fullRenderStart + 250);
const cleanupStart = appJs.indexOf(' _cleanupSessionData(sessionId) {');
const cleanup = appJs.slice(cleanupStart, cleanupStart + 250);
expect(fullRender).toContain('this.closeTabRailActionMenu?.()');
expect(cleanup).toContain('this.closeTabRailActionMenu?.()');
});
});
+21 -10
View File
@@ -17,11 +17,22 @@ const PROJECT_HASH = '-home-arkon-default-claudeman';
const SESSION_UUID = '388113c8-cd01-4e80-93a8-3be66ab1519b';
const RUN_ID = 'wf_test1234-abc';
/**
* The fixture's epochs are anchored to "now", never pinned, because the recency
* assertions below compare them against `Date.now()`. A frozen epoch plus a fixed
* window is a time bomb: the original fixture's newest activity sat at
* 2026-06-14T20:06:40Z, and `getRecentRunSummaries(100000)` — that argument is
* MINUTES, i.e. 69.4 days — stopped matching it on 2026-08-23T06:46:40Z, turning
* CI red on a suite nobody had touched. Offsets from the anchor are preserved
* verbatim, so every parsed duration and ordering assertion is unchanged.
*/
const RUN_ANCHOR = Date.now() - 601_000;
/** A run JSON shaped like a real (killed) run: all three agent states + the bloat fields. */
function sampleRunJson() {
return {
runId: RUN_ID,
timestamp: '2026-06-15T00:00:00.000Z',
timestamp: new Date(RUN_ANCHOR).toISOString(),
taskId: 'task_abc',
// --- bloat fields that MUST be stripped ---
script: 'export const meta = {};\n'.repeat(5000), // ~110KB
@@ -35,7 +46,7 @@ function sampleRunJson() {
workflowName: 'review-open-prs',
status: 'killed',
error: 'user stopped the task',
startTime: 1781466999000,
startTime: RUN_ANCHOR,
defaultModel: 'claude-opus-4-8[1m]',
totalTokens: 109703,
totalToolCalls: 44,
@@ -55,13 +66,13 @@ function sampleRunJson() {
agentId: 'a6c0e282c3f5ac0bf',
model: 'claude-opus-4-8[1m]',
state: 'done',
startedAt: 1781467000002,
queuedAt: 1781466999962,
startedAt: RUN_ANCHOR + 1002,
queuedAt: RUN_ANCHOR + 962,
attempt: 1,
lastToolName: 'StructuredOutput',
lastToolSummary: 'Does the profile setting make the allowlist dead config',
promptPreview: 'You are reviewing a pull request...',
lastProgressAt: 1781467524143,
lastProgressAt: RUN_ANCHOR + 525_143,
tokens: 104703,
toolCalls: 41,
durationMs: 524140,
@@ -76,12 +87,12 @@ function sampleRunJson() {
agentId: 'a1234567890abcdef',
model: 'claude-opus-4-8[1m]',
state: 'progress',
startedAt: 1781467010000,
queuedAt: 1781466999970,
startedAt: RUN_ANCHOR + 11_000,
queuedAt: RUN_ANCHOR + 970,
attempt: 1,
lastToolName: 'Read',
promptPreview: 'Review PR 127...',
lastProgressAt: 1781467600000,
lastProgressAt: RUN_ANCHOR + 601_000,
tokens: 5000,
toolCalls: 3,
},
@@ -93,9 +104,9 @@ function sampleRunJson() {
phaseTitle: 'Verify',
model: 'claude-opus-4-8[1m]',
state: 'start',
queuedAt: 1781466999980,
queuedAt: RUN_ANCHOR + 980,
promptPreview: 'Verify finding x...',
lastProgressAt: 1781466999980,
lastProgressAt: RUN_ANCHOR + 980,
},
],
};