Merge branch 'master' into pr181

This commit is contained in:
Codeman maintainer
2026-08-05 00:04:43 +02:00
77 changed files with 8151 additions and 571 deletions
+158
View File
@@ -71,9 +71,114 @@ describe('run mode UI', () => {
expect(app.runMode).toBe('gemini');
expect(runBtnLabel.textContent).toBe('Run GM');
});
it('accepts Antigravity mode from server sync and updates the run button label', async () => {
const { app, storage, runBtnLabel } = loadRunModeHarness();
storage.set('codeman_runMode', 'claude');
await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'antigravity' }));
expect(app.runMode).toBe('antigravity');
expect(runBtnLabel.textContent).toBe('Run AG');
});
});
describe('Run launch synchronization', () => {
it('keeps launch progress out of an active session terminal', () => {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.activeSessionId = 'existing-session';
app.terminal = {
clear: vi.fn(),
writeln: vi.fn(),
};
app.showToast = vi.fn();
const ownsTerminal = app._beginSessionLaunchStatus('Starting Codex session', '1;32');
app._appendSessionLaunchStatus(ownsTerminal, 'Creating session');
app._reportSessionLaunchError(ownsTerminal, 'Launch failed');
expect(ownsTerminal).toBe(false);
expect(app.terminal.clear).not.toHaveBeenCalled();
expect(app.terminal.writeln).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenNthCalledWith(1, 'Starting Codex session', 'info');
expect(app.showToast).toHaveBeenNthCalledWith(2, 'Launch failed', 'error');
});
it('still renders launch progress in the terminal on the session-less home screen', () => {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.activeSessionId = null; // home screen: nothing else owns the terminal
app.terminal = { clear: vi.fn(), writeln: vi.fn() };
app.showToast = vi.fn();
const ownsTerminal = app._beginSessionLaunchStatus('Starting Codex session', '1;32');
app._appendSessionLaunchStatus(ownsTerminal, 'Creating session');
app._reportSessionLaunchError(ownsTerminal, 'Launch failed');
expect(ownsTerminal).toBe(true);
expect(app.terminal.clear).toHaveBeenCalledTimes(1);
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Starting Codex session');
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Creating session');
expect(app.terminal.writeln.mock.calls.map((c: string[]) => c[0]).join('\n')).toContain('Error: Launch failed');
expect(app.showToast).not.toHaveBeenCalled();
});
/**
* Static guard over session-ui.js itself. The helpers above can be perfectly
* correct while a run*() entry point still writes to the shared xterm
* directly, which is the actual bug: a launch started while another session
* is active wipes that session's terminal, and _cleanupPreviousSession()
* then serializes the wiped view into its restore snapshot. Asserting on the
* helpers alone cannot see that, so pin the call sites here. This also
* covers run modes added later, which is how runAntigravity was caught.
*/
it('routes every run mode through the ownership helpers, never the terminal directly', () => {
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
// Methods live in one Object.assign(prototype, {...}) block at a fixed
// 2-space indent, so `\n },` reliably closes the one we are inside.
const bodies = new Map<string, string>();
const header = /^ {2}async (run[A-Za-z]*)\(\) \{$/gm;
for (let m = header.exec(src); m; m = header.exec(src)) {
const start = m.index + m[0].length;
const end = src.indexOf('\n },', start);
expect(end, `could not find the end of ${m[1]}()`).toBeGreaterThan(start);
bodies.set(m[1], src.slice(start, end));
}
// Fail loudly if the scan matched nothing: a silently empty scan would make
// every assertion below vacuously true.
expect([...bodies.keys()]).toEqual(
expect.arrayContaining(['runClaude', 'runShell', 'runOpenCode', 'runCodex', 'runGemini', 'runAntigravity'])
);
for (const [name, body] of bodies) {
expect(body, `${name}() must not clear a terminal it may not own`).not.toContain('this.terminal.clear(');
expect(body, `${name}() must not write launch status straight to the terminal`).not.toContain(
'this.terminal.writeln('
);
}
});
it('coalesces overlapping Run activations and disables the button while the request is active', async () => {
const runBtn = {
disabled: false,
@@ -560,3 +665,56 @@ describe('Gemini quick start', () => {
expect(selected).toEqual(['sess-gm']);
});
});
describe('Antigravity quick start', () => {
// Same envelope-unwrap regression guard as the Gemini block above, for runAntigravity().
it('drives runAntigravity() through the {success,data} envelope and selects the new session', async () => {
const elements: Record<string, any> = {
quickStartCase: { value: 'ag-case' },
};
const requests: Array<{ url: 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 },
fetch: async (url: string, init?: { body?: string }) => {
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
if (url === '/api/antigravity/status')
return { json: async () => ({ success: true, data: { available: true } }) };
if (url === '/api/quick-start')
return { json: async () => ({ success: true, data: { sessionId: 'sess-ag' } }) };
if (url === '/api/sessions/sess-ag')
return { json: async () => ({ success: true, data: { id: 'sess-ag', name: 'w1-ag-case' } }) };
throw new Error(`unexpected fetch: ${url}`);
},
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
app.loadAppSettingsFromStorage = () => ({});
app.getCaseSettings = () => ({});
app.buildEnvOverrides = () => ({});
app.sessions = new Map();
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
app._renderSessionTabsImmediate = vi.fn();
const selected: string[] = [];
app.selectSession = async (id: string) => {
selected.push(id);
};
await app.runAntigravity();
expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
caseName: 'ag-case',
mode: 'antigravity',
antigravityConfig: { dangerouslySkipPermissions: true },
});
expect(selected).toEqual(['sess-ag']);
});
});