mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
commit
463a083a23
27 files changed
+1437
-32
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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, '\\.')}['"]?: '`));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user