mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Case settings gains a "Default session group" selector (Ungrouped plus every named tab group of the owner). A new session of the case lands in that group in the same versioned layout write that registers it, so it is drawn in place with no Ungrouped-to-group jump. - Stored per owner and case in state.json (caseDefaultGroups), as the group's stable id or null for Ungrouped; read and written only through TabLayoutService under the owner lock. - Precedence in sessionCreated: explicit tabGroupId, then the parent placement, then the case default, else Ungrouped. A deleted or foreign id never fails a spawn and is forgotten only after the creating write lands. - POST /api/quick-start (local, linked, remote, Docker) and /api/ralph-loop/start pass their case; POST /api/sessions infers it lexically from workingDir. The create response carries tabLayout when a case default placed the session. - GET/PUT /api/cases/:name/default-group; deleting a case (or an admin deleting a user's case folder) forgets the value. - The selector mirrors desktop and phone, reverts on a refused save, and is redrawn from every adopted tab layout; zh-CN strings added.
229 lines
8.9 KiB
TypeScript
229 lines
8.9 KiB
TypeScript
/**
|
|
* @fileoverview `tabGroupId` on the create routes: a session created from a
|
|
* group's "New session" action asks to be placed in that group.
|
|
*
|
|
* The routes only validate the field and hand it to `ctx.addSession()`; the
|
|
* placement itself (and the rule that an unknown or foreign group is ignored)
|
|
* lives in TabLayoutService, covered by test/tab-layout-service.test.ts. What is
|
|
* pinned here is the wire contract: the field is validated (a null or an empty
|
|
* string is a 400, never a silent drop), it reaches addSession with the new
|
|
* session, and the committed layout comes back on the response so the browser
|
|
* can draw the tab in its group without waiting for a re-read. Requests without
|
|
* the field keep their exact response shape.
|
|
*
|
|
* Uses app.inject(), so no real HTTP port is needed.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import Fastify, { type FastifyInstance } from 'fastify';
|
|
import fastifyCookie from '@fastify/cookie';
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
|
import { registerSessionRoutes } from '../../src/web/routes/session-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';
|
|
|
|
const LAYOUT = {
|
|
version: 4,
|
|
updatedAt: '2026-10-01T00:00:00.000Z',
|
|
groups: [{ id: 'g1', name: 'Core', refs: [] as Array<{ kind: 'session'; id: string }> }],
|
|
ungrouped: [] as Array<{ kind: 'session'; id: string }>,
|
|
};
|
|
|
|
describe('create routes: tabGroupId', () => {
|
|
let app: FastifyInstance;
|
|
let ctx: MockRouteContext;
|
|
let workingDir: string;
|
|
|
|
beforeEach(async () => {
|
|
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
|
|
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
|
workingDir = await mkdtemp(join(tmpdir(), 'codeman-tab-group-'));
|
|
app = Fastify({ logger: false });
|
|
await app.register(fastifyCookie);
|
|
ctx = createMockRouteContext();
|
|
ctx.addSession.mockImplementation(async (session: { id: string }, placement?: { tabGroupId?: string }) => {
|
|
ctx.sessions.set(session.id, session as never);
|
|
const refs = [{ kind: 'session' as const, id: session.id }];
|
|
return placement?.tabGroupId === 'g1'
|
|
? { ...LAYOUT, version: 5, groups: [{ ...LAYOUT.groups[0], refs }] }
|
|
: { ...LAYOUT, version: 5, ungrouped: refs };
|
|
});
|
|
registerSessionRoutes(app, ctx);
|
|
installRouteErrorHandler(app);
|
|
await app.ready();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await app.close();
|
|
await rm(workingDir, { recursive: true, force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const body = (raw: string) => {
|
|
const parsed = JSON.parse(raw);
|
|
return parsed.data ?? parsed;
|
|
};
|
|
|
|
it('POST /api/sessions hands the group to addSession and returns the committed layout', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions',
|
|
payload: { name: 'w1-x', mode: 'shell', workingDir, tabGroupId: 'g1' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const data = body(res.body);
|
|
expect(ctx.addSession).toHaveBeenCalledTimes(1);
|
|
expect(ctx.addSession.mock.calls[0][0].id).toBe(data.session.id);
|
|
expect(ctx.addSession.mock.calls[0][1]).toEqual({ tabGroupId: 'g1' });
|
|
expect(data.tabLayout.version).toBe(5);
|
|
expect(data.tabLayout.groups[0].refs).toEqual([{ kind: 'session', id: data.session.id }]);
|
|
});
|
|
|
|
it('POST /api/sessions without a group keeps its response shape', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions',
|
|
payload: { name: 'w1-x', mode: 'shell', workingDir },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(Object.keys(body(res.body))).toEqual(['session']);
|
|
expect(ctx.addSession.mock.calls[0][1]).toBeUndefined();
|
|
});
|
|
|
|
it('POST /api/quick-start carries the group the same way', async () => {
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/quick-start',
|
|
payload: { caseName: 'tabgroupcase', mode: 'shell', tabGroupId: 'g1' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const data = body(res.body);
|
|
// 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({
|
|
method: 'POST',
|
|
url: '/api/quick-start',
|
|
payload: { caseName: 'tabgroupcase', mode: 'shell' },
|
|
});
|
|
expect(body(plain.body).tabLayout).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
['null', null],
|
|
['an empty string', ''],
|
|
['a number', 7],
|
|
['an over-long id', 'g'.repeat(101)],
|
|
])('rejects %s as a tabGroupId with a 400 and creates nothing', async (_label, tabGroupId) => {
|
|
for (const [url, payload] of [
|
|
['/api/sessions', { name: 'w1-x', mode: 'shell', workingDir, tabGroupId }],
|
|
['/api/quick-start', { caseName: 'tabgroupcase', mode: 'shell', tabGroupId }],
|
|
] as const) {
|
|
const res = await app.inject({ method: 'POST', url, payload });
|
|
expect(res.statusCode).toBe(400);
|
|
expect(JSON.parse(res.body).errorCode).toBe('INVALID_INPUT');
|
|
}
|
|
expect(ctx.addSession).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('create routes: tabGroupId through the real layout service', () => {
|
|
let app: FastifyInstance;
|
|
let ctx: MockRouteContext;
|
|
let workingDir: string;
|
|
const layouts: Record<string, TabLayout> = {};
|
|
let order: string[] = [];
|
|
|
|
beforeEach(async () => {
|
|
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
|
workingDir = await mkdtemp(join(tmpdir(), 'codeman-tab-group-real-'));
|
|
app = Fastify({ logger: false });
|
|
await app.register(fastifyCookie);
|
|
ctx = createMockRouteContext();
|
|
ctx.sessions.clear(); // only the sessions these requests create
|
|
for (const key of Object.keys(layouts)) delete layouts[key];
|
|
order = [];
|
|
// The real service over an in-memory store, wired the way server.ts wires it.
|
|
const service = new TabLayoutService({
|
|
store: {
|
|
getTabLayout: (owner: string) => layouts[owner] ?? null,
|
|
getTabLayouts: () => ({ ...layouts }),
|
|
getSessions: () => ({}),
|
|
getSessionOrder: () => [...order],
|
|
commitTabLayoutProjection: (
|
|
updates: Readonly<Record<string, TabLayout>>,
|
|
project: (latest: readonly string[]) => readonly string[]
|
|
) => {
|
|
order = [...project(order)];
|
|
Object.assign(layouts, structuredClone(updates));
|
|
return { layouts: structuredClone(updates), sessionOrder: [...order] };
|
|
},
|
|
} as never,
|
|
sessions: ctx.sessions as never,
|
|
readWebviews: async () => [],
|
|
broadcast: () => {},
|
|
broadcastSessionOrder: () => {},
|
|
});
|
|
service.markRestorationSkipped();
|
|
ctx.addSession.mockImplementation((session: Session, placement?: { tabGroupId?: string }) =>
|
|
service.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => {
|
|
ctx.sessions.set(session.id, session as never);
|
|
return () => ctx.sessions.delete(session.id);
|
|
})
|
|
);
|
|
registerSessionRoutes(app, ctx);
|
|
installRouteErrorHandler(app);
|
|
await app.ready();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await app.close();
|
|
await rm(workingDir, { recursive: true, force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const unwrap = (raw: string) => {
|
|
const parsed = JSON.parse(raw);
|
|
return parsed.data ?? parsed;
|
|
};
|
|
|
|
it('places the created session at the end of an existing group and returns the stored layout', async () => {
|
|
const first = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions',
|
|
payload: { name: 'w1-x', mode: 'shell', workingDir },
|
|
});
|
|
const firstId = unwrap(first.body).session.id as string;
|
|
const owner = Object.keys(layouts)[0];
|
|
layouts[owner] = {
|
|
...layouts[owner],
|
|
groups: [{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: firstId }] }],
|
|
ungrouped: [],
|
|
};
|
|
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions',
|
|
payload: { name: 'w2-x', mode: 'shell', workingDir, tabGroupId: 'g1' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const data = unwrap(res.body);
|
|
expect(data.tabLayout).toEqual(layouts[owner]);
|
|
expect(data.tabLayout.version).toBe(layouts[owner].version);
|
|
expect(data.tabLayout.groups[0].refs).toEqual([
|
|
{ kind: 'session', id: firstId },
|
|
{ kind: 'session', id: data.session.id },
|
|
]);
|
|
expect(data.tabLayout.ungrouped).toEqual([]);
|
|
expect(order).toEqual([firstId, data.session.id]);
|
|
});
|
|
});
|