Merge pull request #676: feat(tabs): per-case default session group

Conflict in CLAUDE.md's State Files line resolved by keeping #661's new
config-backup entries and this PR's "case default groups" in state.json.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-22 19:54:54 +02:00
commit 463a083a23
27 files changed
+1437 -32

No files matched your search

+4 -1
View File
@@ -189,10 +189,13 @@ describe('admin case-folder API', () => {
expect(sl.status).toBe(400);
await expect(fs.stat(path.join(spacesDir, 'outside'))).resolves.toBeTruthy();
// A real folder is deleted.
// A real folder is deleted, and kim's default session group for it goes with it.
const store = (server as unknown as { store: import('../src/state-store.js').StateStore }).store;
store.setCaseDefaultGroup('kim', 'proj1', null);
const del = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody });
expect(del.status).toBe(200);
await expect(fs.stat(path.join(spacesDir, 'kim', 'cases', 'proj1'))).rejects.toBeTruthy();
expect(store.getCaseDefaultGroup('kim', 'proj1')).toBeUndefined();
// Deleting it again 404s.
const gone = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody });
+181
View File
@@ -0,0 +1,181 @@
/**
* @fileoverview The Case settings "Default session group" selector (session-ui.js):
* it lists Ungrouped plus every named group the server reports, saves through
* PUT /api/cases/:name/default-group, reverts on a refused save, mirrors the
* desktop and phone popovers, and refreshes its options when the tab layout
* changes (groups created, renamed or deleted). Also pins the zh-CN strings.
*
* Port: none (vm sandbox, stubbed fetch and DOM).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
function fakeSelect() {
const select: any = {
value: '',
disabled: false,
options: [] as any[],
replaceChildren(...nodes: any[]) {
select.options = nodes;
},
};
return select;
}
function harness(server: { groups: Array<{ id: string; name: string }>; groupId: string | null; putStatus?: number }) {
const elements: Record<string, any> = {
quickStartCase: { value: 'proj' },
caseDefaultGroup: fakeSelect(),
caseDefaultGroupMobile: fakeSelect(),
};
const requests: Array<{ url: string; method: string; body?: any }> = [];
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: {
getElementById: (id: string) => elements[id] ?? null,
createElement: () => {
const attrs: Record<string, string> = {};
return { value: '', textContent: '', attrs, setAttribute: (k: string, v: string) => (attrs[k] = v) };
},
},
fetch: async (url: string, init?: { method?: string; body?: string }) => {
const method = init?.method ?? 'GET';
const body = init?.body ? JSON.parse(init.body) : undefined;
requests.push({ url, method, body });
const ok = method !== 'PUT' || (server.putStatus ?? 200) === 200;
if (method === 'PUT' && ok) server.groupId = body.groupId;
const payload = ok
? { success: true, data: { caseName: 'proj', groupId: server.groupId, groups: server.groups } }
: { success: false, error: 'nope', errorCode: 'INVALID_INPUT' };
return { ok, status: ok ? 200 : (server.putStatus ?? 400), json: async () => payload };
},
console,
});
for (const file of ['api-client.js', 'session-ui.js']) {
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
}
const app = new (CodemanApp as any)();
app.showToast = vi.fn();
return { app, elements, requests, server };
}
const optionView = (select: any) => select.options.map((o: any) => [o.value, o.textContent]);
describe('Case settings: Default session group selector', () => {
it('lists Ungrouped plus every named group, selecting the stored one in both popovers', async () => {
const h = harness({
groups: [
{ id: 'g1', name: 'Core' },
{ id: 'g2', name: 'Ops' },
],
groupId: 'g2',
});
await h.app._loadCaseDefaultGroup();
expect(h.requests[0]).toMatchObject({ url: '/api/cases/proj/default-group', method: 'GET' });
for (const id of ['caseDefaultGroup', 'caseDefaultGroupMobile']) {
const select = h.elements[id];
expect(optionView(select)).toEqual([
['', 'Ungrouped'],
['g1', 'Core'],
['g2', 'Ops'],
]);
expect(select.value).toBe('g2');
expect(select.disabled).toBe(false);
// Group names are user text: never handed to the translator.
expect(select.options[1].attrs['data-i18n-skip']).toBe('');
expect(select.options[0].attrs['data-i18n-skip']).toBeUndefined();
}
});
it('saves a choice (and Ungrouped as null) and mirrors it to the other popover', async () => {
const h = harness({ groups: [{ id: 'g1', name: 'Core' }], groupId: null });
await h.app._loadCaseDefaultGroup();
h.elements.caseDefaultGroup.value = 'g1';
await h.app.onCaseDefaultGroupChanged(h.elements.caseDefaultGroup);
expect(h.requests.at(-1)).toEqual({ url: '/api/cases/proj/default-group', method: 'PUT', body: { groupId: 'g1' } });
expect(h.elements.caseDefaultGroupMobile.value).toBe('g1');
h.elements.caseDefaultGroupMobile.value = '';
await h.app.onCaseDefaultGroupChanged(h.elements.caseDefaultGroupMobile);
expect(h.requests.at(-1)?.body).toEqual({ groupId: null });
expect(h.elements.caseDefaultGroup.value).toBe('');
});
it('reverts to the confirmed value and says so when the save is refused', async () => {
const h = harness({ groups: [{ id: 'g1', name: 'Core' }], groupId: 'g1', putStatus: 400 });
await h.app._loadCaseDefaultGroup();
h.elements.caseDefaultGroup.value = '';
await h.app.onCaseDefaultGroupChanged(h.elements.caseDefaultGroup);
expect(h.elements.caseDefaultGroup.value).toBe('g1');
expect(h.app.showToast).toHaveBeenCalledWith('Could not save the default session group.', 'error');
});
it('refreshes its options when the layout changes: created, renamed and deleted groups', async () => {
const h = harness({ groups: [{ id: 'g1', name: 'Core' }], groupId: 'g1' });
await h.app._loadCaseDefaultGroup();
h.app._refreshCaseDefaultGroupOptions({
groups: [
{ id: 'g1', name: 'Core team' },
{ id: 'g3', name: 'New' },
],
});
expect(optionView(h.elements.caseDefaultGroup)).toEqual([
['', 'Ungrouped'],
['g1', 'Core team'],
['g3', 'New'],
]);
expect(h.elements.caseDefaultGroup.value).toBe('g1');
// The selected group is deleted: the selector visibly falls back to Ungrouped.
h.app._refreshCaseDefaultGroupOptions({ groups: [{ id: 'g3', name: 'New' }] });
expect(h.elements.caseDefaultGroup.value).toBe('');
expect(h.elements.caseDefaultGroupMobile.value).toBe('');
});
it('leaves the options alone when a layout broadcast does not change the groups', async () => {
const h = harness({
groups: [
{ id: 'g1', name: 'Core' },
{ id: 'g2', name: 'Ops' },
],
groupId: 'g2',
});
await h.app._loadCaseDefaultGroup();
const before = h.elements.caseDefaultGroup.options;
// A session created or moved elsewhere: same groups, other refs.
h.app._refreshCaseDefaultGroupOptions({
groups: [
{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 's9' }] },
{ id: 'g2', name: 'Ops', refs: [] },
],
});
expect(h.elements.caseDefaultGroup.options).toBe(before); // not rebuilt, so an open dropdown stays open
expect(h.elements.caseDefaultGroup.value).toBe('g2');
});
it('the layout refresh is wired into _applyTabLayout', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const body = app.slice(app.indexOf(' _applyTabLayout(layout) {'));
expect(body.slice(0, body.indexOf('\n }\n'))).toMatch(/^\s*this\._refreshCaseDefaultGroupOptions\?\.\(next\);/m);
});
it('both popovers carry the selector, and every new string has a zh-CN entry', () => {
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
expect(html).toContain('id="caseDefaultGroup"');
expect(html).toContain('id="caseDefaultGroupMobile"');
const i18n = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
for (const text of [
'Default session group',
'New sessions for this case land in this group',
'Could not save the default session group.',
'Ungrouped',
]) {
expect(i18n, text).toMatch(new RegExp(`['"]?${text.replace(/[.]/g, '\\.')}['"]?: '`));
}
});
});
+334
View File
@@ -0,0 +1,334 @@
/**
* @fileoverview Per-case default session group: the state.json store and the
* TabLayoutService placement precedence (explicit group > parent > case default > none).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { StateStore } from '../src/state-store.js';
import { TabLayoutService } from '../src/tab-layout-service.js';
import { TabLayoutValidationError, type TabLayout } from '../src/tab-layout.js';
type SessionFact = { id: string; owner?: string; createdAt: number; parentSessionId?: string };
describe('StateStore case default groups', () => {
let dir: string;
let file: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeman-case-default-'));
file = join(dir, 'state.json');
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
it('distinguishes unset from an intentional Ungrouped choice, per owner', () => {
const store = new StateStore(file);
expect(store.getCaseDefaultGroup('@single', 'app')).toBeUndefined();
store.setCaseDefaultGroup('@single', 'app', 'g1');
store.setCaseDefaultGroup('@single', 'other', null);
store.setCaseDefaultGroup('bob', 'app', 'g9');
expect(store.getCaseDefaultGroup('@single', 'app')).toBe('g1');
expect(store.getCaseDefaultGroup('@single', 'other')).toBeNull();
expect(store.getCaseDefaultGroup('bob', 'app')).toBe('g9');
expect(store.getCaseDefaultGroup('alice', 'app')).toBeUndefined();
});
it('survives a save and reload (persistence round-trip)', () => {
const store = new StateStore(file);
store.setCaseDefaultGroup('@single', 'app', 'g1');
store.setCaseDefaultGroup('@single', 'none', null);
store.saveNow();
const disk = JSON.parse(readFileSync(file, 'utf-8'));
expect(disk.caseDefaultGroups).toEqual({ '@single': { app: 'g1', none: null } });
const reloaded = new StateStore(file);
expect(reloaded.getCaseDefaultGroup('@single', 'app')).toBe('g1');
expect(reloaded.getCaseDefaultGroup('@single', 'none')).toBeNull();
});
it('forgets one owner mapping without touching another owner', () => {
const store = new StateStore(file);
store.setCaseDefaultGroup('alice', 'app', 'g1');
store.setCaseDefaultGroup('bob', 'app', 'g2');
store.deleteCaseDefaultGroup('alice', 'app');
expect(store.getCaseDefaultGroup('alice', 'app')).toBeUndefined();
expect(store.getCaseDefaultGroup('bob', 'app')).toBe('g2');
});
it('reads a malformed on-disk value as unset instead of throwing', () => {
writeFileSync(
file,
JSON.stringify({ sessions: {}, caseDefaultGroups: { '@single': { app: 42, ok: 'g1' }, bad: 'x' } })
);
const store = new StateStore(file);
expect(store.getCaseDefaultGroup('@single', 'app')).toBeUndefined();
expect(store.getCaseDefaultGroup('@single', 'ok')).toBe('g1');
expect(store.getCaseDefaultGroup('bad', 'app')).toBeUndefined();
expect(store.getCaseDefaultGroup('@single', '__proto__')).toBeUndefined();
});
});
function createHarness(options: { layouts?: Record<string, TabLayout>; live?: SessionFact[]; follow?: boolean } = {}) {
const layouts = { ...(options.layouts ?? {}) };
const live = new Map((options.live ?? []).map((session) => [session.id, session]));
const order: string[] = [];
const defaults: Record<string, Record<string, string | null>> = {};
const caseDefaults = {
getCaseDefaultGroup: vi.fn((owner: string, caseName: string) =>
defaults[owner] && caseName in defaults[owner] ? defaults[owner][caseName] : undefined
),
setCaseDefaultGroup: vi.fn((owner: string, caseName: string, groupId: string | null) => {
defaults[owner] = { ...(defaults[owner] ?? {}), [caseName]: groupId };
}),
deleteCaseDefaultGroup: vi.fn((owner: string, caseName: string) => {
if (defaults[owner]) delete defaults[owner][caseName];
}),
};
const store = {
getTabLayout: vi.fn((owner: string) => layouts[owner] ?? null),
getTabLayouts: vi.fn(() => ({ ...layouts })),
getSessions: vi.fn(() => ({})),
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 service = new TabLayoutService({
store: store as never,
sessions: live as never,
readWebviews: vi.fn(async () => []),
broadcast: vi.fn(),
broadcastSessionOrder: vi.fn(),
childrenFollowParent: vi.fn(() => options.follow === true),
caseDefaults,
now: () => '2026-10-22T12:00:00.000Z',
});
const register = (fact: SessionFact) =>
vi.fn(() => {
live.set(fact.id, fact);
return () => live.delete(fact.id);
});
return { service, store, layouts, live, defaults, caseDefaults, register };
}
const grouped = (version = 7): TabLayout => ({
version,
groups: [
{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 'a' }] },
{ id: 'g2', name: 'Ops', refs: [] },
],
ungrouped: [{ kind: 'session', id: 'b' }],
updatedAt: '2026-10-21T00:00:00.000Z',
});
const facts = (owner = 'alice'): SessionFact[] => [
{ id: 'a', owner, createdAt: 1 },
{ id: 'b', owner, createdAt: 2 },
];
const idsIn = (layout: TabLayout, groupId: string | null): string[] =>
(groupId === null ? layout.ungrouped : (layout.groups.find((g) => g.id === groupId)?.refs ?? [])).map((r) => r.id);
describe('TabLayoutService case default placement', () => {
it('places a session for the case into its default group in the creating write', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'new',
{ caseName: 'app' },
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
);
expect(idsIn(layout, 'g2')).toEqual(['new']);
expect(idsIn(layout, null)).toEqual(['b']);
expect(layout.version).toBe(8);
// One versioned write: no transient Ungrouped layout was ever committed.
expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1);
});
it('an explicit tabGroupId wins over the case default', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'new',
{ caseName: 'app', tabGroupId: 'g1' },
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
);
expect(idsIn(layout, 'g1')).toEqual(['a', 'new']);
expect(idsIn(layout, 'g2')).toEqual([]);
});
it('an unknown explicit group falls through to the case default', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'new',
{ caseName: 'app', tabGroupId: 'gone' },
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
);
expect(idsIn(layout, 'g2')).toEqual(['new']);
});
it.each([true, false])('the parent placement wins over the case default (follow=%s)', async (follow) => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts(), follow });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'child',
{ caseName: 'app' },
h.register({ id: 'child', owner: 'alice', createdAt: 3, parentSessionId: 'a' })
);
expect(idsIn(layout, 'g1')).toEqual(['a', 'child']);
expect(idsIn(layout, 'g2')).toEqual([]);
});
it('a parent that is not in the layout does not block the case default', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'orphan',
{ caseName: 'app' },
h.register({ id: 'orphan', owner: 'alice', createdAt: 3, parentSessionId: 'nobody' })
);
expect(idsIn(layout, 'g2')).toEqual(['orphan']);
});
it('an intentional Ungrouped default (null) or no default leaves the session ungrouped', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: null };
const first = await h.service.sessionCreated(
'alice',
'n1',
{ caseName: 'app' },
h.register({ id: 'n1', owner: 'alice', createdAt: 3 })
);
expect(idsIn(first, null)).toEqual(['b', 'n1']);
const second = await h.service.sessionCreated(
'alice',
'n2',
{ caseName: 'unset' },
h.register({ id: 'n2', owner: 'alice', createdAt: 4 })
);
expect(idsIn(second, null)).toEqual(['b', 'n1', 'n2']);
});
it('a deleted default group never fails creation: Ungrouped, and the stale value is cleared', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'deleted-group' };
const layout = await h.service.sessionCreated(
'alice',
'new',
{ caseName: 'app' },
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
);
expect(idsIn(layout, null)).toEqual(['b', 'new']);
expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBeUndefined();
});
it("never applies another owner's group id", async () => {
const h = createHarness({
layouts: { alice: grouped(), bob: { ...grouped(3), groups: [], ungrouped: [] } },
live: [...facts()],
});
// A value naming alice's group, stored under bob, is not found in bob's layout.
h.defaults.bob = { app: 'g1' };
const layout = await h.service.sessionCreated(
'bob',
'bob-new',
{ caseName: 'app' },
h.register({ id: 'bob-new', owner: 'bob', createdAt: 5 })
);
expect(layout.groups).toEqual([]);
expect(idsIn(layout, null)).toEqual(['bob-new']);
expect(idsIn(h.layouts.alice, 'g1')).toEqual(['a']);
});
it('a renamed group keeps receiving sessions (the default is the stable id)', async () => {
const renamed = grouped();
renamed.groups[1] = { ...renamed.groups[1], name: 'Operations' };
const h = createHarness({ layouts: { alice: renamed }, live: facts() });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'new',
{ caseName: 'app' },
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
);
expect(layout.groups[1]).toMatchObject({ id: 'g2', name: 'Operations' });
expect(idsIn(layout, 'g2')).toEqual(['new']);
});
it('changing the default never moves an existing session', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g2' };
await h.service.sessionCreated(
'alice',
's1',
{ caseName: 'app' },
h.register({ id: 's1', owner: 'alice', createdAt: 3 })
);
await h.service.setCaseDefaultGroup('alice', 'app', 'g1');
const layout = await h.service.sessionCreated(
'alice',
's2',
{ caseName: 'app' },
h.register({ id: 's2', owner: 'alice', createdAt: 4 })
);
expect(idsIn(layout, 'g2')).toEqual(['s1']);
expect(idsIn(layout, 'g1')).toEqual(['a', 's2']);
});
it('a failed layout write rolls the registration back and writes no case default change', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'deleted-group' };
h.store.commitTabLayoutProjection.mockImplementationOnce(() => {
throw new Error('disk full');
});
const added = h.register({ id: 'new', owner: 'alice', createdAt: 3 });
await expect(h.service.sessionCreated('alice', 'new', { caseName: 'app' }, added)).rejects.toThrow('disk full');
expect(h.live.has('new')).toBe(false);
expect(h.layouts.alice).toEqual(grouped());
expect(h.caseDefaults.setCaseDefaultGroup).not.toHaveBeenCalled();
expect(h.caseDefaults.deleteCaseDefaultGroup).not.toHaveBeenCalled();
});
});
describe('TabLayoutService case default reads and writes', () => {
it('reports the selection with the owner group options, and a stale id as Ungrouped without writing', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
expect(await h.service.caseDefaultGroup('alice', 'app')).toEqual({
groupId: null,
groups: [
{ id: 'g1', name: 'Core' },
{ id: 'g2', name: 'Ops' },
],
});
h.defaults.alice = { app: 'gone' };
expect((await h.service.caseDefaultGroup('alice', 'app')).groupId).toBeNull();
// A read never writes: the next creation for the case is what forgets the stale id.
expect(h.caseDefaults.deleteCaseDefaultGroup).not.toHaveBeenCalled();
expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBe('gone');
});
it('saves a known group or null, and refuses an id that is not in the owner layout', async () => {
const h = createHarness({ layouts: { alice: grouped(), bob: { ...grouped(1), groups: [] } }, live: facts() });
await h.service.setCaseDefaultGroup('alice', 'app', 'g2');
expect(h.defaults.alice.app).toBe('g2');
await h.service.setCaseDefaultGroup('alice', 'app', null);
expect(h.defaults.alice.app).toBeNull();
await expect(h.service.setCaseDefaultGroup('bob', 'app', 'g1')).rejects.toBeInstanceOf(TabLayoutValidationError);
expect(h.defaults.bob).toBeUndefined();
});
it('forgets a mapping when its case goes away', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g1' };
await h.service.forgetCaseDefaultGroup('alice', 'app');
expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBeUndefined();
});
});
+6
View File
@@ -56,6 +56,12 @@ export function createMockRouteContext(options?: {
runSessionDeletion: vi.fn(async (_removed, action) => action()),
runStaleSessionCleanup: vi.fn(async (_activeIds, action) => action(new Set())),
reconcileAfterRestoration: vi.fn(async () => {}),
caseDefaultGroup: vi.fn(async () => ({ groupId: null, groups: [] })),
setCaseDefaultGroup: vi.fn(async (_owner: string, _caseName: string, groupId: string | null) => ({
groupId,
groups: [],
})),
forgetCaseDefaultGroup: vi.fn(async () => {}),
},
cleanupSession: vi.fn(async () => {}),
setupSessionListeners: vi.fn(async () => {}),
@@ -0,0 +1,441 @@
/**
* @fileoverview Per-case default session group over HTTP: the case routes that
* read and write it, and the create routes that carry the case into the real
* TabLayoutService so a new session lands in its case's group in the creating
* write (POST /api/sessions, and POST /api/quick-start for local, remote and
* Docker cases).
*
* Uses app.inject(), so no real HTTP port is needed.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { Session } from '../../src/session.js';
import { TabLayoutService } from '../../src/tab-layout-service.js';
import { ownerLayoutKey } from '../../src/tab-layout-persistence.js';
import type { TabLayout } from '../../src/tab-layout.js';
import { CASES_DIR } from '../../src/web/route-helpers.js';
import { getDataDir } from '../../src/config/instance.js';
import { safeRmHomeTree } from '../mocks/index.js';
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
});
const CASE = 'cdgcase';
const DATA_FILES = ['docker-hosts.json', 'docker-cases.json', 'remote-hosts.json', 'remote-cases.json'];
interface World {
layouts: Record<string, TabLayout>;
order: string[];
defaults: Record<string, Record<string, string | null>>;
service: TabLayoutService;
}
/** The real layout service over an in-memory store, wired the way server.ts wires it. */
function attachRealLayout(harness: RouteTestHarness): World {
const world = { layouts: {}, order: [], defaults: {} } as unknown as World;
const caseDefaults = {
getCaseDefaultGroup: (owner: string, name: string) =>
world.defaults[owner] && name in world.defaults[owner] ? world.defaults[owner][name] : undefined,
setCaseDefaultGroup: (owner: string, name: string, id: string | null) => {
world.defaults[owner] = { ...(world.defaults[owner] ?? {}), [name]: id };
},
deleteCaseDefaultGroup: (owner: string, name: string) => {
if (world.defaults[owner]) delete world.defaults[owner][name];
},
};
world.service = new TabLayoutService({
store: {
getTabLayout: (owner: string) => world.layouts[owner] ?? null,
getTabLayouts: () => ({ ...world.layouts }),
getSessions: () => ({}),
getSessionOrder: () => [...world.order],
commitTabLayoutProjection: (
updates: Readonly<Record<string, TabLayout>>,
project: (latest: readonly string[]) => readonly string[]
) => {
world.order = [...project(world.order)];
Object.assign(world.layouts, structuredClone(updates));
return { layouts: structuredClone(updates), sessionOrder: [...world.order] };
},
} as never,
sessions: harness.ctx.sessions as never,
readWebviews: async () => [],
broadcast: () => {},
broadcastSessionOrder: () => {},
caseDefaults,
});
world.service.markRestorationSkipped();
(harness.ctx as unknown as { tabLayouts: TabLayoutService }).tabLayouts = world.service;
harness.ctx.addSession.mockImplementation((session: Session, placement?: object) =>
world.service.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => {
harness.ctx.sessions.set(session.id, session as never);
return () => harness.ctx.sessions.delete(session.id);
})
);
return world;
}
const OWNER = ownerLayoutKey(undefined);
const withGroups = (world: World) => {
world.layouts[OWNER] = {
version: 3,
updatedAt: '2026-10-21T00:00:00.000Z',
groups: [
{ id: 'g-core', name: 'Core', refs: [] },
{ id: 'g-ops', name: 'Ops', refs: [] },
],
ungrouped: [],
};
};
const groupOf = (layout: TabLayout, sessionId: string): string | null =>
layout.groups.find((g) => g.refs.some((r) => r.id === sessionId))?.id ?? null;
const unwrap = (raw: string) => {
const parsed = JSON.parse(raw);
return parsed.data ?? parsed;
};
describe('case default group: create routes', () => {
let harness: RouteTestHarness;
let world: World;
const caseDir = join(CASES_DIR, CASE);
const outside = join(homedir(), 'cdg-not-a-case');
beforeEach(async () => {
for (const name of DATA_FILES) await rm(join(getDataDir(), name), { force: true });
await mkdir(caseDir, { recursive: true });
await mkdir(outside, { recursive: true });
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
harness = await createRouteTestHarness(registerSessionRoutes);
harness.ctx.sessions.clear();
world = attachRealLayout(harness);
withGroups(world);
world.defaults[OWNER] = { [CASE]: 'g-ops' };
});
afterEach(async () => {
await harness.app.close();
for (const name of DATA_FILES) await rm(join(getDataDir(), name), { force: true });
safeRmHomeTree(CASES_DIR);
safeRmHomeTree(outside);
vi.restoreAllMocks();
});
it('POST /api/sessions in a case folder lands in the case default group and returns that layout', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-cdg', mode: 'shell', workingDir: caseDir },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(groupOf(world.layouts[OWNER], data.session.id)).toBe('g-ops');
// The response carries the committed layout so the browser draws the tab in place.
expect(data.tabLayout).toEqual(world.layouts[OWNER]);
});
it('POST /api/sessions in a Claude worktree of the case lands in the case default group too', async () => {
// What the Git worktrees modal sends for Claude; every other CLI goes through quick-start with the case name.
const worktree = join(caseDir, '.claude', 'worktrees', 'feat');
await mkdir(worktree, { recursive: true });
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-cdg-wt', mode: 'shell', workingDir: worktree },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(groupOf(world.layouts[OWNER], data.session.id)).toBe('g-ops');
expect(data.tabLayout).toEqual(world.layouts[OWNER]);
});
it('POST /api/sessions without a workingDir infers no case from the server cwd', async () => {
vi.spyOn(process, 'cwd').mockReturnValue(caseDir); // the server's own cwd happens to be a case folder
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-nowd', mode: 'shell' },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(data.session.workingDir).toBe(caseDir);
expect(Object.keys(data)).toEqual(['session']);
expect(groupOf(world.layouts[OWNER], data.session.id)).toBeNull();
});
it('POST /api/sessions outside any case gets no case default and keeps its response shape', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-out', mode: 'shell', workingDir: outside },
});
const data = unwrap(res.body);
expect(Object.keys(data)).toEqual(['session']);
expect(groupOf(world.layouts[OWNER], data.session.id)).toBeNull();
});
it('POST /api/sessions: an explicit tabGroupId still wins', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-cdg', mode: 'shell', workingDir: caseDir, tabGroupId: 'g-core' },
});
expect(groupOf(world.layouts[OWNER], unwrap(res.body).session.id)).toBe('g-core');
});
it('POST /api/quick-start for a local case lands in the case default group', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: CASE, mode: 'shell' },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(groupOf(world.layouts[OWNER], data.sessionId)).toBe('g-ops');
expect(data.tabLayout).toEqual(world.layouts[OWNER]);
});
it('POST /api/quick-start: explicit tabGroupId wins over the case default', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: CASE, mode: 'shell', tabGroupId: 'g-core' },
});
expect(groupOf(world.layouts[OWNER], unwrap(res.body).sessionId)).toBe('g-core');
});
it('POST /api/quick-start for a remote case lands in the case default group', async () => {
await writeFile(
join(getDataDir(), 'remote-hosts.json'),
JSON.stringify([{ id: 'remote1', label: 'Remote', host: '10.0.0.5', username: 'dev' }])
);
await writeFile(
join(getDataDir(), 'remote-cases.json'),
JSON.stringify([{ name: 'cdgremote', type: 'remote', hostId: 'remote1', remotePath: '/home/dev/work' }])
);
world.defaults[OWNER].cdgremote = 'g-core';
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'cdgremote', mode: 'claude' },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(data.sessionId, res.body).toBeTruthy();
expect(groupOf(world.layouts[OWNER], data.sessionId)).toBe('g-core');
});
it('POST /api/quick-start for a Docker case lands in the case default group', async () => {
await writeFile(
join(getDataDir(), 'docker-hosts.json'),
JSON.stringify([{ id: 'docker1', label: 'Docker', image: 'codeman/agent:base' }])
);
await writeFile(
join(getDataDir(), 'docker-cases.json'),
JSON.stringify([
{ name: 'cdgdocker', type: 'docker', hostId: 'docker1', hostWorkspacePath: caseDir, container: 'codeman-cdg' },
])
);
world.defaults[OWNER].cdgdocker = 'g-core';
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'cdgdocker', mode: 'claude' },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(data.sessionId, res.body).toBeTruthy();
expect(groupOf(world.layouts[OWNER], data.sessionId)).toBe('g-core');
});
it('a deleted default group never fails creation: the session lands ungrouped and the value is cleared', async () => {
world.defaults[OWNER][CASE] = 'g-deleted';
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: CASE, mode: 'shell' },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(groupOf(world.layouts[OWNER], data.sessionId)).toBeNull();
expect(data.tabLayout).toBeUndefined();
expect(CASE in world.defaults[OWNER]).toBe(false);
});
it('a creation that fails after registration leaves no layout reference', async () => {
vi.mocked(Session.prototype.startShell).mockRejectedValueOnce(new Error('pane failed'));
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: CASE, mode: 'shell' },
});
expect(unwrap(res.body).sessionId).toBeUndefined();
// The route's own error path ran cleanupSession for the registered id.
expect(harness.ctx.cleanupSession).toHaveBeenCalledTimes(1);
});
});
describe('case default group: GET/PUT /api/cases/:name/default-group', () => {
let harness: RouteTestHarness;
let world: World;
const caseDir = join(CASES_DIR, CASE);
beforeEach(async () => {
for (const name of DATA_FILES) await rm(join(getDataDir(), name), { force: true });
await mkdir(caseDir, { recursive: true });
harness = await createRouteTestHarness(registerCaseRoutes);
world = attachRealLayout(harness);
withGroups(world);
});
afterEach(async () => {
await harness.app.close();
delete process.env.CODEMAN_MULTIUSER;
for (const name of DATA_FILES) await rm(join(getDataDir(), name), { force: true });
safeRmHomeTree(CASES_DIR);
vi.restoreAllMocks();
});
const get = (name = CASE) => harness.app.inject({ method: 'GET', url: `/api/cases/${name}/default-group` });
const put = (groupId: unknown, name = CASE) =>
harness.app.inject({ method: 'PUT', url: `/api/cases/${name}/default-group`, payload: { groupId } });
it('lists Ungrouped (null) and every named group of the owner', async () => {
const res = await get();
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({
success: true,
data: {
caseName: CASE,
groupId: null,
groups: [
{ id: 'g-core', name: 'Core' },
{ id: 'g-ops', name: 'Ops' },
],
},
});
});
it('saves a group and reads it back; null is an intentional Ungrouped', async () => {
expect((await put('g-ops')).json().data.groupId).toBe('g-ops');
expect(world.defaults[OWNER][CASE]).toBe('g-ops');
expect((await get()).json().data.groupId).toBe('g-ops');
expect((await put(null)).json().data.groupId).toBeNull();
expect(world.defaults[OWNER][CASE]).toBeNull();
});
it('reads the current group names, so a rename shows through (the id is stored)', async () => {
await put('g-ops');
world.layouts[OWNER].groups[1].name = 'Operations';
const data = (await get()).json().data;
expect(data.groupId).toBe('g-ops');
expect(data.groups[1]).toEqual({ id: 'g-ops', name: 'Operations' });
});
it('a deleted group reads back as Ungrouped, and the read changes nothing', async () => {
world.defaults[OWNER] = { [CASE]: 'g-gone' };
expect((await get()).json().data.groupId).toBeNull();
// GET is read-only; the next session created for the case forgets the stale id.
expect(world.defaults[OWNER][CASE]).toBe('g-gone');
});
it.each([
['an unknown group', 'g-nope', 400],
['an empty string', '', 400],
['a number', 7, 400],
])('refuses %s without changing the stored value', async (_label, groupId, status) => {
await put('g-core');
const res = await put(groupId);
expect(res.statusCode).toBe(status);
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(world.defaults[OWNER][CASE]).toBe('g-core');
});
it('404s a case that does not exist (or is not the caller’s), for both verbs', async () => {
expect((await get('nosuchcase')).statusCode).toBe(404);
expect((await put('g-core', 'nosuchcase')).statusCode).toBe(404);
expect(world.defaults[OWNER]).toBeUndefined();
});
it('accepts a remote case and a Docker case by name', async () => {
await writeFile(
join(getDataDir(), 'remote-hosts.json'),
JSON.stringify([{ id: 'remote1', label: 'Remote', host: '10.0.0.5', username: 'dev' }])
);
await writeFile(
join(getDataDir(), 'remote-cases.json'),
JSON.stringify([{ name: 'cdgremote', type: 'remote', hostId: 'remote1', remotePath: '/home/dev/work' }])
);
await writeFile(
join(getDataDir(), 'docker-cases.json'),
JSON.stringify([{ name: 'cdgdocker', type: 'docker', hostId: 'docker1', hostWorkspacePath: caseDir }])
);
expect((await put('g-core', 'cdgremote')).statusCode).toBe(200);
expect((await put('g-ops', 'cdgdocker')).statusCode).toBe(200);
expect(world.defaults[OWNER]).toMatchObject({ cdgremote: 'g-core', cdgdocker: 'g-ops' });
});
it('DELETE /api/cases/:name forgets the mapping', async () => {
await put('g-ops');
const res = await harness.app.inject({ method: 'DELETE', url: `/api/cases/${CASE}` });
expect(res.statusCode).toBe(200);
expect(CASE in world.defaults[OWNER]).toBe(false);
});
});
describe('case default group: multi-user scoping', () => {
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
vi.restoreAllMocks();
});
it("stores a non-admin's choice under their own owner key and validates against their own groups", async () => {
process.env.CODEMAN_MULTIUSER = '1';
const harness = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
try {
harness.ctx.sessions.clear();
const world = attachRealLayout(harness);
world.layouts.alice = {
version: 1,
updatedAt: '2026-10-21T00:00:00.000Z',
groups: [{ id: 'g-alice', name: 'Alice', refs: [] }],
ungrouped: [],
};
world.layouts.bob = {
version: 1,
updatedAt: '2026-10-21T00:00:00.000Z',
groups: [{ id: 'g-bob', name: 'Bob', refs: [] }],
ungrouped: [],
};
const { resolveCasesDir } = await import('../../src/web/route-helpers.js');
const bobCases = resolveCasesDir({ username: 'bob', role: 'user' } as never);
await mkdir(join(bobCases, 'bobcase'), { recursive: true });
const read = await harness.app.inject({ method: 'GET', url: '/api/cases/bobcase/default-group' });
expect(read.json().data.groups).toEqual([{ id: 'g-bob', name: 'Bob' }]);
const foreign = await harness.app.inject({
method: 'PUT',
url: '/api/cases/bobcase/default-group',
payload: { groupId: 'g-alice' },
});
expect(foreign.statusCode).toBe(400);
const own = await harness.app.inject({
method: 'PUT',
url: '/api/cases/bobcase/default-group',
payload: { groupId: 'g-bob' },
});
expect(own.statusCode).toBe(200);
expect(world.defaults).toEqual({ bob: { bobcase: 'g-bob' } });
safeRmHomeTree(join(bobCases, '..'));
} finally {
await harness.app.close();
}
});
});
+2
View File
@@ -476,6 +476,8 @@ describe('ralph-routes', () => {
payload: { taskDescription: 'test task', completionPhrase: 'DONE', caseName: 'registration-order' },
});
await vi.waitFor(() => expect(harness.ctx.addSession).toHaveBeenCalledTimes(1));
// The case rides the registration, so the session lands in the case's default session group.
expect(harness.ctx.addSession).toHaveBeenCalledWith(expect.anything(), { caseName: 'registration-order' });
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
expect(harness.ctx.setupSessionListeners).not.toHaveBeenCalled();
+3 -1
View File
@@ -104,7 +104,9 @@ describe('create routes: tabGroupId', () => {
});
expect(res.statusCode).toBe(200);
const data = body(res.body);
expect(ctx.addSession.mock.calls[0][1]).toEqual({ tabGroupId: 'g1' });
// quick-start always names its case too, for the case's default group (the
// explicit group still wins inside TabLayoutService).
expect(ctx.addSession.mock.calls[0][1]).toEqual({ caseName: 'tabgroupcase', tabGroupId: 'g1' });
expect(data.tabLayout.groups[0].refs).toEqual([{ kind: 'session', id: data.sessionId }]);
const plain = await app.inject({