mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Opening another session in the case you are already working in meant re-picking the case and the mode in the toolbar. An opt-in + now sits with each tab's gear and close icons (App Settings, Tabs, New Session Button on Tabs: per device, off by default everywhere). It opens a menu of the CLIs that tab's case can start, each shown with its logo and name as in the Run menus, and a pick starts one session in that tab's case, or the managed worktree it runs in, and in its named group, without changing the Run button's mode or case. The CLIs offered follow the Run menu's own rule for the case, so a Docker case lists what its container has rather than what the host has. A tab outside any named group sends no group, so the case's default session group places the new session the way it places a toolbar Run. Wherever a tab has a row menu (the vertical rail, the sidebar) that menu gains "New session here…", the keyboard route to the same picker, since Enter on the + itself does nothing. On a phone the + joins gear and close on the active tab only, and the label reserve grows to 68px while the setting is on, so the tab's centre stays off the icons. The shared tab layout menu no longer closes when focus leaves an item for nowhere: Safari never focuses a clicked button, so pressing an item blurred the focused one with no related target and the click was lost. This is the tile count menu's rule, and it fixes the named-group menu as well.
552 lines
23 KiB
TypeScript
552 lines
23 KiB
TypeScript
/**
|
||
* @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<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;
|
||
};
|
||
|
||
/**
|
||
* 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<unknown>) {
|
||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||
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 = '<div id="sessionTabs"></div><select id="quickStartCase"></select>';
|
||
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<Record<string, unknown>> = [];
|
||
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();
|
||
}
|
||
});
|
||
});
|