Files
Codeman/test/custom-model-one-shot-launch.test.ts
T
Codeman maintainer 4205f6930f fix(release): the seven findings from the pre-release review of the whole tree
A full review of the release tree found seven things, and four of them were mine.

**The gate was red, and I put it there.** Splitting `confirmed` into `confirmedContext`
and `confirmedSwap` changed the wire field without moving three assertions that check
it: `custom-model-one-shot-launch.test.ts` and two in `custom-model-run-menu-ui.test.ts`
(the swap modal and the context modal, each of which already receives exactly the right
per-question flag). Moved, with the titles.

**Worse, my own tests for the split never ran.** The four cases in
`session-custom-model.test.ts` that exist specifically to pin it call `mockRunning()`,
which was declared inside a sibling `describe`, so they threw a ReferenceError during
setup. The split would have shipped with no passing server-side coverage while the gate
reported the failure as four broken tests rather than as four tests that were never
written. `mockRunning` is hoisted to the outer describe.

**The submit verifier pressed Enter into shell panes.** `#455`'s SubmitVerifier resolved
its composer glyph as `promptGlyph ?? '❯'`, and only claude and codex declare one, so
the other eight modes fell back to claude's `❯`. That is also starship's default shell
prompt, and pure's, and spaceship's, and p10k lean's. On such a shell the line
`❯ npm run build` sits on screen for as long as the command runs, the verifier reads it
as an unsubmitted prompt, and re-presses Enter into the running program's stdin up to
nine times on its 2s..60s schedule. Mostly a stray newline; not harmless against a y/N
prompt, `read -p`, an installer or a pager, where it takes the default. The module's own
fileoverview already stated the rule this broke. Now `?? ''`, which
`promptStillInComposer()` already treats as inert, so the verifier runs only for a CLI
that actually declares a composer.

**My #451 dedent removal left a count behind**: "Two rules keep it honest" introducing
three numbered rules.

The rest is documentation the split outran. `confirmedContext`/`confirmedSwap` appeared
in no doc at all, while `docs/api-reference.md` (the SemVer-covered contract) still told
an integrator to retry with `confirmed: true` for both questions, which is precisely the
thing the split exists to stop. Documented there, in `docs/custom-model-endpoints.md`
and in CLAUDE.md. The custom-model changeset gained the split and the `CLAUDE_CONFIG_DIR`
multi-user consequence, both user-visible and both previously absent, and #454's gained
the one exception to its own claim: a Custom Endpoints launch ignores the Instance count
stepper and always starts one session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 12:58:33 +02:00

242 lines
9.7 KiB
TypeScript

/**
* @fileoverview Frontend tests for the one-shot custom-model launch path added to
* session-ui.js (docs/custom-model-endpoints-plan.md): `runCustomModelEntry` dispatches
* to `_runCustomModelEntryOneShot` for every custom-model-eligible CLI except claude,
* which launches directly on the endpoint (no restart) by folding `customModel` into
* the run<Mode>() function's own `/api/quick-start` body via `_pendingCustomModelForLaunch`
* and `_quickStartWithCustomModelConfirm`. Fixes the visible native-boot-then-restart the
* restart-after-launch path (`_runCustomModelEntryViaRestart`, still used for claude)
* showed on every custom-model run — confirmed live on Codex, whose TUI fully
* reinitializes on a restart.
*
* Uses the same JSDOM + `runScripts: "dangerously"` approach as
* test/custom-model-run-menu-ui.test.ts, extended with the DOM elements runCodex() (the
* CLI this was reported against) reads.
*
* Port: none.
*/
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
import { describe, expect, it } from 'vitest';
const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
function bootApp() {
const dom = new JSDOM(
`<!doctype html><body>
<select id="quickStartCase"><option value="testcase" selected>testcase</option></select>
<input id="tabCount" value="1">
<button id="runBtn"></button>
<div id="runModeMenu"></div>
</body>`,
{ url: 'http://localhost/', runScripts: 'dangerously' }
);
const win = dom.window as unknown as Window & typeof globalThis & { CodemanApp: new () => any };
(win as unknown as { eval: (s: string) => void }).eval('window.CodemanApp = function CodemanApp() {};');
(win as unknown as { eval: (s: string) => void }).eval(CONSTANTS_JS);
(win as unknown as { eval: (s: string) => void }).eval(SESSION_UI_JS);
const app = new win.CodemanApp();
app.cases = [{ name: 'testcase' }];
app.terminal = { focus: () => {} };
app.loadAppSettingsFromStorage = () => ({});
app.getCaseSettings = () => ({});
app.buildEnvOverrides = () => ({});
app.showToast = () => {};
app._beginSessionLaunchStatus = () => 'status-token';
app._reportSessionLaunchError = (_token: unknown, message: string) => {
app._lastReportedError = message;
};
app._ensureCreatedSessionVisible = async () => {};
app.selectSession = async () => {};
app._nextCaseSessionStartNumber = () => 1;
return { win, app };
}
describe('runCustomModelEntry dispatch', () => {
it('routes claude through the restart-after-launch path', async () => {
const { app } = bootApp();
let calledRestart = false;
let calledOneShot = false;
app._runCustomModelEntryViaRestart = async () => {
calledRestart = true;
};
app._runCustomModelEntryOneShot = async () => {
calledOneShot = true;
};
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(calledRestart).toBe(true);
expect(calledOneShot).toBe(false);
});
it('routes every other custom-model-eligible CLI through the one-shot path', async () => {
for (const mode of ['opencode', 'codex', 'gemini', 'pi', 'grok', 'deepseek', 'omp']) {
const { app } = bootApp();
let calledRestart = false;
let calledOneShot = false;
app._runCustomModelEntryViaRestart = async () => {
calledRestart = true;
};
app._runCustomModelEntryOneShot = async () => {
calledOneShot = true;
};
await app.runCustomModelEntry(mode, 'llama-box', 'qwen3');
expect(calledRestart, mode).toBe(false);
expect(calledOneShot, mode).toBe(true);
}
});
});
describe('_runCustomModelEntryOneShot', () => {
it('stashes the pick on _pendingCustomModelForLaunch for the duration of run(), then clears it', async () => {
const { app } = bootApp();
let seenDuringRun: unknown;
app.run = async function (this: typeof app) {
seenDuringRun = this._pendingCustomModelForLaunch;
};
await app._runCustomModelEntryOneShot('codex', 'llama-box', 'qwen3');
expect(seenDuringRun).toEqual({ endpointId: 'llama-box', modelId: 'qwen3' });
expect(app._pendingCustomModelForLaunch).toBeUndefined();
});
it('clears the pending pick even when run() throws', async () => {
const { app } = bootApp();
app.run = async () => {
throw new Error('boom');
};
await expect(app._runCustomModelEntryOneShot('codex', 'llama-box', 'qwen3')).rejects.toThrow('boom');
expect(app._pendingCustomModelForLaunch).toBeUndefined();
});
it('starts the loading watcher when the launch reports modelSwapInProgress, passing the new session id', async () => {
const { app } = bootApp();
app.run = async () => {
app._lastCustomModelLaunchResult = { modelSwapInProgress: true, sessionId: 'new-session' };
};
let watched: unknown[] | null = null;
app._watchLlamaSwapLoading = async (...args: unknown[]) => {
watched = args;
};
await app._runCustomModelEntryOneShot('codex', 'llama-box', 'qwen3');
expect(watched).toEqual(['llama-box', 'qwen3', 'new-session']);
});
it('never starts the watcher when no swap was needed', async () => {
const { app } = bootApp();
app.run = async () => {
app._lastCustomModelLaunchResult = { modelSwapInProgress: false };
};
let watchCalled = false;
app._watchLlamaSwapLoading = async () => {
watchCalled = true;
};
await app._runCustomModelEntryOneShot('codex', 'llama-box', 'qwen3');
expect(watchCalled).toBe(false);
});
});
describe('_quickStartWithCustomModelConfirm', () => {
function withFetch(win: Window & typeof globalThis, handler: (body: any) => any) {
(win as unknown as { fetch: typeof fetch }).fetch = (async (_url: string, opts: any) => ({
json: async () => handler(JSON.parse(opts.body)),
})) as unknown as typeof fetch;
}
it('returns the response directly when no confirmation is needed', async () => {
const { win, app } = bootApp();
withFetch(win, (body) => ({ success: true, data: { sessionId: 's1', modelSwapInProgress: false, body } }));
const data = await app._quickStartWithCustomModelConfirm({
mode: 'codex',
customModel: { endpointId: 'e', modelId: 'm' },
});
expect(data.success).toBe(true);
expect(data.data.sessionId).toBe('s1');
expect(app._lastCustomModelLaunchResult).toEqual(data.data);
});
it('confirming re-sends with confirmedSwap and returns the second response', async () => {
const { win, app } = bootApp();
app._confirmModelSwap = async () => true;
let calls = 0;
withFetch(win, (body) => {
calls += 1;
if (calls === 1) {
return {
success: true,
data: {
requiresConfirmation: true,
currentlyLoadedModel: 'llama3',
affectedSessions: [{ id: 's2', name: 'w2' }],
},
};
}
// the SWAP question's own flag, never the blanket `confirmed`: answering this one
// must not also silence the context-floor warning.
expect(body.customModel.confirmedSwap).toBe(true);
expect(body.customModel.confirmed).toBeUndefined();
return { success: true, data: { sessionId: 's1', modelSwapInProgress: true } };
});
const data = await app._quickStartWithCustomModelConfirm({
mode: 'codex',
customModel: { endpointId: 'e', modelId: 'm' },
});
expect(calls).toBe(2);
expect(data.data.sessionId).toBe('s1');
expect(app._lastCustomModelLaunchResult.modelSwapInProgress).toBe(true);
});
it('cancelling never re-sends, and reports a cancellation error', async () => {
const { win, app } = bootApp();
app._confirmModelSwap = async () => false;
let calls = 0;
withFetch(win, () => {
calls += 1;
return {
success: true,
data: {
requiresConfirmation: true,
currentlyLoadedModel: 'llama3',
affectedSessions: [{ id: 's2', name: 'w2' }],
},
};
});
const data = await app._quickStartWithCustomModelConfirm({
mode: 'codex',
customModel: { endpointId: 'e', modelId: 'm' },
});
expect(calls).toBe(1);
expect(data.success).toBe(false);
expect(data.error).toMatch(/cancelled/i);
expect(app._lastCustomModelLaunchResult).toBeUndefined();
});
});
describe('runCodex(): one-shot custom-model launch (the CLI this was reported against)', () => {
it('folds _pendingCustomModelForLaunch into the quick-start body as customModel', async () => {
const { win, app } = bootApp();
(win as unknown as { fetch: typeof fetch }).fetch = (async (url: string, opts?: any) => {
if (url === '/api/codex/status') return { json: async () => ({ data: { available: true } }) };
const body = JSON.parse(opts.body);
expect(body.customModel).toEqual({ endpointId: 'llama-box', modelId: 'qwen3' });
return { json: async () => ({ success: true, data: { sessionId: 's1', modelSwapInProgress: false } }) };
}) as unknown as typeof fetch;
app._pendingCustomModelForLaunch = { endpointId: 'llama-box', modelId: 'qwen3' };
await app.runCodex();
expect(app._lastReportedError).toBeUndefined();
});
it('omits customModel entirely for a plain (non-custom-model) Codex launch', async () => {
const { win, app } = bootApp();
(win as unknown as { fetch: typeof fetch }).fetch = (async (url: string, opts?: any) => {
if (url === '/api/codex/status') return { json: async () => ({ data: { available: true } }) };
const body = JSON.parse(opts.body);
expect(body.customModel).toBeUndefined();
return { json: async () => ({ success: true, data: { sessionId: 's1' } }) };
}) as unknown as typeof fetch;
await app.runCodex();
expect(app._lastReportedError).toBeUndefined();
});
});