/** * @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). The tab "+" picker (app.js) is driven end to end against the * same routes, since an ungrouped tab relies on this fallback by design. * * Uses app.inject(), so no real HTTP port is needed. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { readFileSync } from 'node:fs'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { JSDOM } from 'jsdom'; 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(); 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; order: string[]; defaults: Record>; 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>, 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; }; /** * The shipping frontend in JSDOM, with fetch answered by the route harnesses, so * a click in the tab "+" picker reaches the real create route and layout service. */ async function bootTabApp(route: (url: string, init?: { method?: string; body?: string }) => Promise) { const dom = new JSDOM('', { url: 'https://localhost/', runScripts: 'outside-only', }); if (dom.window.document.readyState !== 'complete') { await new Promise((resolve) => dom.window.addEventListener('load', resolve)); } // eslint-disable-next-line @typescript-eslint/no-explicit-any const win = dom.window as any; win.setInterval = () => 0; win.requestAnimationFrame = () => 0; win.__codemanCliCatalog = [{ id: 'shell', kind: 'shell', label: 'Shell', enabled: true }]; const publicDir = join(process.cwd(), 'src/web/public'); win.eval( 'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' + 'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' + ['constants.js', 'app.js', 'mobile-overview.js', 'session-ui.js'] .map((file) => readFileSync(join(publicDir, file), 'utf8')) .join('\n;\n') + '\n;window.__CdgCodemanApp = CodemanApp;' ); win.fetch = async (url: string, init?: { method?: string; body?: string }) => { const body = await route(url, init); return { ok: true, status: 200, json: async () => body }; }; win.document.body.innerHTML = '
'; const app = Object.create(win.__CdgCodemanApp.prototype); Object.assign(app, { $: (id: string) => win.document.getElementById(id), sessions: new Map(), activeSessionId: 'tab-1', terminal: { clear: () => {}, writeln: () => {}, focus: () => {}, cols: 80, rows: 24 }, _runMinLockMs: 0, _runMode: 'claude', getTerminalDimensions: () => null, selectSession: async () => {}, loadQuickStartCases: () => {}, showToast: vi.fn(), _applyTabLayout: () => {}, _onSessionCreated: () => {}, _renderSessionTabsImmediate: () => {}, }); return { app, win }; } 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('from a tab "+" in the browser', () => { /** Opens the tab's "+" picker, picks Shell, and returns the create request and the new session's group. */ async function launchBeside(tabGroupId: string | null) { const cases = await createRouteTestHarness(registerCaseRoutes); const creates: Array> = []; try { const { app, win } = await bootTabApp(async (url, init) => { const payload = init?.body ? JSON.parse(init.body) : undefined; if (url === '/api/sessions' && init?.method === 'POST') creates.push(payload); const target = url.startsWith('/api/cases/') ? cases : harness; const res = await target.app.inject({ method: (init?.method ?? 'GET') as 'GET', url, payload }); // The envelope server.ts's preSerialization hook adds, which the harness does not install. const body = res.body ? JSON.parse(res.body) : {}; return typeof body.success === 'boolean' ? body : { success: true, data: body }; }); app.cases = [{ name: CASE, path: caseDir }]; app.sessions.set('tab-1', { id: 'tab-1', name: 'w1-cdg', workingDir: caseDir }); app.tabLayout = { ...world.layouts[OWNER], groups: world.layouts[OWNER].groups.map((g) => ({ ...g, refs: g.id === tabGroupId ? [{ kind: 'session', id: 'tab-1' }] : [], })), }; app.collapsedTabGroupIds = new Set(); app.toggleTabGroupCollapsed = () => {}; const plus = win.document.createElement('span'); win.document.body.appendChild(plus); app.openTabNewSessionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: plus }, 'tab-1'); const shell = [...win.document.querySelectorAll('[aria-label="New session here"] button')].find( (button: Element) => button.textContent === 'Terminal / Shell' ) as HTMLElement; shell.click(); await vi.waitFor(() => expect(creates).toHaveLength(1)); await vi.waitFor(() => expect(harness.ctx.sessions.size).toBe(1)); const [id] = [...harness.ctx.sessions.keys()]; return { create: creates[0], group: groupOf(world.layouts[OWNER], id), toasts: app.showToast.mock.calls }; } finally { await cases.app.close(); } } it('an ungrouped tab sends no group, so the case default group places the session (chosen on purpose)', async () => { const { create, group, toasts } = await launchBeside(null); expect(create).toEqual({ workingDir: caseDir, mode: 'shell', name: `s1-${CASE}` }); expect(group).toBe('g-ops'); // The launch went through: no error reported along the way. expect(toasts.filter(([, kind]: [string, string]) => kind === 'error')).toEqual([]); }); it('a grouped tab sends its own group, which wins over the case default', async () => { const { create, group } = await launchBeside('g-core'); expect(create).toEqual({ workingDir: caseDir, mode: 'shell', name: `s1-${CASE}`, tabGroupId: 'g-core' }); expect(group).toBe('g-core'); }); }); }); 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(); } }); });