mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Post-merge follow-ups from the deep review of #334 and #335, so they ship in the same release as the features. Tab-layout foundation (#335): - PUT /api/session-order drops unknown/foreign ids again instead of 400ing the whole write, in both the owner and the admin path (single-user requests are the synthetic admin, so that path is the one the browser hits). The frontend debounces its reorder push and swallows errors, so a session deleted inside the debounce window silently cost the user the entire reorder - and the endpoint sits on the stable /api/v1 surface, where the pre-layout server merged leniently. - A failed mux restore no longer locks explicit deletions into 500s for the process lifetime: runSessionDeletion and webviewDeleted degrade to best-effort without layout coordination, while the automated stale sweep (runStaleSessionCleanup) stays fail-closed. - sse-events doc comment: no 'suppressed' hook event exists; hooks stay 8. - registerSessionWithLayout resolves its owner through ownerLayoutKey() instead of a hardcoded '@single'. Vertical rail (#334) - all rail-awareness gaps in sidebar-only predicates, unified behind the new _isVerticalTabList() (sidebar OR rail): - Drag-reorder read the insertion side from clientX in the rail, so before/after was effectively arbitrary on vertical rows; the drag-over indicators now draw as top/bottom edges there like the sidebar's. - The active tab is scrolled into view in the rail (Alt+N/palette selection used to leave the row below the fold). - Floating subagent/ultracode windows anchor to the RIGHT of rail tabs, and the connector redraw gates (render tail + strip scroll) cover the rail. - Server-seeded tabOrientation is applied when the async settings load resolves, not only at boot, so a fresh device shows the rail immediately. - The pre-paint script stamps data-tab-orientation and --tab-rail-width (sidebar-wins and solo carve-outs included), removing the flash of the header strip on every vertical-mode load. - The session name font defaults to 12px, the sidebar's historical 0.75rem size, so installs that never touch the new slider are not restyled. Also documents the rail in CLAUDE.md (second #sessionTabs host, mover ordering, the axis-predicate rule) and gives tab-rail-resize.js its @dependency/@loadorder header. Full gate green (6093 tests); the excluded browser suite was run by hand - only the known environmental failures (opencode/codex binaries) remain, identical to pristine master. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1276 lines
47 KiB
TypeScript
1276 lines
47 KiB
TypeScript
/**
|
|
* @fileoverview Tab-layout server coordinator lifecycle, ownership, and version tests.
|
|
*/
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { TabLayoutService } from '../src/tab-layout-service.js';
|
|
import { SseEvent } from '../src/web/sse-events.js';
|
|
import type { TabLayout } from '../src/tab-layout.js';
|
|
|
|
type SessionFact = { id: string; owner?: string; createdAt: number; parentSessionId?: string; pinned?: boolean };
|
|
|
|
function createHarness(
|
|
options: {
|
|
layouts?: Record<string, TabLayout>;
|
|
order?: string[];
|
|
persisted?: SessionFact[];
|
|
live?: SessionFact[];
|
|
webviews?: Array<{ id: string; owner?: string }>;
|
|
now?: () => string;
|
|
} = {}
|
|
) {
|
|
const layouts = { ...(options.layouts ?? {}) };
|
|
const persisted = Object.fromEntries((options.persisted ?? []).map((session) => [session.id, session]));
|
|
const live = new Map((options.live ?? []).map((session) => [session.id, session]));
|
|
const order = [...(options.order ?? [])];
|
|
const readWebviews = vi.fn(async () => options.webviews ?? []);
|
|
const store = {
|
|
getTabLayout: vi.fn((owner: string) => layouts[owner] ?? null),
|
|
getTabLayouts: vi.fn(() => ({ ...layouts })),
|
|
getSessions: vi.fn(() => persisted),
|
|
getSessionOrder: vi.fn(() => [...order]),
|
|
commitTabLayoutProjection: vi.fn(
|
|
(updates: Readonly<Record<string, TabLayout>>, project: (latest: readonly string[]) => readonly string[]) => {
|
|
const projected = [...project([...order])];
|
|
Object.assign(layouts, structuredClone(updates));
|
|
order.splice(0, order.length, ...projected);
|
|
return { layouts: structuredClone(updates), sessionOrder: [...order] };
|
|
}
|
|
),
|
|
};
|
|
const broadcast = vi.fn();
|
|
const broadcastSessionOrder = vi.fn();
|
|
const service = new TabLayoutService({
|
|
store: store as never,
|
|
sessions: live as never,
|
|
readWebviews,
|
|
broadcast,
|
|
broadcastSessionOrder,
|
|
now: options.now ?? (() => '2026-08-16T12:00:00.000Z'),
|
|
});
|
|
return { service, store, broadcast, broadcastSessionOrder, layouts, order, live, persisted, readWebviews };
|
|
}
|
|
|
|
const existing = (refs: TabLayout['ungrouped'], version = 7): TabLayout => ({
|
|
version,
|
|
groups: [],
|
|
ungrouped: refs,
|
|
updatedAt: '2026-08-15T00:00:00.000Z',
|
|
});
|
|
|
|
describe('TabLayoutService', () => {
|
|
it('guards destructive transactions by restoration state while allowing skipped test mode', async () => {
|
|
const h = createHarness({ layouts: { '@single': existing([{ kind: 'session', id: 'mine' }]) } });
|
|
const action = vi.fn(async () => 'removed');
|
|
|
|
await expect(h.service.runSessionDeletion([{ id: 'mine' }], action)).rejects.toThrow(/restoration.*pending/i);
|
|
expect(action).not.toHaveBeenCalled();
|
|
|
|
h.service.markRestorationSkipped();
|
|
await expect(h.service.runSessionDeletion([{ id: 'mine' }], action)).resolves.toBe('removed');
|
|
expect(action).toHaveBeenCalledTimes(1);
|
|
expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled();
|
|
expect(h.broadcastSessionOrder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('degrades an explicit deletion to best-effort after a failed restore, while the stale sweep stays blocked', async () => {
|
|
// A tmux hiccup at boot used to lock DELETE /api/sessions/:id (and webview
|
|
// deletes) into opaque 500s for the whole process lifetime. A user's
|
|
// explicit close now runs without layout coordination; only the AUTOMATED
|
|
// stale sweep stays fail-closed, because it picks its own victims from
|
|
// state a failed restore may have left incomplete.
|
|
const h = createHarness({ layouts: { '@single': existing([{ kind: 'session', id: 'mine' }]) } });
|
|
const action = vi.fn(async () => 'removed');
|
|
h.service.markRestorationFailed();
|
|
|
|
await expect(h.service.runSessionDeletion([{ id: 'mine' }], action)).resolves.toBe('removed');
|
|
expect(action).toHaveBeenCalledTimes(1);
|
|
expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled();
|
|
expect(h.broadcastSessionOrder).not.toHaveBeenCalled();
|
|
|
|
await expect(h.service.webviewDeleted('@single', 'w1')).resolves.toBeUndefined();
|
|
|
|
const cleanup = vi.fn();
|
|
await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).rejects.toThrow(/restoration.*failed/i);
|
|
expect(cleanup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('prepares and commits a complete-state session deletion around the resource action', async () => {
|
|
const h = createHarness({
|
|
layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) },
|
|
live: [{ id: 'mine', owner: 'alice', createdAt: 1 }],
|
|
});
|
|
h.service.markRestorationComplete();
|
|
const action = vi.fn(async () => {
|
|
expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled();
|
|
h.live.delete('mine');
|
|
return 'removed';
|
|
});
|
|
|
|
await expect(h.service.runSessionDeletion([{ id: 'mine', owner: 'alice' }], action)).resolves.toBe('removed');
|
|
|
|
expect(h.layouts.alice.ungrouped).toEqual([]);
|
|
expect(h.layouts.alice.version).toBe(8);
|
|
});
|
|
|
|
it('leaves stale records, layouts, versions, and events intact when bulk preparation fails', async () => {
|
|
const h = createHarness({
|
|
layouts: { alice: existing([{ kind: 'session', id: 'stale' }]) },
|
|
persisted: [{ id: 'stale', owner: 'alice', createdAt: 1 }],
|
|
});
|
|
h.service.markRestorationComplete();
|
|
const cleanup = vi.fn(() => {
|
|
delete h.persisted.stale;
|
|
return { count: 1, cleaned: [{ id: 'stale', owner: 'alice' }] };
|
|
});
|
|
h.store.getSessions
|
|
.mockImplementationOnce(() => h.persisted)
|
|
.mockImplementationOnce(() => {
|
|
throw new Error('malformed persisted state');
|
|
});
|
|
|
|
await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).rejects.toThrow('malformed persisted state');
|
|
|
|
expect(cleanup).not.toHaveBeenCalled();
|
|
expect(h.persisted.stale).toBeDefined();
|
|
expect(h.layouts.alice).toEqual(existing([{ kind: 'session', id: 'stale' }]));
|
|
expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled();
|
|
expect(h.broadcast).not.toHaveBeenCalled();
|
|
expect(h.broadcastSessionOrder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('bulk-cleans multiple owners once each while preserving pinned stale records', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'alice-stale' },
|
|
{ kind: 'session', id: 'alice-pinned' },
|
|
]),
|
|
bob: existing([{ kind: 'session', id: 'bob-stale' }]),
|
|
},
|
|
persisted: [
|
|
{ id: 'bob-stale', owner: 'bob', createdAt: 1 },
|
|
{ id: 'alice-stale', owner: 'alice', createdAt: 2 },
|
|
{ id: 'alice-pinned', owner: 'alice', createdAt: 3, pinned: true },
|
|
],
|
|
});
|
|
h.service.markRestorationComplete();
|
|
const cleanup = vi.fn(() => {
|
|
delete h.persisted['alice-stale'];
|
|
delete h.persisted['bob-stale'];
|
|
return { count: 2 };
|
|
});
|
|
|
|
await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).resolves.toEqual({ count: 2 });
|
|
|
|
expect(h.layouts.alice.ungrouped).toEqual([{ kind: 'session', id: 'alice-pinned' }]);
|
|
expect(h.layouts.bob.ungrouped).toEqual([]);
|
|
expect(h.layouts.alice.version).toBe(8);
|
|
expect(h.layouts.bob.version).toBe(8);
|
|
expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1);
|
|
expect(Object.keys(h.store.commitTabLayoutProjection.mock.calls[0][0])).toEqual(['alice', 'bob']);
|
|
expect(h.broadcast).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('does not commit a prepared stale layout when asynchronous cleanup rejects', async () => {
|
|
const h = createHarness({
|
|
layouts: { alice: existing([{ kind: 'session', id: 'alice-stale' }]) },
|
|
persisted: [{ id: 'alice-stale', owner: 'alice', createdAt: 1 }],
|
|
});
|
|
h.service.markRestorationComplete();
|
|
const cleanup = vi.fn(async () => {
|
|
throw new Error('state removal failed');
|
|
});
|
|
|
|
await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).rejects.toThrow('state removal failed');
|
|
|
|
expect(h.layouts.alice).toEqual(existing([{ kind: 'session', id: 'alice-stale' }]));
|
|
expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled();
|
|
expect(h.broadcast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('binds stale cleanup to revalidated original candidates after an owner queue wait', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([{ kind: 'session', id: 'alice-stale' }]),
|
|
bob: existing([{ kind: 'session', id: 'bob-new-stale' }]),
|
|
},
|
|
order: ['alice-stale'],
|
|
persisted: [{ id: 'alice-stale', owner: 'alice', createdAt: 1 }],
|
|
});
|
|
h.service.markRestorationComplete();
|
|
let releaseFacts!: () => void;
|
|
const factsBlocked = new Promise<void>((resolve) => {
|
|
releaseFacts = resolve;
|
|
});
|
|
h.readWebviews.mockImplementationOnce(async () => {
|
|
await factsBlocked;
|
|
return [];
|
|
});
|
|
|
|
const occupiedOwnerQueue = h.service.get('alice');
|
|
await vi.waitFor(() => expect(h.readWebviews).toHaveBeenCalledOnce());
|
|
const cleanup = vi.fn((ids: ReadonlySet<string>) => ({ ids: [...ids] }));
|
|
const pending = h.service.runStaleSessionCleanup(new Set(), cleanup);
|
|
|
|
h.persisted['alice-stale'].pinned = true;
|
|
h.persisted['bob-new-stale'] = { id: 'bob-new-stale', owner: 'bob', createdAt: 2 };
|
|
releaseFacts();
|
|
|
|
await occupiedOwnerQueue;
|
|
await expect(pending).resolves.toEqual({ ids: [] });
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
expect(cleanup).toHaveBeenCalledWith(new Set());
|
|
expect(h.persisted['alice-stale']).toBeDefined();
|
|
expect(h.persisted['bob-new-stale']).toBeDefined();
|
|
expect(h.layouts.alice.ungrouped).toEqual([{ kind: 'session', id: 'alice-stale' }]);
|
|
expect(h.layouts.bob.ungrouped).toEqual([{ kind: 'session', id: 'bob-new-stale' }]);
|
|
expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows skipped-mode stale cleanup without pruning or versioning layouts', async () => {
|
|
const h = createHarness({
|
|
layouts: { '@single': existing([{ kind: 'session', id: 'stale' }]) },
|
|
persisted: [{ id: 'stale', createdAt: 1 }],
|
|
});
|
|
h.service.markRestorationSkipped();
|
|
const cleanup = vi.fn(() => ({ count: 1 }));
|
|
|
|
await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).resolves.toEqual({ count: 1 });
|
|
|
|
expect(cleanup).toHaveBeenCalledOnce();
|
|
expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled();
|
|
expect(h.broadcast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('repairs missing owner refs once while preserving unknown refs', async () => {
|
|
const h = createHarness({
|
|
layouts: { alice: existing([{ kind: 'session', id: 'unknown' }]) },
|
|
live: [{ id: 'mine', owner: 'alice', createdAt: 1 }],
|
|
});
|
|
const first = await h.service.get('alice');
|
|
expect(first.ungrouped).toEqual([
|
|
{ kind: 'session', id: 'unknown' },
|
|
{ kind: 'session', id: 'mine' },
|
|
]);
|
|
expect(first.version).toBe(8);
|
|
await h.service.get('alice');
|
|
expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1);
|
|
expect(h.broadcast).toHaveBeenCalledWith(SseEvent.TabLayoutChanged, { owner: 'alice', version: 8 });
|
|
});
|
|
|
|
it('repairs only the legacy projection on GET when the stored layout is already canonical', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'a' },
|
|
{ kind: 'session', id: 'b' },
|
|
]),
|
|
},
|
|
order: ['b', 'a'],
|
|
live: [
|
|
{ id: 'a', owner: 'alice', createdAt: 1 },
|
|
{ id: 'b', owner: 'alice', createdAt: 2 },
|
|
],
|
|
});
|
|
|
|
const first = await h.service.get('alice');
|
|
const second = await h.service.get('alice');
|
|
|
|
expect(first).toEqual(
|
|
existing([
|
|
{ kind: 'session', id: 'a' },
|
|
{ kind: 'session', id: 'b' },
|
|
])
|
|
);
|
|
expect(second).toEqual(first);
|
|
expect(h.order).toEqual(['a', 'b']);
|
|
expect(h.layouts.alice.version).toBe(7);
|
|
expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1);
|
|
expect(h.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({});
|
|
expect(h.broadcast).not.toHaveBeenCalled();
|
|
expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({
|
|
changedOwnerOrders: { alice: ['a', 'b'] },
|
|
globalOrder: ['a', 'b'],
|
|
globalChanged: true,
|
|
});
|
|
expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({
|
|
changedOwnerOrders: { alice: ['a', 'b'] },
|
|
globalOrder: ['a', 'b'],
|
|
globalChanged: true,
|
|
});
|
|
});
|
|
|
|
it('isolates owner metadata and filters saved webviews by exact owner', async () => {
|
|
const h = createHarness({
|
|
live: [
|
|
{ id: 'a', owner: 'alice', createdAt: 1 },
|
|
{ id: 'b', owner: 'bob', createdAt: 2 },
|
|
],
|
|
webviews: [
|
|
{ id: 'wa', owner: 'alice' },
|
|
{ id: 'wb', owner: 'bob' },
|
|
],
|
|
});
|
|
expect((await h.service.get('alice')).ungrouped.map((ref) => ref.id)).toEqual(['a', 'wa']);
|
|
expect((await h.service.get('bob')).ungrouped.map((ref) => ref.id)).toEqual(['b', 'wb']);
|
|
});
|
|
|
|
it('rejects foreign and unknown refs atomically even for an admin-owned layout', async () => {
|
|
const h = createHarness({
|
|
layouts: { admin: existing([{ kind: 'session', id: 'mine' }]) },
|
|
live: [
|
|
{ id: 'mine', owner: 'admin', createdAt: 1 },
|
|
{ id: 'foreign', owner: 'bob', createdAt: 2 },
|
|
],
|
|
});
|
|
const desired = existing([{ kind: 'session', id: 'foreign' }]);
|
|
await expect(h.service.put('admin', desired, 7)).rejects.toThrow(/not owned/);
|
|
expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([6, 8])('conflicts on every non-exact base version (%s)', async (baseVersion) => {
|
|
const h = createHarness({
|
|
layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) },
|
|
live: [{ id: 'mine', owner: 'alice', createdAt: 1 }],
|
|
});
|
|
expect(await h.service.put('alice', existing([{ kind: 'session', id: 'mine' }]), baseVersion)).toEqual({
|
|
status: 'conflict',
|
|
layout: existing([{ kind: 'session', id: 'mine' }]),
|
|
});
|
|
expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled();
|
|
expect(h.broadcastSessionOrder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns an unpersisted prepared layout for a stale first write without broadcasting', async () => {
|
|
const h = createHarness({
|
|
live: [{ id: 'mine', owner: 'alice', createdAt: 1 }],
|
|
});
|
|
|
|
await expect(h.service.put('alice', existing([{ kind: 'session', id: 'mine' }]), 7)).resolves.toEqual({
|
|
status: 'conflict',
|
|
layout: {
|
|
version: 0,
|
|
groups: [],
|
|
ungrouped: [{ kind: 'session', id: 'mine' }],
|
|
updatedAt: '2026-08-16T12:00:00.000Z',
|
|
},
|
|
});
|
|
expect(h.layouts.alice).toBeUndefined();
|
|
expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled();
|
|
expect(h.broadcast).not.toHaveBeenCalled();
|
|
expect(h.broadcastSessionOrder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns a normalized prepared layout for a stale write without persisting the reconciliation', async () => {
|
|
const h = createHarness({
|
|
layouts: { alice: existing([{ kind: 'session', id: 'unknown' }]) },
|
|
live: [{ id: 'mine', owner: 'alice', createdAt: 1 }],
|
|
});
|
|
|
|
await expect(h.service.put('alice', existing([{ kind: 'session', id: 'mine' }]), 6)).resolves.toEqual({
|
|
status: 'conflict',
|
|
layout: existing([
|
|
{ kind: 'session', id: 'unknown' },
|
|
{ kind: 'session', id: 'mine' },
|
|
]),
|
|
});
|
|
expect(h.layouts.alice).toEqual(existing([{ kind: 'session', id: 'unknown' }]));
|
|
expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled();
|
|
expect(h.broadcast).not.toHaveBeenCalled();
|
|
expect(h.broadcastSessionOrder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('folds first-write initialization and the requested update into one commit', async () => {
|
|
const h = createHarness({
|
|
live: [{ id: 'mine', owner: 'alice', createdAt: 1 }],
|
|
});
|
|
const desired = {
|
|
...existing([]),
|
|
groups: [{ id: 'g', name: 'Mine', refs: [{ kind: 'session', id: 'mine' }] }],
|
|
};
|
|
|
|
const result = await h.service.put('alice', desired, 0);
|
|
|
|
expect(result).toEqual({
|
|
status: 'updated',
|
|
layout: {
|
|
version: 1,
|
|
groups: [{ id: 'g', name: 'Mine', refs: [{ kind: 'session', id: 'mine' }] }],
|
|
ungrouped: [],
|
|
updatedAt: '2026-08-16T12:00:00.000Z',
|
|
},
|
|
});
|
|
expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1);
|
|
expect(h.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({ alice: result.layout });
|
|
expect(h.broadcast).toHaveBeenCalledExactlyOnceWith(SseEvent.TabLayoutChanged, { owner: 'alice', version: 1 });
|
|
});
|
|
|
|
it('uses the prepared authoritative ordering for a matching folded first write with tied session timestamps', async () => {
|
|
const h = createHarness({
|
|
live: [
|
|
{ id: 'a', owner: 'alice', createdAt: 1 },
|
|
{ id: 'B', owner: 'alice', createdAt: 1 },
|
|
],
|
|
});
|
|
|
|
const result = await h.service.put('alice', existing([]), 0);
|
|
|
|
expect(result).toEqual({
|
|
status: 'updated',
|
|
layout: {
|
|
version: 1,
|
|
groups: [],
|
|
ungrouped: [
|
|
{ kind: 'session', id: 'B' },
|
|
{ kind: 'session', id: 'a' },
|
|
],
|
|
updatedAt: '2026-08-16T12:00:00.000Z',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('folds normalization and a matching requested update into one commit', async () => {
|
|
const h = createHarness({
|
|
layouts: { alice: existing([{ kind: 'session', id: 'unknown' }]) },
|
|
live: [{ id: 'mine', owner: 'alice', createdAt: 1 }],
|
|
});
|
|
const desired = {
|
|
...existing([]),
|
|
groups: [{ id: 'g', name: 'Mine', refs: [{ kind: 'session', id: 'mine' }] }],
|
|
};
|
|
|
|
const result = await h.service.put('alice', desired, 7);
|
|
|
|
expect(result).toEqual({
|
|
status: 'updated',
|
|
layout: {
|
|
version: 8,
|
|
groups: [{ id: 'g', name: 'Mine', refs: [{ kind: 'session', id: 'mine' }] }],
|
|
ungrouped: [],
|
|
updatedAt: '2026-08-16T12:00:00.000Z',
|
|
},
|
|
});
|
|
expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1);
|
|
expect(h.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({ alice: result.layout });
|
|
expect(h.broadcast).toHaveBeenCalledExactlyOnceWith(SseEvent.TabLayoutChanged, { owner: 'alice', version: 8 });
|
|
});
|
|
|
|
it('stores a matching write with one fresh version and minimal event payload', async () => {
|
|
const h = createHarness({
|
|
layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) },
|
|
live: [{ id: 'mine', owner: 'alice', createdAt: 1 }],
|
|
});
|
|
const result = await h.service.put(
|
|
'alice',
|
|
{ ...existing([{ kind: 'session', id: 'mine' }]), groups: [{ id: 'g', name: ' Group ', refs: [] }] },
|
|
7
|
|
);
|
|
expect(result.status).toBe('updated');
|
|
expect(result.layout).toMatchObject({ version: 8, updatedAt: '2026-08-16T12:00:00.000Z' });
|
|
expect(result.layout.groups[0].name).toBe('Group');
|
|
expect(h.broadcast).toHaveBeenLastCalledWith(SseEvent.TabLayoutChanged, { owner: 'alice', version: 8 });
|
|
});
|
|
|
|
it('serializes concurrent writes so only one exact base version can win', async () => {
|
|
const h = createHarness({
|
|
layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) },
|
|
live: [{ id: 'mine', owner: 'alice', createdAt: 1 }],
|
|
});
|
|
const desired = existing([{ kind: 'session', id: 'mine' }]);
|
|
const results = await Promise.all([h.service.put('alice', desired, 7), h.service.put('alice', desired, 7)]);
|
|
expect(results.map((result) => result.status).sort()).toEqual(['conflict', 'updated']);
|
|
expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1);
|
|
expect(h.layouts.alice.version).toBe(8);
|
|
});
|
|
|
|
it('places a root at the end and a child after its parent subtree', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'parent' },
|
|
{ kind: 'session', id: 'tail' },
|
|
]),
|
|
},
|
|
live: [
|
|
{ id: 'parent', owner: 'alice', createdAt: 1 },
|
|
{ id: 'tail', owner: 'alice', createdAt: 2 },
|
|
],
|
|
});
|
|
h.live.set('root', { id: 'root', owner: 'alice', createdAt: 3 } as never);
|
|
await h.service.sessionCreated('alice');
|
|
expect(h.layouts.alice.ungrouped.map((ref) => ref.id)).toEqual(['parent', 'tail', 'root']);
|
|
h.live.set('child', { id: 'child', owner: 'alice', createdAt: 4, parentSessionId: 'parent' } as never);
|
|
await h.service.sessionCreated('alice');
|
|
expect(h.layouts.alice.ungrouped.map((ref) => ref.id)).toEqual(['parent', 'child', 'tail', 'root']);
|
|
expect(h.layouts.alice.version).toBe(9);
|
|
});
|
|
|
|
it('appends a newly saved owner webview and rejects over-limit writes atomically', async () => {
|
|
const webviews: Array<{ id: string; owner?: string }> = [];
|
|
const h = createHarness({
|
|
layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) },
|
|
live: [{ id: 'mine', owner: 'alice', createdAt: 1 }],
|
|
webviews,
|
|
});
|
|
webviews.push({ id: 'dashboard', owner: 'alice' });
|
|
await h.service.webviewCreated('alice');
|
|
expect(h.layouts.alice.ungrouped.at(-1)).toEqual({ kind: 'webview', id: 'dashboard' });
|
|
|
|
const tooMany = Array.from({ length: 513 }, (_, index) => ({ kind: 'session' as const, id: `s-${index}` }));
|
|
await expect(h.service.put('alice', existing(tooMany, 8), 8)).rejects.toThrow(/512/);
|
|
expect(h.layouts.alice.version).toBe(8);
|
|
});
|
|
|
|
it('materializes children and removes an explicitly deleted parent in one mutation', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'parent' },
|
|
{ kind: 'session', id: 'child' },
|
|
]),
|
|
},
|
|
live: [{ id: 'child', owner: 'alice', createdAt: 2, parentSessionId: 'parent' }],
|
|
});
|
|
h.service.markRestorationComplete();
|
|
await h.service.sessionsRemoved([{ id: 'parent', owner: 'alice' }]);
|
|
expect(h.layouts.alice.ungrouped).toEqual([{ kind: 'session', id: 'child', placement: 'manual' }]);
|
|
expect(h.layouts.alice.version).toBe(8);
|
|
expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('publishes multi-owner post-removal repairs in one combined transaction', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([{ kind: 'session', id: 'a-stale' }]),
|
|
bob: existing([{ kind: 'session', id: 'b-stale' }]),
|
|
},
|
|
order: ['a-stale', 'b-stale'],
|
|
});
|
|
h.service.markRestorationComplete();
|
|
|
|
await h.service.sessionsRemoved([
|
|
{ id: 'a-stale', owner: 'alice' },
|
|
{ id: 'b-stale', owner: 'bob' },
|
|
]);
|
|
|
|
expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1);
|
|
expect(Object.keys(h.store.commitTabLayoutProjection.mock.calls[0][0])).toEqual(['alice', 'bob']);
|
|
expect(h.layouts.alice.ungrouped).toEqual([]);
|
|
expect(h.layouts.bob.ungrouped).toEqual([]);
|
|
expect(h.order).toEqual([]);
|
|
});
|
|
|
|
it('does not publish any owner when later multi-owner post-removal preparation fails', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([{ kind: 'session', id: 'a-stale' }]),
|
|
bob: existing([{ kind: 'session', id: 'b-stale' }]),
|
|
},
|
|
order: ['a-stale', 'b-stale'],
|
|
});
|
|
h.service.markRestorationComplete();
|
|
h.readWebviews.mockResolvedValueOnce([]).mockRejectedValueOnce(new Error('bob facts failed'));
|
|
|
|
await expect(
|
|
h.service.sessionsRemoved([
|
|
{ id: 'a-stale', owner: 'alice' },
|
|
{ id: 'b-stale', owner: 'bob' },
|
|
])
|
|
).rejects.toThrow('bob facts failed');
|
|
|
|
expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled();
|
|
expect(h.layouts.alice.ungrouped.map((ref) => ref.id)).toEqual(['a-stale']);
|
|
expect(h.layouts.bob.ungrouped.map((ref) => ref.id)).toEqual(['b-stale']);
|
|
});
|
|
|
|
it('prepares deletion version metadata before running the irreversible action', async () => {
|
|
const action = vi.fn(async () => 'removed');
|
|
const h = createHarness({
|
|
layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) },
|
|
live: [{ id: 'mine', owner: 'alice', createdAt: 1 }],
|
|
now: () => {
|
|
throw new Error('clock failed');
|
|
},
|
|
});
|
|
h.service.markRestorationComplete();
|
|
|
|
await expect(h.service.runSessionDeletion([{ id: 'mine', owner: 'alice' }], action)).rejects.toThrow(
|
|
'clock failed'
|
|
);
|
|
|
|
expect(action).not.toHaveBeenCalled();
|
|
expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('removes a legacy-only ID when deleting before owner layout migration', async () => {
|
|
const h = createHarness({
|
|
order: ['mine'],
|
|
live: [{ id: 'mine', owner: 'alice', createdAt: 1 }],
|
|
});
|
|
h.service.markRestorationComplete();
|
|
const action = vi.fn(async () => {
|
|
h.live.delete('mine');
|
|
return 'removed';
|
|
});
|
|
|
|
await expect(h.service.runSessionDeletion([{ id: 'mine', owner: 'alice' }], action)).resolves.toBe('removed');
|
|
|
|
expect(h.layouts.alice).toBeUndefined();
|
|
expect(h.order).toEqual([]);
|
|
expect(h.store.commitTabLayoutProjection).toHaveBeenCalledExactlyOnceWith({}, expect.any(Function));
|
|
expect(h.broadcast).not.toHaveBeenCalled();
|
|
expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({
|
|
changedOwnerOrders: { alice: [] },
|
|
globalOrder: [],
|
|
globalChanged: true,
|
|
});
|
|
});
|
|
|
|
it('removes a deleted legacy ID absent from an existing layout without bumping its version', async () => {
|
|
const h = createHarness({
|
|
layouts: { alice: existing([{ kind: 'session', id: 'keep' }]) },
|
|
order: ['gone', 'keep'],
|
|
live: [
|
|
{ id: 'gone', owner: 'alice', createdAt: 1 },
|
|
{ id: 'keep', owner: 'alice', createdAt: 2 },
|
|
],
|
|
});
|
|
h.service.markRestorationComplete();
|
|
|
|
await h.service.runSessionDeletion([{ id: 'gone', owner: 'alice' }], async () => {
|
|
h.live.delete('gone');
|
|
});
|
|
|
|
expect(h.layouts.alice).toEqual(existing([{ kind: 'session', id: 'keep' }]));
|
|
expect(h.order).toEqual(['keep']);
|
|
expect(h.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({});
|
|
expect(h.broadcast).not.toHaveBeenCalled();
|
|
expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({
|
|
changedOwnerOrders: { alice: ['keep'] },
|
|
globalOrder: ['keep'],
|
|
globalChanged: true,
|
|
});
|
|
});
|
|
|
|
it('removes legacy-only IDs from post-removal repair with no migrated layout or layout delta', async () => {
|
|
const h = createHarness({
|
|
layouts: { bob: existing([{ kind: 'session', id: 'keep' }]) },
|
|
order: ['alice-gone', 'bob-gone', 'keep'],
|
|
live: [{ id: 'keep', owner: 'bob', createdAt: 3 }],
|
|
});
|
|
h.service.markRestorationComplete();
|
|
|
|
await h.service.sessionsRemoved([
|
|
{ id: 'alice-gone', owner: 'alice' },
|
|
{ id: 'bob-gone', owner: 'bob' },
|
|
]);
|
|
|
|
expect(h.layouts.alice).toBeUndefined();
|
|
expect(h.layouts.bob).toEqual(existing([{ kind: 'session', id: 'keep' }]));
|
|
expect(h.order).toEqual(['keep']);
|
|
expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1);
|
|
expect(h.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({});
|
|
expect(h.broadcast).not.toHaveBeenCalled();
|
|
expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({
|
|
changedOwnerOrders: { alice: [], bob: ['keep'] },
|
|
globalOrder: ['keep'],
|
|
globalChanged: true,
|
|
});
|
|
});
|
|
|
|
it('removes a confirmed stale legacy-only ID even before owner layout migration', async () => {
|
|
const h = createHarness({
|
|
order: ['stale'],
|
|
persisted: [{ id: 'stale', owner: 'alice', createdAt: 1 }],
|
|
});
|
|
h.service.markRestorationComplete();
|
|
const cleanup = vi.fn((ids: ReadonlySet<string>) => {
|
|
delete h.persisted.stale;
|
|
return [...ids];
|
|
});
|
|
|
|
await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).resolves.toEqual(['stale']);
|
|
|
|
expect(h.layouts.alice).toBeUndefined();
|
|
expect(h.order).toEqual([]);
|
|
expect(h.store.commitTabLayoutProjection).toHaveBeenCalledExactlyOnceWith({}, expect.any(Function));
|
|
expect(h.broadcast).not.toHaveBeenCalled();
|
|
expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({
|
|
changedOwnerOrders: { alice: [] },
|
|
globalOrder: [],
|
|
globalChanged: true,
|
|
});
|
|
});
|
|
|
|
it('does not prune or bump when restoration was skipped', async () => {
|
|
const h = createHarness({ layouts: { '@single': existing([{ kind: 'session', id: 'gone' }]) } });
|
|
h.service.markRestorationSkipped();
|
|
await h.service.sessionsRemoved([{ id: 'gone' }]);
|
|
await h.service.webviewDeleted('@single', 'also-gone');
|
|
expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled();
|
|
expect(h.layouts['@single'].version).toBe(7);
|
|
});
|
|
|
|
it('retains pinned stopped sessions and prunes saved webviews after restoration', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'pinned' },
|
|
{ kind: 'webview', id: 'gone-web' },
|
|
]),
|
|
},
|
|
persisted: [{ id: 'pinned', owner: 'alice', createdAt: 1, pinned: true }],
|
|
});
|
|
h.service.markRestorationComplete();
|
|
await h.service.webviewDeleted('alice', 'gone-web');
|
|
expect(h.layouts.alice.ungrouped).toEqual([{ kind: 'session', id: 'pinned' }]);
|
|
expect(h.layouts.alice.version).toBe(8);
|
|
});
|
|
|
|
it('bridges a regular legacy reorder into the owner layout and canonical global projection', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'a' },
|
|
{ kind: 'session', id: 'b' },
|
|
]),
|
|
},
|
|
order: ['a', 'b'],
|
|
live: [
|
|
{ id: 'a', owner: 'alice', createdAt: 1 },
|
|
{ id: 'b', owner: 'alice', createdAt: 2 },
|
|
],
|
|
});
|
|
|
|
await expect(h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['b', 'a'])).resolves.toEqual({
|
|
order: ['b', 'a'],
|
|
changedOwnerOrders: { alice: ['b', 'a'] },
|
|
globalOrder: ['b', 'a'],
|
|
globalChanged: true,
|
|
});
|
|
expect((await h.service.get('alice')).ungrouped.map((ref) => ref.id)).toEqual(['b', 'a']);
|
|
expect(h.layouts.alice.version).toBe(8);
|
|
expect(h.broadcast).toHaveBeenCalledExactlyOnceWith(SseEvent.TabLayoutChanged, { owner: 'alice', version: 8 });
|
|
expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({
|
|
changedOwnerOrders: { alice: ['b', 'a'] },
|
|
globalOrder: ['b', 'a'],
|
|
globalChanged: true,
|
|
});
|
|
});
|
|
|
|
it('preserves an unmapped global slot while a legacy PUT reorders authoritative owner sessions', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'unknown' },
|
|
{ kind: 'session', id: 'a' },
|
|
{ kind: 'session', id: 'b' },
|
|
]),
|
|
},
|
|
order: ['unknown', 'a', 'b'],
|
|
live: [
|
|
{ id: 'a', owner: 'alice', createdAt: 1 },
|
|
{ id: 'b', owner: 'alice', createdAt: 2 },
|
|
],
|
|
});
|
|
|
|
const result = await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['b', 'a']);
|
|
|
|
expect(h.layouts.alice.ungrouped).toEqual([
|
|
{ kind: 'session', id: 'unknown' },
|
|
{ kind: 'session', id: 'b' },
|
|
{ kind: 'session', id: 'a' },
|
|
]);
|
|
expect(result.globalOrder).toEqual(['unknown', 'b', 'a']);
|
|
expect(h.order).toEqual(['unknown', 'b', 'a']);
|
|
expect(result.order).toEqual(['b', 'a']);
|
|
});
|
|
|
|
it('preserves an unmapped global slot during a GET order repair', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'unknown' },
|
|
{ kind: 'session', id: 'b' },
|
|
{ kind: 'session', id: 'a' },
|
|
]),
|
|
},
|
|
order: ['unknown', 'a', 'b'],
|
|
live: [
|
|
{ id: 'a', owner: 'alice', createdAt: 1 },
|
|
{ id: 'b', owner: 'alice', createdAt: 2 },
|
|
],
|
|
});
|
|
|
|
await h.service.get('alice');
|
|
|
|
expect(h.order).toEqual(['unknown', 'b', 'a']);
|
|
expect(h.broadcastSessionOrder).toHaveBeenCalledWith({
|
|
changedOwnerOrders: { alice: ['b', 'a'] },
|
|
globalOrder: ['unknown', 'b', 'a'],
|
|
globalChanged: true,
|
|
});
|
|
});
|
|
|
|
it('removes an unmapped global id only when a trusted deletion explicitly excludes it', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'unknown' },
|
|
{ kind: 'session', id: 'a' },
|
|
]),
|
|
},
|
|
order: ['unknown', 'a'],
|
|
live: [{ id: 'a', owner: 'alice', createdAt: 1 }],
|
|
});
|
|
h.service.markRestorationComplete();
|
|
|
|
await h.service.runSessionDeletion([{ id: 'unknown', owner: 'alice' }], async () => undefined);
|
|
|
|
expect(h.layouts.alice.ungrouped).toEqual([{ kind: 'session', id: 'a' }]);
|
|
expect(h.order).toEqual(['a']);
|
|
});
|
|
|
|
it('reports an owner layout-order change even when the legacy global slice already matches', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'a' },
|
|
{ kind: 'session', id: 'b' },
|
|
]),
|
|
},
|
|
order: ['b', 'a'],
|
|
live: [
|
|
{ id: 'a', owner: 'alice', createdAt: 1 },
|
|
{ id: 'b', owner: 'alice', createdAt: 2 },
|
|
],
|
|
});
|
|
|
|
const result = await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['b', 'a']);
|
|
|
|
expect(result.changedOwnerOrders).toEqual({ alice: ['b', 'a'] });
|
|
expect(result.globalChanged).toBe(false);
|
|
expect(h.broadcastSessionOrder).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('reports an authoritative owner-order correction when a placement-only PUT repairs stale global order', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'a' },
|
|
{ kind: 'session', id: 'b' },
|
|
]),
|
|
},
|
|
order: ['b', 'a'],
|
|
live: [
|
|
{ id: 'a', owner: 'alice', createdAt: 1 },
|
|
{ id: 'b', owner: 'alice', createdAt: 2 },
|
|
],
|
|
});
|
|
const desired = {
|
|
...existing([], 7),
|
|
groups: [
|
|
{
|
|
id: 'g',
|
|
name: 'Same order',
|
|
refs: [
|
|
{ kind: 'session' as const, id: 'a' },
|
|
{ kind: 'session' as const, id: 'b' },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = await h.service.put('alice', desired, 7);
|
|
|
|
expect(result.status).toBe('updated');
|
|
expect(h.order).toEqual(['a', 'b']);
|
|
expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({
|
|
changedOwnerOrders: { alice: ['a', 'b'] },
|
|
globalOrder: ['a', 'b'],
|
|
globalChanged: true,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['foreign owner', ['b', 'a']],
|
|
['unknown stored ref', ['unknown', 'a']],
|
|
])('drops a %s from a regular legacy request instead of rejecting the write', async (_label, requested) => {
|
|
const original = existing([
|
|
{ kind: 'session', id: 'a' },
|
|
{ kind: 'session', id: 'unknown' },
|
|
]);
|
|
const h = createHarness({
|
|
layouts: { alice: original },
|
|
order: ['a', 'b', 'unknown'],
|
|
live: [
|
|
{ id: 'a', owner: 'alice', createdAt: 1 },
|
|
{ id: 'b', owner: 'bob', createdAt: 2 },
|
|
],
|
|
});
|
|
|
|
// Unknown/foreign ids are dropped, never a 400: the browser's reorder push
|
|
// is debounced and swallows errors, so a rejection would silently lose the
|
|
// user's whole reorder when a session dies inside the debounce window.
|
|
const result = await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, requested);
|
|
|
|
expect(result.order).toEqual(['a']);
|
|
expect(h.layouts.alice).toEqual(original);
|
|
expect(h.order).toEqual(['a', 'b', 'unknown']);
|
|
expect(h.broadcast).not.toHaveBeenCalled();
|
|
expect(h.broadcastSessionOrder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('applies the surviving reorder when the request contains a deleted id (owner path)', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'a1' },
|
|
{ kind: 'session', id: 'a2' },
|
|
]),
|
|
},
|
|
order: ['a1', 'a2'],
|
|
live: [
|
|
{ id: 'a1', owner: 'alice', createdAt: 1 },
|
|
{ id: 'a2', owner: 'alice', createdAt: 2 },
|
|
],
|
|
});
|
|
|
|
const result = await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['a2', 'ghost', 'a1']);
|
|
|
|
expect(result.order).toEqual(['a2', 'a1']);
|
|
expect(h.order).toEqual(['a2', 'a1']);
|
|
});
|
|
|
|
it('applies the surviving reorder when the request contains a deleted id (admin path)', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'a1' },
|
|
{ kind: 'session', id: 'a2' },
|
|
]),
|
|
},
|
|
order: ['a1', 'a2'],
|
|
live: [
|
|
{ id: 'a1', owner: 'alice', createdAt: 1 },
|
|
{ id: 'a2', owner: 'alice', createdAt: 2 },
|
|
],
|
|
});
|
|
|
|
const result = await h.service.putLegacyOrder({ owner: 'root', isAdmin: true }, ['a2', 'ghost', 'a1']);
|
|
|
|
expect(result.order).toEqual(['a2', 'a1']);
|
|
expect(h.order).toEqual(['a2', 'a1']);
|
|
});
|
|
|
|
it('keeps containers and anchored slots fixed, materializes ranked children, and merges missing known sessions', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: {
|
|
...existing([
|
|
{ kind: 'session', id: 'parent' },
|
|
{ kind: 'session', id: 'child' },
|
|
{ kind: 'session', id: 'c' },
|
|
]),
|
|
groups: [
|
|
{
|
|
id: 'g',
|
|
name: 'Work',
|
|
refs: [
|
|
{ kind: 'session', id: 'a' },
|
|
{ kind: 'webview', id: 'w' },
|
|
{ kind: 'session', id: 'unknown' },
|
|
{ kind: 'session', id: 'b' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
order: ['a', 'b', 'parent', 'child', 'c'],
|
|
live: [
|
|
{ id: 'a', owner: 'alice', createdAt: 1 },
|
|
{ id: 'b', owner: 'alice', createdAt: 2 },
|
|
{ id: 'parent', owner: 'alice', createdAt: 3 },
|
|
{ id: 'child', owner: 'alice', createdAt: 4, parentSessionId: 'parent' },
|
|
{ id: 'c', owner: 'alice', createdAt: 5 },
|
|
],
|
|
webviews: [{ id: 'w', owner: 'alice' }],
|
|
});
|
|
|
|
const result = await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['b', 'a', 'child', 'parent']);
|
|
|
|
expect(result.order).toEqual(['b', 'a', 'child', 'parent', 'c']);
|
|
expect(h.layouts.alice.groups).toEqual([
|
|
{
|
|
id: 'g',
|
|
name: 'Work',
|
|
refs: [
|
|
{ kind: 'session', id: 'b' },
|
|
{ kind: 'webview', id: 'w' },
|
|
{ kind: 'session', id: 'unknown' },
|
|
{ kind: 'session', id: 'a' },
|
|
],
|
|
},
|
|
]);
|
|
expect(h.layouts.alice.ungrouped).toEqual([
|
|
{ kind: 'session', id: 'child', placement: 'manual' },
|
|
{ kind: 'session', id: 'parent' },
|
|
{ kind: 'session', id: 'c' },
|
|
]);
|
|
expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({
|
|
changedOwnerOrders: { alice: ['b', 'a', 'child', 'parent', 'c'] },
|
|
globalOrder: ['b', 'a', 'child', 'parent', 'c'],
|
|
globalChanged: true,
|
|
});
|
|
expect(await h.service.get('alice')).toEqual(h.layouts.alice);
|
|
});
|
|
|
|
it('does not claim or disturb a known foreign session found in a stale owner layout', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'a1' },
|
|
{ kind: 'session', id: 'b1' },
|
|
{ kind: 'session', id: 'a2' },
|
|
]),
|
|
bob: existing([
|
|
{ kind: 'session', id: 'b1' },
|
|
{ kind: 'session', id: 'b2' },
|
|
]),
|
|
},
|
|
order: ['a1', 'b1', 'a2', 'b2'],
|
|
live: [
|
|
{ id: 'a1', owner: 'alice', createdAt: 1 },
|
|
{ id: 'b1', owner: 'bob', createdAt: 2 },
|
|
{ id: 'a2', owner: 'alice', createdAt: 3 },
|
|
{ id: 'b2', owner: 'bob', createdAt: 4 },
|
|
],
|
|
});
|
|
|
|
const result = await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['a2', 'a1']);
|
|
|
|
expect(result).toMatchObject({
|
|
order: ['a2', 'a1'],
|
|
changedOwnerOrders: { alice: ['a2', 'a1'] },
|
|
globalOrder: ['a2', 'b1', 'a1', 'b2'],
|
|
globalChanged: true,
|
|
});
|
|
expect(h.layouts.alice.ungrouped.map((ref) => ref.id)).toEqual(['a2', 'a1']);
|
|
expect(h.layouts.bob.ungrouped.map((ref) => ref.id)).toEqual(['b1', 'b2']);
|
|
expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({
|
|
changedOwnerOrders: { alice: ['a2', 'a1'] },
|
|
globalOrder: ['a2', 'b1', 'a1', 'b2'],
|
|
globalChanged: true,
|
|
});
|
|
});
|
|
|
|
it('publishes layout and order together, suppresses duplicate order events, and rolls back store rejection', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'a' },
|
|
{ kind: 'session', id: 'b' },
|
|
]),
|
|
},
|
|
order: ['a', 'b'],
|
|
live: [
|
|
{ id: 'a', owner: 'alice', createdAt: 1 },
|
|
{ id: 'b', owner: 'alice', createdAt: 2 },
|
|
],
|
|
});
|
|
const desired = {
|
|
...existing([], 7),
|
|
groups: [
|
|
{
|
|
id: 'g',
|
|
name: 'Grouped',
|
|
refs: [
|
|
{ kind: 'session' as const, id: 'a' },
|
|
{ kind: 'session' as const, id: 'b' },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
await h.service.put('alice', desired, 7);
|
|
expect(h.order).toEqual(['a', 'b']);
|
|
expect(h.broadcast).toHaveBeenCalledTimes(1);
|
|
expect(h.broadcastSessionOrder).not.toHaveBeenCalled();
|
|
await h.service.get('alice');
|
|
expect(h.broadcastSessionOrder).not.toHaveBeenCalled();
|
|
|
|
const beforeLayout = structuredClone(h.layouts.alice);
|
|
const beforeOrder = [...h.order];
|
|
h.store.commitTabLayoutProjection.mockImplementationOnce(() => {
|
|
throw new Error('publication failed');
|
|
});
|
|
h.broadcast.mockClear();
|
|
await expect(h.service.put('alice', { ...desired, groups: [] }, 8)).rejects.toThrow('publication failed');
|
|
expect(h.layouts.alice).toEqual(beforeLayout);
|
|
expect(h.order).toEqual(beforeOrder);
|
|
expect(h.broadcast).not.toHaveBeenCalled();
|
|
expect(h.broadcastSessionOrder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses latest-state synchronous publication for overlapping owner preparations without a lost update', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'a1' },
|
|
{ kind: 'session', id: 'a2' },
|
|
]),
|
|
bob: existing([
|
|
{ kind: 'session', id: 'b1' },
|
|
{ kind: 'session', id: 'b2' },
|
|
]),
|
|
},
|
|
order: ['a1', 'b1', 'a2', 'b2'],
|
|
live: [
|
|
{ id: 'a1', owner: 'alice', createdAt: 1 },
|
|
{ id: 'b1', owner: 'bob', createdAt: 2 },
|
|
{ id: 'a2', owner: 'alice', createdAt: 3 },
|
|
{ id: 'b2', owner: 'bob', createdAt: 4 },
|
|
],
|
|
});
|
|
|
|
await Promise.all([
|
|
h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['a2', 'a1']),
|
|
h.service.putLegacyOrder({ owner: 'bob', isAdmin: false }, ['b2', 'b1']),
|
|
]);
|
|
|
|
expect(h.order).toEqual(['a2', 'b2', 'a1', 'b1']);
|
|
expect(h.layouts.alice.ungrouped.map((ref) => ref.id)).toEqual(['a2', 'a1']);
|
|
expect(h.layouts.bob.ungrouped.map((ref) => ref.id)).toEqual(['b2', 'b1']);
|
|
});
|
|
|
|
it('admin publishes all changed owner ranks atomically and can change only cross-owner interleaving', async () => {
|
|
const makeHarness = () =>
|
|
createHarness({
|
|
layouts: {
|
|
alice: existing([
|
|
{ kind: 'session', id: 'a1' },
|
|
{ kind: 'session', id: 'a2' },
|
|
]),
|
|
bob: existing([
|
|
{ kind: 'session', id: 'b1' },
|
|
{ kind: 'session', id: 'b2' },
|
|
]),
|
|
},
|
|
order: ['a1', 'b1', 'a2', 'b2'],
|
|
live: [
|
|
{ id: 'a1', owner: 'alice', createdAt: 1 },
|
|
{ id: 'b1', owner: 'bob', createdAt: 2 },
|
|
{ id: 'a2', owner: 'alice', createdAt: 3 },
|
|
{ id: 'b2', owner: 'bob', createdAt: 4 },
|
|
],
|
|
});
|
|
const ranked = makeHarness();
|
|
|
|
await expect(
|
|
ranked.service.putLegacyOrder({ owner: 'admin', isAdmin: true }, ['a2', 'b2', 'a1', 'b1'])
|
|
).resolves.toEqual({
|
|
order: ['a2', 'b2', 'a1', 'b1'],
|
|
changedOwnerOrders: { alice: ['a2', 'a1'], bob: ['b2', 'b1'] },
|
|
globalOrder: ['a2', 'b2', 'a1', 'b1'],
|
|
globalChanged: true,
|
|
});
|
|
expect(Object.keys(ranked.store.commitTabLayoutProjection.mock.calls[0][0])).toEqual(['alice', 'bob']);
|
|
expect(ranked.layouts.alice.version).toBe(8);
|
|
expect(ranked.layouts.bob.version).toBe(8);
|
|
|
|
const interleaved = makeHarness();
|
|
await expect(
|
|
interleaved.service.putLegacyOrder({ owner: 'admin', isAdmin: true }, ['b1', 'a1', 'b2', 'a2'])
|
|
).resolves.toEqual({
|
|
order: ['b1', 'a1', 'b2', 'a2'],
|
|
changedOwnerOrders: {},
|
|
globalOrder: ['b1', 'a1', 'b2', 'a2'],
|
|
globalChanged: true,
|
|
});
|
|
expect(interleaved.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({});
|
|
expect(interleaved.layouts.alice.version).toBe(7);
|
|
expect(interleaved.layouts.bob.version).toBe(7);
|
|
});
|
|
|
|
it('admin preserves requested owner slots while canonical grouped owner order wins', async () => {
|
|
const h = createHarness({
|
|
layouts: {
|
|
alice: {
|
|
...existing([{ kind: 'session', id: 'a2' }]),
|
|
groups: [{ id: 'fixed', name: 'Fixed', refs: [{ kind: 'session', id: 'a1' }] }],
|
|
},
|
|
bob: existing([
|
|
{ kind: 'session', id: 'b1' },
|
|
{ kind: 'session', id: 'b2' },
|
|
]),
|
|
},
|
|
order: ['a1', 'b1', 'a2', 'b2'],
|
|
live: [
|
|
{ id: 'a1', owner: 'alice', createdAt: 1 },
|
|
{ id: 'b1', owner: 'bob', createdAt: 2 },
|
|
{ id: 'a2', owner: 'alice', createdAt: 3 },
|
|
{ id: 'b2', owner: 'bob', createdAt: 4 },
|
|
],
|
|
});
|
|
|
|
const result = await h.service.putLegacyOrder({ owner: 'admin', isAdmin: true }, ['b2', 'a2', 'b1', 'a1']);
|
|
|
|
expect(result.globalOrder).toEqual(['b2', 'a1', 'b1', 'a2']);
|
|
expect(result.order).toEqual(result.globalOrder);
|
|
expect(result.changedOwnerOrders).toEqual({ bob: ['b2', 'b1'] });
|
|
expect(h.layouts.alice.version).toBe(7);
|
|
expect(h.layouts.bob.version).toBe(8);
|
|
});
|
|
|
|
it('admin publishes prototype-like owner keys as own layout entries', async () => {
|
|
const owner = '__proto__';
|
|
const h = createHarness({
|
|
layouts: {
|
|
[owner]: existing([
|
|
{ kind: 'session', id: 'p1' },
|
|
{ kind: 'session', id: 'p2' },
|
|
]),
|
|
},
|
|
order: ['p1', 'p2'],
|
|
live: [
|
|
{ id: 'p1', owner, createdAt: 1 },
|
|
{ id: 'p2', owner, createdAt: 2 },
|
|
],
|
|
});
|
|
|
|
await h.service.putLegacyOrder({ owner: 'admin', isAdmin: true }, ['p2', 'p1']);
|
|
|
|
const updates = h.store.commitTabLayoutProjection.mock.calls[0][0];
|
|
expect(Object.hasOwn(updates, owner)).toBe(true);
|
|
expect(h.layouts[owner].version).toBe(8);
|
|
expect(h.layouts[owner].ungrouped.map((ref) => ref.id)).toEqual(['p2', 'p1']);
|
|
});
|
|
|
|
it('admin retries owner discovery when a new trusted owner appears while queues are pending', async () => {
|
|
const h = createHarness({
|
|
layouts: { alice: existing([{ kind: 'session', id: 'a1' }]) },
|
|
order: ['a1'],
|
|
live: [{ id: 'a1', owner: 'alice', createdAt: 1 }],
|
|
});
|
|
let release!: () => void;
|
|
const blocked = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
h.readWebviews.mockImplementationOnce(async () => {
|
|
await blocked;
|
|
return [];
|
|
});
|
|
const occupied = h.service.get('alice');
|
|
await vi.waitFor(() => expect(h.readWebviews).toHaveBeenCalledOnce());
|
|
|
|
const admin = h.service.putLegacyOrder({ owner: 'admin', isAdmin: true }, ['b1', 'a1']);
|
|
h.live.set('b1', { id: 'b1', owner: 'bob', createdAt: 2 } as never);
|
|
release();
|
|
|
|
await occupied;
|
|
await expect(admin).resolves.toMatchObject({ globalOrder: ['b1', 'a1'] });
|
|
expect(h.layouts.bob).toMatchObject({ version: 0, ungrouped: [{ kind: 'session', id: 'b1' }] });
|
|
expect(Object.hasOwn(h.store.commitTabLayoutProjection.mock.calls.at(-1)![0], 'bob')).toBe(true);
|
|
});
|
|
});
|