2026-09-21 04:37:03 +02:00
|
|
|
/**
|
|
|
|
|
* @fileoverview Table-driven pin for `run()`'s dispatch in session-ui.js
|
|
|
|
|
* (PR #458). Before the run-menu consolidation, `run()` was an eight-arm
|
|
|
|
|
* `if (mode === 'codex') return this.runCodex(); ...` chain and each arm was
|
|
|
|
|
* pinned only by the name it called; after it, every non-claude, non-shell
|
|
|
|
|
* mode reaches ONE shared launcher, `_runCliMode(mode)`, gated on
|
|
|
|
|
* `EXTERNAL_CLI_MODES` (the key set of `RUN_MODE_LAUNCH`). Nothing pinned that
|
|
|
|
|
* gate: a mode dropped from the table would fall through to `runClaude()` and
|
|
|
|
|
* start a Claude session under a Codex label with no error, while an unknown
|
|
|
|
|
* mode reaching `_runCliMode()` would throw on `entry.label` of an undefined
|
|
|
|
|
* entry.
|
|
|
|
|
*
|
|
|
|
|
* The external ids are read off `RUN_MODE_LAUNCH` itself (same JSDOM
|
|
|
|
|
* extraction as test/run-mode-launch-table-drift.test.ts, which separately
|
|
|
|
|
* pins that key set against stock.ts), so a ninth CLI is covered the day it
|
|
|
|
|
* lands in the table.
|
|
|
|
|
*
|
|
|
|
|
* Port: none.
|
|
|
|
|
*/
|
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
|
import { JSDOM } from 'jsdom';
|
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
|
|
|
|
|
|
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
|
|
|
|
|
|
|
|
|
interface HarnessApp {
|
|
|
|
|
_runMode?: string;
|
|
|
|
|
_runInFlight?: boolean;
|
|
|
|
|
_runMinLockMs?: number;
|
|
|
|
|
run: () => Promise<unknown>;
|
|
|
|
|
runClaude: ReturnType<typeof vi.fn>;
|
|
|
|
|
runShell: ReturnType<typeof vi.fn>;
|
|
|
|
|
_runCliMode: ReturnType<typeof vi.fn>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Harness {
|
|
|
|
|
app: HarnessApp;
|
|
|
|
|
runBtn: HTMLButtonElement;
|
|
|
|
|
externalIds: string[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 07:48:26 +08:00
|
|
|
function loadHarness(catalog: Array<{ id: string; kind: string; enabled: boolean }> = []): Harness {
|
2026-09-21 04:37:03 +02:00
|
|
|
const dom = new JSDOM('<!doctype html><body><button id="runBtn"></button></body>', {
|
|
|
|
|
url: 'http://localhost/',
|
|
|
|
|
runScripts: 'dangerously',
|
|
|
|
|
});
|
|
|
|
|
const win = dom.window as unknown as {
|
|
|
|
|
eval: (s: string) => void;
|
|
|
|
|
document: Document;
|
|
|
|
|
CodemanApp: new () => HarnessApp;
|
|
|
|
|
__TEST_RUN_MODE_LAUNCH: Record<string, unknown>;
|
2026-09-24 07:48:26 +08:00
|
|
|
__codemanCliCatalog: Array<{ id: string; kind: string; enabled: boolean }>;
|
2026-09-21 04:37:03 +02:00
|
|
|
};
|
2026-09-24 07:48:26 +08:00
|
|
|
win.__codemanCliCatalog = catalog;
|
2026-09-21 04:37:03 +02:00
|
|
|
win.eval('window.CodemanApp = function CodemanApp() {};');
|
|
|
|
|
// The assignment rides in the SAME evaluated string as the module:
|
|
|
|
|
// RUN_MODE_LAUNCH is a bare top-level `const`, visible only to this eval call
|
|
|
|
|
// (see test/run-mode-launch-table-drift.test.ts for the measurement).
|
|
|
|
|
win.eval(`${SESSION_UI_JS}\nwindow.__TEST_RUN_MODE_LAUNCH = RUN_MODE_LAUNCH;`);
|
|
|
|
|
const app = new win.CodemanApp();
|
|
|
|
|
app._runMinLockMs = 0; // run() otherwise holds its lock for >= 500ms per call
|
|
|
|
|
app.runClaude = vi.fn(async () => 'claude');
|
|
|
|
|
app.runShell = vi.fn(async () => 'shell');
|
|
|
|
|
app._runCliMode = vi.fn(async (mode: string) => `cli:${mode}`);
|
|
|
|
|
return {
|
|
|
|
|
app,
|
|
|
|
|
runBtn: win.document.getElementById('runBtn') as HTMLButtonElement,
|
|
|
|
|
externalIds: Object.keys(win.__TEST_RUN_MODE_LAUNCH),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('run() dispatch (session-ui.js)', () => {
|
|
|
|
|
const { externalIds } = loadHarness();
|
|
|
|
|
|
|
|
|
|
it('reads at least the eight external CLIs off RUN_MODE_LAUNCH (anti-vacuity)', () => {
|
|
|
|
|
expect(externalIds.length).toBeGreaterThanOrEqual(8);
|
|
|
|
|
expect(externalIds).not.toContain('claude');
|
|
|
|
|
expect(externalIds).not.toContain('shell');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("'claude' reaches runClaude() and nothing else", async () => {
|
|
|
|
|
const { app } = loadHarness();
|
|
|
|
|
app._runMode = 'claude';
|
|
|
|
|
await expect(app.run()).resolves.toBe('claude');
|
|
|
|
|
expect(app.runClaude).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(app._runCliMode).not.toHaveBeenCalled();
|
|
|
|
|
expect(app.runShell).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("'shell' reaches runShell(), which needs no CLI probe at all", async () => {
|
|
|
|
|
const { app } = loadHarness();
|
|
|
|
|
app._runMode = 'shell';
|
|
|
|
|
await expect(app.run()).resolves.toBe('shell');
|
|
|
|
|
expect(app.runShell).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(app.runClaude).not.toHaveBeenCalled();
|
|
|
|
|
expect(app._runCliMode).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (const id of externalIds) {
|
|
|
|
|
it(`'${id}' reaches _runCliMode('${id}') and never runClaude()`, async () => {
|
|
|
|
|
const { app } = loadHarness();
|
|
|
|
|
app._runMode = id;
|
|
|
|
|
await expect(app.run()).resolves.toBe(`cli:${id}`);
|
|
|
|
|
expect(app._runCliMode).toHaveBeenCalledTimes(1);
|
2026-10-03 18:06:08 -04:00
|
|
|
expect(app._runCliMode).toHaveBeenCalledWith(id, {});
|
2026-09-21 04:37:03 +02:00
|
|
|
expect(app.runClaude).not.toHaveBeenCalled();
|
|
|
|
|
expect(app.runShell).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 07:48:26 +08:00
|
|
|
it('an enabled registry agent reaches the shared launcher', async () => {
|
|
|
|
|
const { app } = loadHarness([{ id: 'custom-agent', kind: 'agent', enabled: true }]);
|
|
|
|
|
app._runMode = 'custom-agent';
|
|
|
|
|
await expect(app.run()).resolves.toBe('cli:custom-agent');
|
|
|
|
|
expect(app._runCliMode).toHaveBeenCalledTimes(1);
|
2026-10-03 18:06:08 -04:00
|
|
|
expect(app._runCliMode).toHaveBeenCalledWith('custom-agent', {});
|
2026-09-24 07:48:26 +08:00
|
|
|
expect(app.runClaude).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-21 04:37:03 +02:00
|
|
|
it('an unknown mode lands on runClaude(), never on the shared launcher', async () => {
|
2026-09-24 07:48:26 +08:00
|
|
|
// A stale localStorage mode must not reach `_runCliMode()`: it has neither
|
|
|
|
|
// a stock launch shape nor an enabled registry entry to launch.
|
2026-09-21 04:37:03 +02:00
|
|
|
for (const mode of ['nope', 'CLAUDE', 'code x']) {
|
|
|
|
|
const { app } = loadHarness();
|
|
|
|
|
app._runMode = mode;
|
|
|
|
|
await expect(app.run()).resolves.toBe('claude');
|
|
|
|
|
expect(app.runClaude).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(app._runCliMode).not.toHaveBeenCalled();
|
|
|
|
|
expect(app.runShell).not.toHaveBeenCalled();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('an unset or empty _runMode defaults to claude', async () => {
|
|
|
|
|
for (const mode of [undefined, '']) {
|
|
|
|
|
const { app } = loadHarness();
|
|
|
|
|
app._runMode = mode;
|
|
|
|
|
await expect(app.run()).resolves.toBe('claude');
|
|
|
|
|
expect(app.runClaude).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(app._runCliMode).not.toHaveBeenCalled();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('holds the launch lock for the whole launch and releases it afterwards', async () => {
|
|
|
|
|
const { app, runBtn } = loadHarness();
|
|
|
|
|
app._runMode = 'codex';
|
|
|
|
|
const pending = app.run();
|
|
|
|
|
expect(app._runInFlight).toBe(true);
|
|
|
|
|
expect(runBtn.disabled).toBe(true);
|
|
|
|
|
expect(runBtn.getAttribute('aria-busy')).toBe('true');
|
|
|
|
|
// A second click while the first launch is in flight is a no-op.
|
|
|
|
|
await expect(app.run()).resolves.toBeUndefined();
|
|
|
|
|
await pending;
|
|
|
|
|
expect(app._runCliMode).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(app._runInFlight).toBe(false);
|
|
|
|
|
expect(runBtn.disabled).toBe(false);
|
|
|
|
|
expect(runBtn.hasAttribute('aria-busy')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|