mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Merge remote-tracking branch 'origin/master' into relprep/pr-510
Brings the 1.33.2 based adoption feature up to master. Conflicts were resolved by keeping master's intent and re-applying the feature on top: both sides kept for imports, the script tag and the stylesheet blocks; the trust-dialog guard keeps master's stopped check plus the adopted refusal; the Ralph and image watchers keep master's remote branch plus the adopted skip; the close confirmation keeps master's worktree option plus the adopted wording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
57b2dcc466
858 files changed
+173626
-7078
No files matched your search
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @fileoverview Accessory keys that keep the keyboard up must not take focus on
|
||||
* press. A tap used to blur the terminal's textarea and the click handler then
|
||||
* refocused it, so every arrow/Tab/Esc tap started a keyboard dismissal. The bar
|
||||
* now cancels the mousedown default for exactly those keys (and the first,
|
||||
* arming tap of clear/compact); every other key keeps the old behavior.
|
||||
*
|
||||
* Loaded with `vm` against a minimal fake DOM, like the other accessory-bar tests.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type Listener = (e: unknown) => void;
|
||||
type Bar = {
|
||||
init(): void;
|
||||
_confirmAction: string | null;
|
||||
syncReadMyMind(): void;
|
||||
syncCodexKeys(): void;
|
||||
_syncComposerDraftIndicator(): void;
|
||||
};
|
||||
|
||||
function loadBar() {
|
||||
const listeners: Record<string, Listener> = {};
|
||||
const element = {
|
||||
className: '',
|
||||
innerHTML: '',
|
||||
addEventListener: (type: string, fn: Listener) => {
|
||||
listeners[type] = fn;
|
||||
},
|
||||
};
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
document: { createElement: () => element, querySelector: () => null },
|
||||
MobileDetection: { isTouchDevice: () => true },
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
vm.runInContext(
|
||||
`${readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8')}\nglobalThis.__bar = KeyboardAccessoryBar;`,
|
||||
context
|
||||
);
|
||||
const bar = (context as unknown as { __bar: Bar }).__bar;
|
||||
bar.syncReadMyMind = () => {};
|
||||
bar.syncCodexKeys = () => {};
|
||||
bar._syncComposerDraftIndicator = () => {};
|
||||
bar.init();
|
||||
// `null` is a press on the bar itself (padding, a gap): closest() finds no key.
|
||||
const press = (action: string | null) => {
|
||||
const preventDefault = vi.fn();
|
||||
listeners.mousedown({
|
||||
target: { closest: () => (action === null ? null : { dataset: { action } }) },
|
||||
preventDefault,
|
||||
});
|
||||
return preventDefault.mock.calls.length > 0;
|
||||
};
|
||||
return { bar, press };
|
||||
}
|
||||
|
||||
describe('accessory keys keep the terminal focused on press', () => {
|
||||
it('cancels the press default for keys that leave the keyboard up', () => {
|
||||
const { press } = loadBar();
|
||||
for (const action of [
|
||||
'scroll-up',
|
||||
'scroll-down',
|
||||
'arrow-left',
|
||||
'arrow-right',
|
||||
'tab',
|
||||
'shift-tab',
|
||||
'shift-left',
|
||||
'shift-right',
|
||||
'ctrl',
|
||||
'ctrl-o',
|
||||
'opt-enter',
|
||||
'esc',
|
||||
'effort-max',
|
||||
'clear-input',
|
||||
]) {
|
||||
expect(press(action), action).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves keys that move focus or dismiss the keyboard alone', () => {
|
||||
const { press } = loadBar();
|
||||
for (const action of ['compose', 'readmymind', 'pick-path', 'paste', 'dismiss', 'init']) {
|
||||
expect(press(action), action).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels only the arming tap of clear/compact, not the confirming one', () => {
|
||||
const { bar, press } = loadBar();
|
||||
bar._confirmAction = null;
|
||||
expect(press('clear')).toBe(true);
|
||||
bar._confirmAction = 'clear';
|
||||
expect(press('clear')).toBe(false);
|
||||
expect(press('compact')).toBe(true);
|
||||
bar._confirmAction = 'compact';
|
||||
expect(press('compact')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a press outside a key', () => {
|
||||
const { press } = loadBar();
|
||||
expect(press(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Phase 5 admin API tests (live server, port 3173).
|
||||
* @fileoverview Phase 5 admin API tests (live server, ephemeral port).
|
||||
*
|
||||
* Covers the admin user-management endpoints: multi-user gate, requireAdmin,
|
||||
* create (one-time password), patch + last-admin invariant, reset-password,
|
||||
@@ -16,9 +16,8 @@ import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const PORT = 3173;
|
||||
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
|
||||
const url = (p: string) => `http://localhost:${PORT}${p}`;
|
||||
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
|
||||
const admin = { Authorization: basic('root', 'rootpass123'), 'Content-Type': 'application/json' };
|
||||
const adminNoBody = { Authorization: basic('root', 'rootpass123') };
|
||||
const regular = { Authorization: basic('joe', 'joepass1234'), 'Content-Type': 'application/json' };
|
||||
@@ -48,7 +47,7 @@ beforeAll(async () => {
|
||||
invalidateUsersCache();
|
||||
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
|
||||
await createUser({ username: 'joe', role: 'user', password: 'joepass1234' });
|
||||
server = new WebServer(PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
@@ -190,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,304 @@
|
||||
/**
|
||||
* @fileoverview Tests for Claude Code's advisor tool (code.claude.com/docs/en/advisor)
|
||||
* carried as a per-session `advisorModel`.
|
||||
*
|
||||
* The advisor rides the launch's ONE `--settings` JSON object as the `advisorModel` key,
|
||||
* never the `--advisor` flag: the flag exits at launch on a pairing the CLI refuses
|
||||
* (`claude --advisor haiku` prints "cannot be used as an advisor" and exits 1), while the
|
||||
* settings key degrades to "no advisor". Verified against Claude Code 2.1.289 with
|
||||
* `claude -p --settings '{"advisorModel":"opus"}' /advisor` → "Advisor: Opus 5.5".
|
||||
*
|
||||
* `--settings` is extracted through a REAL shell, as in statusline-cli-flag.test.ts, so the
|
||||
* assertions see exactly what a spawned pane would.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { buildAdvisorSettings, buildInteractiveArgs } from '../src/session-cli-builder.js';
|
||||
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
import { isAdvisorModel, ADVISOR_MODEL_ALIASES } from '../src/types.js';
|
||||
import { Session } from '../src/session.js';
|
||||
import {
|
||||
CreateSessionSchema,
|
||||
QuickStartSchema,
|
||||
RalphLoopStartSchema,
|
||||
SettingsUpdateSchema,
|
||||
} from '../src/web/schemas.js';
|
||||
|
||||
const EXPORTER_CMD = 'curl -sfk -X POST "$CODEMAN_API_URL/api/status-telemetry" --data @- 2>/dev/null || true';
|
||||
|
||||
/**
|
||||
* What the direct-PTY fallback hands `pty.spawn`. The file and argv are recorded, then a
|
||||
* harmless stand-in runs instead: the real `claude` must never start from a test, and the
|
||||
* stand-in is a real process so `Session.stop()` has a real pid to signal.
|
||||
*/
|
||||
const ptySpawns = vi.hoisted(() => [] as Array<{ file: string; args: string[] }>);
|
||||
vi.mock('node-pty', async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import('node-pty')>();
|
||||
return {
|
||||
...real,
|
||||
spawn: (file: string, args: string[] | string, options: import('node-pty').IPtyForkOptions) => {
|
||||
ptySpawns.push({ file, args: Array.isArray(args) ? args : [args] });
|
||||
return real.spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], options);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function extractSettingsJson(cmd: string): unknown {
|
||||
const idx = cmd.indexOf('--settings ');
|
||||
expect(idx).toBeGreaterThan(-1);
|
||||
const out = execFileSync('bash', ['-c', `set -- ${cmd.slice(idx)}; printf '%s' "$2"`]).toString();
|
||||
return JSON.parse(out);
|
||||
}
|
||||
|
||||
describe('isAdvisorModel', () => {
|
||||
it('accepts the documented aliases', () => {
|
||||
for (const alias of ADVISOR_MODEL_ALIASES) expect(isAdvisorModel(alias)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts full model ids in the advisor-capable families', () => {
|
||||
expect(isAdvisorModel('claude-opus-5-5')).toBe(true);
|
||||
expect(isAdvisorModel('claude-fable-5-1')).toBe(true);
|
||||
expect(isAdvisorModel('claude-sonnet-5-5')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects haiku, which can call an advisor but never act as one', () => {
|
||||
expect(isAdvisorModel('haiku')).toBe(false);
|
||||
expect(isAdvisorModel('claude-haiku-4-5-20251001')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects anything that could break out of the quoted --settings argument', () => {
|
||||
for (const bad of [
|
||||
'',
|
||||
'OPUS',
|
||||
'opus[1m]',
|
||||
"opus'; rm -rf /; '",
|
||||
'opus"}',
|
||||
'claude-opus-5-5 --dangerously-skip-permissions',
|
||||
`claude-opus-${'5-'.repeat(40)}5`,
|
||||
undefined,
|
||||
null,
|
||||
42,
|
||||
]) {
|
||||
expect(isAdvisorModel(bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAdvisorSettings', () => {
|
||||
it('returns the settings key for a valid model and nothing otherwise', () => {
|
||||
expect(buildAdvisorSettings('opus')).toEqual({ advisorModel: 'opus' });
|
||||
expect(buildAdvisorSettings(undefined)).toEqual({});
|
||||
expect(buildAdvisorSettings('haiku')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSpawnCommand advisorModel (tmux launch, claude mode)', () => {
|
||||
it('rides --settings as the advisorModel key, never the --advisor flag', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', advisorModel: 'opus' });
|
||||
expect(cmd).not.toContain('--advisor');
|
||||
expect(cmd.match(/--settings/g)).toHaveLength(1);
|
||||
expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'opus' });
|
||||
});
|
||||
|
||||
it('merges ultracode, advisor and the statusLine exporter into ONE --settings object', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
effort: 'ultracode',
|
||||
advisorModel: 'fable',
|
||||
statusLineCommand: EXPORTER_CMD,
|
||||
});
|
||||
expect(cmd.match(/--settings/g)).toHaveLength(1);
|
||||
expect(extractSettingsJson(cmd)).toEqual({
|
||||
ultracode: true,
|
||||
advisorModel: 'fable',
|
||||
statusLine: { type: 'command', command: EXPORTER_CMD },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a regular --effort flag beside the advisor settings', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', effort: 'high', advisorModel: 'sonnet' });
|
||||
expect(cmd).toContain("--effort 'high'");
|
||||
expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'sonnet' });
|
||||
});
|
||||
|
||||
it('carries the advisor on the resume variant too', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
resumeSessionId: '11111111-2222-3333-4444-555555555555',
|
||||
advisorModel: 'opus',
|
||||
});
|
||||
expect(cmd).toContain('--resume');
|
||||
expect(extractSettingsJson(cmd)).toEqual({ advisorModel: 'opus' });
|
||||
});
|
||||
|
||||
it('leaves the command byte-identical when no advisor (or an invalid one) is set', () => {
|
||||
for (const base of [
|
||||
{ mode: 'claude', sessionId: 'sid-1' },
|
||||
{ mode: 'claude', sessionId: 'sid-1', effort: 'ultracode' as const, statusLineCommand: EXPORTER_CMD },
|
||||
]) {
|
||||
const without = buildSpawnCommand(base);
|
||||
expect(buildSpawnCommand({ ...base, advisorModel: undefined })).toBe(without);
|
||||
expect(buildSpawnCommand({ ...base, advisorModel: 'haiku' })).toBe(without);
|
||||
}
|
||||
});
|
||||
|
||||
it('is inert for a CLI that has no --settings carrier', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'codex', sessionId: 'sid-1', advisorModel: 'opus' });
|
||||
expect(cmd).not.toContain('advisorModel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInteractiveArgs advisorModel (direct-PTY fallback)', () => {
|
||||
const settingsOf = (args: string[]) => {
|
||||
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
|
||||
return JSON.parse(args[args.indexOf('--settings') + 1]);
|
||||
};
|
||||
|
||||
it('adds a --settings object holding only the advisor', () => {
|
||||
const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, undefined, undefined, null, 'opus');
|
||||
expect(args).not.toContain('--advisor');
|
||||
expect(settingsOf(args)).toEqual({ advisorModel: 'opus' });
|
||||
});
|
||||
|
||||
it("folds the advisor into ultracode's --settings object", () => {
|
||||
const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, 'ultracode', undefined, null, 'fable');
|
||||
expect(settingsOf(args)).toEqual({ ultracode: true, advisorModel: 'fable' });
|
||||
});
|
||||
|
||||
it('keeps --effort beside it for a regular level', () => {
|
||||
const args = buildInteractiveArgs('sid', 'normal', undefined, undefined, 'max', undefined, null, 'sonnet');
|
||||
expect(args).toEqual(expect.arrayContaining(['--effort', 'max']));
|
||||
expect(settingsOf(args)).toEqual({ advisorModel: 'sonnet' });
|
||||
});
|
||||
|
||||
it('is unchanged without an advisor', () => {
|
||||
expect(buildInteractiveArgs('sid', 'normal', undefined, undefined, 'high', undefined, null, undefined)).toEqual(
|
||||
buildInteractiveArgs('sid', 'normal', undefined, undefined, 'high', undefined, null)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #514 (per-session `model` → `--model`) and #530 landed together. A claude session carrying
|
||||
* both must launch with `--model <id>` AND the advisor folded into the one `--settings` JSON,
|
||||
* whatever the effort, on the tmux template and on the direct-PTY fallback alike. Recovery of
|
||||
* the same pair is pinned in test/session-model-recovery.test.ts.
|
||||
*/
|
||||
describe('a per-session model together with an advisor', () => {
|
||||
const MODEL = 'claude-fable-5-1';
|
||||
const cases = [
|
||||
['ultracode', { ultracode: true, advisorModel: 'opus' }],
|
||||
['high', { advisorModel: 'opus' }],
|
||||
[undefined, { advisorModel: 'opus' }],
|
||||
] as const;
|
||||
|
||||
it.each(cases)('tmux template carries --model and the merged --settings, effort %s', (effort, settings) => {
|
||||
for (const resumeSessionId of [undefined, '11111111-2222-3333-4444-555555555555']) {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
model: MODEL,
|
||||
effort,
|
||||
advisorModel: 'opus',
|
||||
resumeSessionId,
|
||||
claudeCliVersion: null,
|
||||
});
|
||||
// The resume variant renders `resume || new`, so the model appears once per branch.
|
||||
expect(cmd).toContain(`--model "${MODEL}"`);
|
||||
expect(cmd).not.toContain('--advisor ');
|
||||
const settingsFlags = cmd.match(/--settings /g) ?? [];
|
||||
expect(settingsFlags.length).toBe(resumeSessionId ? 2 : 1);
|
||||
expect(extractSettingsJson(cmd)).toEqual(settings);
|
||||
if (effort === 'high') expect(cmd).toContain("--effort 'high'");
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the statusLine exporter in the same object beside the model', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'claude',
|
||||
sessionId: 'sid-1',
|
||||
model: MODEL,
|
||||
effort: 'ultracode',
|
||||
advisorModel: 'fable',
|
||||
statusLineCommand: EXPORTER_CMD,
|
||||
claudeCliVersion: null,
|
||||
});
|
||||
expect(cmd).toContain(`--model "${MODEL}"`);
|
||||
expect(cmd.match(/--settings /g)).toHaveLength(1);
|
||||
expect(extractSettingsJson(cmd)).toEqual({
|
||||
ultracode: true,
|
||||
advisorModel: 'fable',
|
||||
statusLine: { type: 'command', command: EXPORTER_CMD },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(cases)('direct-PTY args carry --model and the merged --settings, effort %s', (effort, settings) => {
|
||||
const args = buildInteractiveArgs('sid', 'normal', MODEL, undefined, effort, undefined, null, 'opus');
|
||||
expect(args[args.indexOf('--model') + 1]).toBe(MODEL);
|
||||
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
|
||||
expect(JSON.parse(args[args.indexOf('--settings') + 1])).toEqual(settings);
|
||||
if (effort === 'high') expect(args).toEqual(expect.arrayContaining(['--effort', 'high']));
|
||||
});
|
||||
|
||||
describe('a real Session on the direct-PTY fallback', () => {
|
||||
const live: Session[] = [];
|
||||
afterEach(async () => {
|
||||
for (const s of live.splice(0)) await s.stop();
|
||||
ptySpawns.length = 0;
|
||||
});
|
||||
|
||||
it.each(cases)('hands pty.spawn both, effort %s', async (effort, settings) => {
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
useMux: false,
|
||||
model: MODEL,
|
||||
advisorModel: 'opus',
|
||||
effort,
|
||||
});
|
||||
live.push(session);
|
||||
await session.startInteractive();
|
||||
|
||||
expect(ptySpawns).toHaveLength(1);
|
||||
const { args } = ptySpawns[0];
|
||||
expect(args[args.indexOf('--model') + 1]).toBe(MODEL);
|
||||
expect(args.filter((a) => a === '--settings')).toHaveLength(1);
|
||||
expect(JSON.parse(args[args.indexOf('--settings') + 1])).toEqual(settings);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session advisorModel', () => {
|
||||
it('stores a valid advisor and persists it through toState()', () => {
|
||||
const session = new Session({ workingDir: '/tmp', advisorModel: 'opus' });
|
||||
expect(session.toState().advisorModel).toBe('opus');
|
||||
});
|
||||
|
||||
it('drops an invalid value instead of forwarding it to the launch', () => {
|
||||
expect(new Session({ workingDir: '/tmp', advisorModel: 'haiku' }).toState().advisorModel).toBeUndefined();
|
||||
expect(new Session({ workingDir: '/tmp' }).toState().advisorModel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('advisorModel request validation', () => {
|
||||
it.each([
|
||||
['CreateSessionSchema', CreateSessionSchema, { workingDir: '/tmp' }],
|
||||
['QuickStartSchema', QuickStartSchema, {}],
|
||||
['RalphLoopStartSchema', RalphLoopStartSchema, { taskDescription: 'x' }],
|
||||
] as const)('%s accepts advisor models and rejects the rest', (_name, schema, base) => {
|
||||
expect(schema.safeParse({ ...base, advisorModel: 'opus' }).success).toBe(true);
|
||||
expect(schema.safeParse({ ...base, advisorModel: 'claude-fable-5-1' }).success).toBe(true);
|
||||
expect(schema.safeParse({ ...base }).success).toBe(true);
|
||||
expect(schema.safeParse({ ...base, advisorModel: 'haiku' }).success).toBe(false);
|
||||
expect(schema.safeParse({ ...base, advisorModel: "opus'" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('SettingsUpdateSchema takes claudeAdvisorModel, with "" meaning the CLI default', () => {
|
||||
expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: '' }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: 'fable' }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({ claudeAdvisorModel: 'haiku' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
/** @fileoverview Strict bounded native rollout attribution without viewer heuristics. */
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import {
|
||||
__clearCodexAttributionMemo,
|
||||
findExactCodexConversation,
|
||||
resolveEffectiveCodexHome,
|
||||
} from '../src/agent-codex-identity.js';
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
const A = '11111111-1111-4111-8111-111111111111';
|
||||
const B = '22222222-2222-4222-8222-222222222222';
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'agent-attribution-'));
|
||||
roots.push(root);
|
||||
const home = join(root, 'home');
|
||||
await mkdir(join(home, 'sessions'), { recursive: true });
|
||||
const options = { codexHome: home, sessionId: 'managed', workingDir: root };
|
||||
const put = async (id: string, payload: object = {}, text?: string) => {
|
||||
const path = join(home, 'sessions', `rollout-${id}.jsonl`);
|
||||
await writeFile(
|
||||
path,
|
||||
text ??
|
||||
JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: { id, cwd: root, originator: 'codeman_managed', ...payload },
|
||||
}) + '\n'
|
||||
);
|
||||
return path;
|
||||
};
|
||||
return { root, home, options, put };
|
||||
}
|
||||
it('requires one complete native session_meta matching the rollout ID, managed originator and canonical workspace', async () => {
|
||||
const f = await fixture();
|
||||
const path = await f.put(A);
|
||||
expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path });
|
||||
await f.put(A, { id: B });
|
||||
expect(await findExactCodexConversation(f.options)).toBeUndefined();
|
||||
});
|
||||
it('never chooses newest cwd files or an ambiguous managed originator', async () => {
|
||||
const f = await fixture();
|
||||
await f.put(A, { originator: 'codex_cli' });
|
||||
expect(await findExactCodexConversation(f.options)).toBeUndefined();
|
||||
await f.put(A);
|
||||
await f.put(B);
|
||||
expect(await findExactCodexConversation(f.options)).toBeUndefined();
|
||||
});
|
||||
it('allows an explicit resume identity but still requires native metadata and the workspace', async () => {
|
||||
const f = await fixture();
|
||||
const path = await f.put(A, { originator: 'original-launch' });
|
||||
expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toEqual({ id: A, path });
|
||||
await f.put(B);
|
||||
expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toBeUndefined();
|
||||
await f.put(A, { cwd: f.home });
|
||||
expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toBeUndefined();
|
||||
});
|
||||
it.each(['partial', 'duplicate', 'oversized'] as const)('refuses %s metadata', async (kind) => {
|
||||
const f = await fixture();
|
||||
const row = JSON.stringify({ type: 'session_meta', payload: { id: A, cwd: f.root, originator: 'codeman_managed' } });
|
||||
await f.put(
|
||||
A,
|
||||
{},
|
||||
kind === 'partial'
|
||||
? row
|
||||
: kind === 'duplicate'
|
||||
? row + '\n' + row + '\n'
|
||||
: row.slice(0, -2) + ',"instructions":"' + 'x'.repeat(140000) + '"}}\n'
|
||||
);
|
||||
expect(await findExactCodexConversation(f.options)).toBeUndefined();
|
||||
});
|
||||
it('enforces traversal/read caps and ignores symlinked directories', async () => {
|
||||
const f = await fixture();
|
||||
await f.put(A);
|
||||
expect(await findExactCodexConversation(f.options, { maxEntries: 0, maxBytes: 1048576 })).toBeUndefined();
|
||||
expect(await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 128 })).toBeUndefined();
|
||||
await mkdir(join(f.root, 'elsewhere'));
|
||||
await symlink(join(f.root, 'elsewhere'), join(f.home, 'sessions', 'alias'));
|
||||
expect((await findExactCodexConversation(f.options))?.id).toBe(A);
|
||||
});
|
||||
it('resolves per-session, process and default CODEX_HOME relative to the session workspace', () => {
|
||||
expect(resolveEffectiveCodexHome('custom', '/tmp/work', { CODEX_HOME: '/other' })).toBe('/tmp/work/custom');
|
||||
expect(resolveEffectiveCodexHome(undefined, '/tmp/work', { CODEX_HOME: 'process' })).toBe('/tmp/work/process');
|
||||
expect(resolveEffectiveCodexHome(undefined, '/tmp/work', { HOME: '/tmp/user' })).toBe('/tmp/user/.codex');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['trailing-space', 'codex-home ', 'codex-home'],
|
||||
['whitespace-only', ' ', '.codex'],
|
||||
])(
|
||||
'keeps the raw %s CODEX_HOME namespace through the Session getter and attribution helper',
|
||||
async (_label, configured, wrongHome) => {
|
||||
const { Session } = await import('../src/session.js');
|
||||
const f = await fixture();
|
||||
const expectedHome = join(f.root, configured);
|
||||
const siblingHome = join(f.root, wrongHome);
|
||||
const record =
|
||||
JSON.stringify({ type: 'session_meta', payload: { id: A, cwd: f.root, originator: 'codeman_managed' } }) + '\n';
|
||||
for (const home of [expectedHome, siblingHome]) {
|
||||
await mkdir(join(home, 'sessions'), { recursive: true });
|
||||
await writeFile(join(home, 'sessions', `rollout-${A}.jsonl`), record);
|
||||
}
|
||||
const session = new Session({
|
||||
id: 'managed',
|
||||
workingDir: f.root,
|
||||
mode: 'codex',
|
||||
envOverrides: { CODEX_HOME: configured },
|
||||
});
|
||||
try {
|
||||
const codexHome = resolveEffectiveCodexHome(session.getCodexHomeOverride(), session.workingDir, { HOME: f.root });
|
||||
expect(await findExactCodexConversation({ ...f.options, codexHome })).toEqual({
|
||||
id: A,
|
||||
path: join(expectedHome, 'sessions', `rollout-${A}.jsonl`),
|
||||
});
|
||||
expect(session.getCodexHomeOverride()).toBe(configured);
|
||||
} finally {
|
||||
await session.stop(false);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('attributes an owned rollout among 320 unrelated full-size metadata prefixes', async () => {
|
||||
const f = await fixture();
|
||||
const path = await f.put(A);
|
||||
await Promise.all(
|
||||
Array.from({ length: 320 }, (_, n) => {
|
||||
const id = `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`;
|
||||
return f.put(id, { originator: 'codex_cli', instructions: 'x'.repeat(5000) });
|
||||
})
|
||||
);
|
||||
expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path });
|
||||
expect(
|
||||
await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 1048576, maxPrefixBytes: 4096 })
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['workspace', 'metadata'] as const)(
|
||||
'probes %s bounded past the cap before canonical filesystem reads',
|
||||
async (where) => {
|
||||
const { vi } = await import('vitest');
|
||||
const probe = await import('../src/utils/bounded-path-probe.js');
|
||||
const f = await fixture();
|
||||
const alias = join(f.root, 'workspace-alias');
|
||||
await symlink(f.root, alias);
|
||||
await f.put(A, { cwd: where === 'metadata' ? alias : f.root });
|
||||
const blocked = where === 'workspace' ? f.root : alias;
|
||||
const spy = vi
|
||||
.spyOn(probe, 'probePathKind')
|
||||
.mockImplementation(async (path) => (path === blocked ? 'unknown' : 'directory'));
|
||||
try {
|
||||
expect(await findExactCodexConversation(f.options)).toBeUndefined();
|
||||
expect(spy).toHaveBeenCalledWith(blocked, { pastCap: true });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
describe('remembered attribution on a large, never-pruned store', () => {
|
||||
beforeEach(() => __clearCodexAttributionMemo());
|
||||
const OWNED = '33333333-3333-4333-8333-333333333333';
|
||||
const NEWER = '44444444-4444-4444-8444-444444444444';
|
||||
/** codex's own layout: sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl */
|
||||
async function dated(f: Awaited<ReturnType<typeof fixture>>, day: string, id: string, payload: object = {}) {
|
||||
const dir = join(f.home, 'sessions', ...day.split('/'));
|
||||
await mkdir(dir, { recursive: true });
|
||||
const path = join(dir, `rollout-${day.replaceAll('/', '-')}T00-00-00-${id}.jsonl`);
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: { id, cwd: f.root, originator: 'codeman_managed', ...payload },
|
||||
}) + '\n'
|
||||
);
|
||||
return path;
|
||||
}
|
||||
/** `count` unrelated 19 KiB rollouts (a real session_meta size) spread over older days. */
|
||||
async function unrelated(f: Awaited<ReturnType<typeof fixture>>, count: number) {
|
||||
const instructions = 'x'.repeat(19 * 1024);
|
||||
for (let start = 0; start < count; start += 200) {
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(200, count - start) }, (_, k) => {
|
||||
const n = start + k;
|
||||
const day = `2026/${String(1 + (n % 9)).padStart(2, '0')}/${String(1 + (n % 28)).padStart(2, '0')}`;
|
||||
return dated(f, day, `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`, {
|
||||
originator: 'codex_cli',
|
||||
instructions,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
const small = { maxEntries: 20, maxBytes: 1048576 };
|
||||
|
||||
it('attributes one owned rollout beside 2,100 unrelated full-size rollouts', async () => {
|
||||
const f = await fixture();
|
||||
await unrelated(f, 2100);
|
||||
const path = await dated(f, '2026/10/24', OWNED);
|
||||
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
|
||||
}, 60_000);
|
||||
|
||||
it('re-verifies only the remembered file on a later call instead of re-walking the store', async () => {
|
||||
const f = await fixture();
|
||||
await unrelated(f, 300);
|
||||
const path = await dated(f, '2026/10/24', OWNED);
|
||||
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
|
||||
// 20 entries cannot cover 300 rollouts in 9 months of day directories: only the
|
||||
// remembered head plus the directories not older than it fit.
|
||||
expect(await findExactCodexConversation(f.options, small)).toEqual({ id: OWNED, path });
|
||||
__clearCodexAttributionMemo();
|
||||
expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still catches a newer owned rollout (codex /new) after remembering one', async () => {
|
||||
const f = await fixture();
|
||||
await unrelated(f, 50);
|
||||
const path = await dated(f, '2026/10/24', OWNED);
|
||||
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
|
||||
await dated(f, '2026/10/25', NEWER);
|
||||
expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
|
||||
expect(await findExactCodexConversation(f.options)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('answers unattributed, without a full walk, when the remembered file verifies but its incremental scan runs out', async () => {
|
||||
const f = await fixture();
|
||||
const path = await dated(f, '2026/10/24', OWNED);
|
||||
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
|
||||
// 30 rollouts in the remembered day itself: the incremental scan reads them all,
|
||||
// so 20 entries cannot cover it, and a full walk (a superset) could not either.
|
||||
await Promise.all(
|
||||
Array.from({ length: 30 }, (_, n) =>
|
||||
dated(f, '2026/10/24', `20000000-2222-4222-8222-${String(n).padStart(12, '0')}`, { originator: 'codex_cli' })
|
||||
)
|
||||
);
|
||||
expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
|
||||
// The memo was dropped, so a call with room walks the store and attributes again.
|
||||
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
|
||||
});
|
||||
|
||||
it('drops the remembered file when it is replaced, and walks again', async () => {
|
||||
const f = await fixture();
|
||||
const path = await dated(f, '2026/10/24', OWNED);
|
||||
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
|
||||
await rm(path);
|
||||
await dated(f, '2026/10/24', OWNED, { originator: 'codex_cli' });
|
||||
expect(await findExactCodexConversation(f.options)).toBeUndefined();
|
||||
const moved = await dated(f, '2026/10/23', NEWER);
|
||||
expect(await findExactCodexConversation(f.options)).toEqual({ id: NEWER, path: moved });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @fileoverview The synced `agentCoordinationEnabled` setting (default OFF) that gates the
|
||||
* experimental inspect/stage/submit routes: the strict settings schema admits it, App
|
||||
* Settings loads and saves it under the same control id, and its row reads in Chinese
|
||||
* (zh-CN) with no English left beyond the command and product names. The route gate itself
|
||||
* is pinned in test/routes/agent-session-routes.test.ts.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
|
||||
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||
const SETTINGS_UI = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8');
|
||||
const ID = 'appSettingsAgentCoordination';
|
||||
|
||||
type Api = { configure(o: Record<string, string>): void; start(): void };
|
||||
|
||||
/** What may stay Latin: the product, CLI, key and protocol names and the three commands. */
|
||||
const leftover = (text: string) =>
|
||||
text.replace(/\b(Codex|Enter|HTTP|inspect|stage|submit)\b/g, '').match(/[A-Za-z]+/g) ?? [];
|
||||
|
||||
function rowStrings(doc: Document): { label: string; description: string } {
|
||||
const row = doc.getElementById(ID)!.closest('.set-row')!;
|
||||
return {
|
||||
label: row.querySelector('.set-row-label')!.textContent!.trim(),
|
||||
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const source = new JSDOM(INDEX);
|
||||
const english = rowStrings(source.window.document);
|
||||
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
|
||||
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
|
||||
api.start();
|
||||
api.configure({ language: 'zh-CN' });
|
||||
const chinese = rowStrings(dom.window.document);
|
||||
|
||||
afterAll(() => {
|
||||
source.window.close();
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
describe('agentCoordinationEnabled', () => {
|
||||
it('is a boolean the strict settings schema accepts, and nothing else', () => {
|
||||
expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: true }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: false }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: 'yes' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('has one checkbox in App Settings, loaded only from an explicit true and saved by id', () => {
|
||||
expect(INDEX.split(`id="${ID}"`)).toHaveLength(2);
|
||||
expect(source.window.document.getElementById(ID)!.getAttribute('type')).toBe('checkbox');
|
||||
expect(SETTINGS_UI).toContain(
|
||||
`document.getElementById('${ID}').checked = settings.agentCoordinationEnabled === true;`
|
||||
);
|
||||
expect(SETTINGS_UI).toContain(`agentCoordinationEnabled: document.getElementById('${ID}').checked,`);
|
||||
});
|
||||
|
||||
it('sits in the synced Cross-session features group', () => {
|
||||
const group = source.window.document.getElementById(ID)!.closest('.set-group')!;
|
||||
expect(group.querySelector('.set-group-head h4')!.textContent).toBe('Cross-session features');
|
||||
expect(group.querySelector('.set-group-head .set-scope')!.textContent).toBe('synced');
|
||||
});
|
||||
|
||||
it('finds the row (the translation check is not vacuous)', () => {
|
||||
expect(english.label).toBe('Experimental agent coordination');
|
||||
expect(english.description).toMatch(/^Lets agents read another session's output/);
|
||||
});
|
||||
|
||||
it('translates the label and the description, with no English left', () => {
|
||||
for (const key of ['label', 'description'] as const) {
|
||||
expect(chinese[key]).not.toBe(english[key]);
|
||||
expect(leftover(chinese[key])).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('adds each key to the dictionary once', () => {
|
||||
for (const text of [english.label, english.description]) {
|
||||
expect(I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @fileoverview Opt-in agent inbox snapshot (src/web/agent-inbox-persistence.ts).
|
||||
*
|
||||
* Off writes nothing, switching off deletes the file and keeps memory, on writes 0600
|
||||
* through tmp + rename, and a boot with the setting on restores what was pending.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { AgentInbox } from '../src/web/agent-inbox.js';
|
||||
import { AgentInboxPersistence } from '../src/web/agent-inbox-persistence.js';
|
||||
|
||||
describe('AgentInboxPersistence', () => {
|
||||
let dir: string;
|
||||
let file: string;
|
||||
let inbox: AgentInbox;
|
||||
let persistence: AgentInboxPersistence;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codeman-inbox-'));
|
||||
file = join(dir, 'agent-inbox.json');
|
||||
inbox = new AgentInbox();
|
||||
// Debounce 0 so a test can wait one macrotask instead of sleeping.
|
||||
persistence = new AgentInboxPersistence(inbox, () => file, 0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
persistence.dispose();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
it('writes nothing while off (the default)', async () => {
|
||||
persistence.load(false);
|
||||
inbox.post('s', null, 'hello');
|
||||
await tick();
|
||||
expect(existsSync(file)).toBe(false);
|
||||
});
|
||||
|
||||
it('writes a 0600 snapshot when on, and no tmp file is left behind', async () => {
|
||||
persistence.load(true);
|
||||
inbox.post('s', null, 'hello');
|
||||
await tick();
|
||||
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s[0].text).toBe('hello');
|
||||
expect(statSync(file).mode & 0o777).toBe(0o600);
|
||||
expect(existsSync(`${file}.tmp`)).toBe(false);
|
||||
});
|
||||
|
||||
it('switching on writes the current inboxes at once', () => {
|
||||
persistence.load(false);
|
||||
inbox.post('s', null, 'posted while off');
|
||||
persistence.setEnabled(true);
|
||||
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('switching off deletes the snapshot and keeps the in-memory inboxes', async () => {
|
||||
persistence.load(true);
|
||||
inbox.post('s', null, 'hello');
|
||||
await tick();
|
||||
expect(existsSync(file)).toBe(true);
|
||||
persistence.setEnabled(false);
|
||||
expect(existsSync(file)).toBe(false);
|
||||
expect(inbox.pendingCount('s')).toBe(1);
|
||||
inbox.post('s', null, 'after off');
|
||||
await tick();
|
||||
expect(existsSync(file)).toBe(false);
|
||||
});
|
||||
|
||||
it('a boot with the setting on restores pending mail', async () => {
|
||||
persistence.load(true);
|
||||
inbox.post('s', 'sender', 'survives');
|
||||
await tick();
|
||||
persistence.dispose();
|
||||
|
||||
const next = new AgentInbox();
|
||||
const nextPersistence = new AgentInboxPersistence(next, () => file, 0);
|
||||
expect(nextPersistence.load(true)).toBe(1);
|
||||
expect(next.read('s', { receiver: false }).messages[0]).toMatchObject({ from: 'sender', text: 'survives' });
|
||||
nextPersistence.dispose();
|
||||
});
|
||||
|
||||
it('a boot with the setting off removes a file left from earlier and restores nothing', () => {
|
||||
writeFileSync(file, JSON.stringify({ version: 1, inboxes: { s: [] } }));
|
||||
expect(persistence.load(false)).toBe(0);
|
||||
expect(existsSync(file)).toBe(false);
|
||||
});
|
||||
|
||||
it('an unreadable file is an empty inbox, not a failed boot', () => {
|
||||
writeFileSync(file, '{not json');
|
||||
expect(persistence.load(true)).toBe(0);
|
||||
});
|
||||
|
||||
it('flush writes a pending change before shutdown', () => {
|
||||
const slow = new AgentInboxPersistence(inbox, () => file, 60_000);
|
||||
slow.load(true);
|
||||
inbox.post('s', null, 'just before shutdown');
|
||||
expect(existsSync(file)).toBe(false);
|
||||
slow.flush();
|
||||
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s).toHaveLength(1);
|
||||
slow.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @fileoverview How server.ts treats the agent inbox across a session's lifecycle.
|
||||
*
|
||||
* - Delete (`killMux=true`) drops the inbox; detach and shutdown (`killMux=false`)
|
||||
* keep it, because a detached session comes back.
|
||||
* - Boot pruning of orphaned inboxes runs only after a successful restore, and keeps
|
||||
* every session state.json still holds plus every session on the reboot-restore
|
||||
* offer (its record is already pruned, and a rebuild reuses the id). A failed
|
||||
* restore leaves all mail alone.
|
||||
*
|
||||
* Uses a bare `WebServer.prototype` object with just the fields these paths touch,
|
||||
* like tab-layout-restore-gate.test.ts.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { agentInbox } from '../src/web/agent-inbox.js';
|
||||
import { rebootRestoreRegistry } from '../src/web/reboot-restore-registry.js';
|
||||
import type { RebootRestoreEntry } from '../src/reboot-restore.js';
|
||||
import type { SessionState } from '../src/types.js';
|
||||
|
||||
type CleanupServer = {
|
||||
_doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise<void>;
|
||||
finalizeRestoredState(restored: boolean): Promise<void>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function bareServer(live: string[] = [], persisted: string[] = []): CleanupServer {
|
||||
const server = Object.create(WebServer.prototype) as CleanupServer;
|
||||
Object.assign(server, {
|
||||
sessions: new Map(live.map((id) => [id, {}])),
|
||||
keepWarmService: { remove: () => {} },
|
||||
gitFetchScheduler: { remove: () => {} },
|
||||
store: {
|
||||
getSession: () => null,
|
||||
getSessions: () => Object.fromEntries(persisted.map((id) => [id, {}])),
|
||||
removeRalphState: () => {},
|
||||
},
|
||||
tabLayouts: {
|
||||
markRestorationComplete: () => {},
|
||||
markRestorationFailed: () => {},
|
||||
reconcileAfterRestoration: async () => {},
|
||||
},
|
||||
cleanupStaleSessions: async () => 0,
|
||||
_customModelDisplacedNotified: new Set(),
|
||||
respawnControllers: new Map(),
|
||||
respawnTimers: new Map(),
|
||||
pendingRespawnStarts: new Map(),
|
||||
runSummaryTrackers: new Map(),
|
||||
stopTranscriptWatcher: () => {},
|
||||
persistDeb: { cancelKey: () => {} },
|
||||
sse: { cleanupSessionBatches: () => {} },
|
||||
// A real delete also tells the Discord bridge, which frees its per-tab state and may archive the thread.
|
||||
discordBridge: { sessionDeleted: async () => {}, forgetTab: () => {} },
|
||||
broadcast: () => {},
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
/** A reboot-restore offer entry; the prune reads only its `sessionId`. */
|
||||
function offerEntry(sessionId: string): RebootRestoreEntry {
|
||||
return {
|
||||
sessionId,
|
||||
workingDir: `/tmp/codeman-agent-inbox-offer/${sessionId}`,
|
||||
mode: 'claude',
|
||||
resumeConversationId: `conv-${sessionId}`,
|
||||
state: { id: sessionId, workingDir: `/tmp/codeman-agent-inbox-offer/${sessionId}` } as SessionState,
|
||||
};
|
||||
}
|
||||
|
||||
describe('agent inbox server wiring', () => {
|
||||
beforeEach(() => agentInbox.resetForTests());
|
||||
afterEach(() => {
|
||||
agentInbox.resetForTests();
|
||||
rebootRestoreRegistry.reset();
|
||||
});
|
||||
|
||||
it('a delete drops the inbox; a detach (killMux=false) keeps it', async () => {
|
||||
const server = bareServer();
|
||||
agentInbox.post('detached', null, 'still here after detach');
|
||||
agentInbox.post('deleted', null, 'gone with the session');
|
||||
await server._doCleanupSession('detached', false, 'test');
|
||||
await server._doCleanupSession('deleted', true, 'test');
|
||||
expect(agentInbox.pendingCount('detached')).toBe(1);
|
||||
expect(agentInbox.pendingCount('deleted')).toBe(0);
|
||||
});
|
||||
|
||||
it('a failed restore prunes nothing', async () => {
|
||||
const server = bareServer();
|
||||
agentInbox.post('not-restored', null, 'm');
|
||||
await server.finalizeRestoredState(false);
|
||||
expect(agentInbox.pendingCount('not-restored')).toBe(1);
|
||||
});
|
||||
|
||||
it('a successful restore prunes orphans and keeps live and persisted sessions', async () => {
|
||||
const server = bareServer(['live'], ['kept-for-recovery']);
|
||||
for (const id of ['live', 'kept-for-recovery', 'orphan']) agentInbox.post(id, null, 'm');
|
||||
await server.finalizeRestoredState(true);
|
||||
expect(agentInbox.pendingCount('live')).toBe(1);
|
||||
expect(agentInbox.pendingCount('kept-for-recovery')).toBe(1);
|
||||
expect(agentInbox.pendingCount('orphan')).toBe(0);
|
||||
});
|
||||
|
||||
it('a successful restore keeps the inbox of a session on the reboot-restore offer', async () => {
|
||||
// After a host reboot the dead session is in neither `this.sessions` nor state.json
|
||||
// (cleanupStaleSessions already pruned its record), only in the offer, and the
|
||||
// rebuild reuses its id, so its mail must still be there when it comes back.
|
||||
const server = bareServer();
|
||||
rebootRestoreRegistry.set([offerEntry('on-offer')]);
|
||||
for (const id of ['on-offer', 'orphan']) agentInbox.post(id, null, 'm');
|
||||
await server.finalizeRestoredState(true);
|
||||
expect(rebootRestoreRegistry.list(() => true).map((e) => e.sessionId)).toEqual(['on-offer']);
|
||||
expect(agentInbox.pendingCount('on-offer')).toBe(1);
|
||||
expect(agentInbox.pendingCount('orphan')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* @fileoverview The agent inbox store (src/web/agent-inbox.ts), pure: no routes, no IO.
|
||||
*
|
||||
* Each block pins one rule from discussion #558: read until ack, seen only for the
|
||||
* receiver, byte and count caps that refuse instead of dropping, a TTL counted from
|
||||
* each message's own timestamp, and a snapshot that survives malformed input.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
AgentInbox,
|
||||
MAX_INBOX_BYTES,
|
||||
MAX_MESSAGE_BYTES,
|
||||
MAX_MESSAGES_PER_INBOX,
|
||||
MESSAGE_TTL_MS,
|
||||
} from '../src/web/agent-inbox.js';
|
||||
|
||||
const RX = 'receiver-session';
|
||||
|
||||
describe('AgentInbox', () => {
|
||||
let now: number;
|
||||
let inbox: AgentInbox;
|
||||
|
||||
beforeEach(() => {
|
||||
now = 1_000_000;
|
||||
inbox = new AgentInbox(() => now);
|
||||
});
|
||||
|
||||
const post = (text: string, from: string | null = 'sender') => {
|
||||
const r = inbox.post(RX, from, text);
|
||||
if (!r.ok) throw new Error(`post refused: ${r.reason}`);
|
||||
return r.message;
|
||||
};
|
||||
|
||||
describe('read until ack', () => {
|
||||
it('a read removes nothing; the same messages come back until acked', () => {
|
||||
const a = post('first');
|
||||
const b = post('second');
|
||||
expect(inbox.read(RX, { receiver: true }).messages.map((m) => m.id)).toEqual([a.id, b.id]);
|
||||
expect(inbox.read(RX, { receiver: true }).messages.map((m) => m.id)).toEqual([a.id, b.id]);
|
||||
expect(inbox.pendingCount(RX)).toBe(2);
|
||||
expect(inbox.ack(RX, [a.id])).toBe(1);
|
||||
expect(inbox.read(RX, { receiver: true }).messages.map((m) => m.id)).toEqual([b.id]);
|
||||
});
|
||||
|
||||
it('ack is idempotent: an id that is already gone or never existed is not an error', () => {
|
||||
const a = post('x');
|
||||
expect(inbox.ack(RX, [a.id])).toBe(1);
|
||||
expect(inbox.ack(RX, [a.id])).toBe(0);
|
||||
expect(inbox.ack(RX, ['no-such-id'])).toBe(0);
|
||||
expect(inbox.ack('no-such-session', [a.id])).toBe(0);
|
||||
});
|
||||
|
||||
it('ack removes only the given ids, never a message posted after the read', () => {
|
||||
const a = post('read me');
|
||||
inbox.read(RX, { receiver: true });
|
||||
const late = post('arrived after the read');
|
||||
inbox.ack(RX, [a.id]);
|
||||
expect(inbox.read(RX, { receiver: false }).messages.map((m) => m.id)).toEqual([late.id]);
|
||||
});
|
||||
|
||||
it('keeps multi-line text byte for byte', () => {
|
||||
const text = 'line one\nline two\r\n\ttabbed\n';
|
||||
post(text);
|
||||
expect(inbox.read(RX, { receiver: true }).messages[0].text).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seen belongs to the receiver', () => {
|
||||
it('a receiver read marks seen; the flag on each message is the state BEFORE this read', () => {
|
||||
post('m');
|
||||
const first = inbox.read(RX, { receiver: true });
|
||||
expect(first.messages[0].seen).toBe(false);
|
||||
expect(first.unseen).toBe(0);
|
||||
const second = inbox.read(RX, { receiver: true });
|
||||
expect(second.messages[0].seen).toBe(true);
|
||||
});
|
||||
|
||||
it('a peek changes nothing: unseen stays unseen', () => {
|
||||
post('m');
|
||||
const peek = inbox.read(RX, { receiver: false });
|
||||
expect(peek.messages[0].seen).toBe(false);
|
||||
expect(peek.unseen).toBe(1);
|
||||
expect(inbox.unseenCount(RX)).toBe(1);
|
||||
expect(inbox.summary().get(RX)).toMatchObject({ pending: 1, unseen: 1 });
|
||||
});
|
||||
|
||||
it('a message posted after a receiver read is unseen again', () => {
|
||||
post('a');
|
||||
inbox.read(RX, { receiver: true });
|
||||
expect(inbox.unseenCount(RX)).toBe(0);
|
||||
post('b');
|
||||
expect(inbox.unseenCount(RX)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('caps refuse, never drop the oldest', () => {
|
||||
it(`refuses message ${MAX_MESSAGES_PER_INBOX + 1} and keeps the first one`, () => {
|
||||
const first = post('0');
|
||||
for (let i = 1; i < MAX_MESSAGES_PER_INBOX; i++) post(String(i));
|
||||
expect(inbox.post(RX, null, 'one too many')).toEqual({ ok: false, reason: 'inbox-full-count' });
|
||||
const ids = inbox.read(RX, { receiver: false }).messages.map((m) => m.id);
|
||||
expect(ids).toHaveLength(MAX_MESSAGES_PER_INBOX);
|
||||
expect(ids[0]).toBe(first.id);
|
||||
});
|
||||
|
||||
it('counts the per-message cap in UTF-8 bytes, not characters', () => {
|
||||
// 'é' is 2 bytes: half the byte budget in characters already fills it.
|
||||
expect(inbox.post(RX, null, 'é'.repeat(MAX_MESSAGE_BYTES / 2)).ok).toBe(true);
|
||||
expect(inbox.post(RX, null, 'é'.repeat(MAX_MESSAGE_BYTES / 2 + 1))).toEqual({
|
||||
ok: false,
|
||||
reason: 'message-too-large',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a post that would cross the per-inbox byte cap', () => {
|
||||
const chunk = 'x'.repeat(MAX_MESSAGE_BYTES);
|
||||
const fit = Math.floor(MAX_INBOX_BYTES / MAX_MESSAGE_BYTES);
|
||||
for (let i = 0; i < fit; i++) post(chunk);
|
||||
expect(inbox.post(RX, null, 'x'.repeat(MAX_INBOX_BYTES - fit * MAX_MESSAGE_BYTES + 1))).toEqual({
|
||||
ok: false,
|
||||
reason: 'inbox-full-bytes',
|
||||
});
|
||||
expect(inbox.pendingCount(RX)).toBe(fit);
|
||||
});
|
||||
|
||||
it('an ack makes room again', () => {
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < MAX_MESSAGES_PER_INBOX; i++) ids.push(post(String(i)).id);
|
||||
inbox.ack(RX, [ids[0]]);
|
||||
expect(inbox.post(RX, null, 'now it fits').ok).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses empty text', () => {
|
||||
expect(inbox.post(RX, null, '')).toEqual({ ok: false, reason: 'empty' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('TTL from the message timestamp', () => {
|
||||
it('a message older than the TTL is gone, read or not', () => {
|
||||
post('old');
|
||||
now += MESSAGE_TTL_MS / 2;
|
||||
const fresh = post('fresh');
|
||||
now += MESSAGE_TTL_MS / 2 + 1;
|
||||
expect(inbox.read(RX, { receiver: false }).messages.map((m) => m.id)).toEqual([fresh.id]);
|
||||
});
|
||||
|
||||
it('a restore keeps createdAt, so a restart does not restart the clock', () => {
|
||||
post('m');
|
||||
const snap = JSON.parse(JSON.stringify(inbox.snapshot()));
|
||||
now += MESSAGE_TTL_MS - 1000;
|
||||
const restarted = new AgentInbox(() => now);
|
||||
expect(restarted.restore(snap)).toBe(1);
|
||||
now += 1001;
|
||||
expect(restarted.pendingCount(RX)).toBe(0);
|
||||
});
|
||||
|
||||
it('does not restore a message that expired while the server was down', () => {
|
||||
post('m');
|
||||
const snap = inbox.snapshot();
|
||||
now += MESSAGE_TTL_MS + 1;
|
||||
expect(new AgentInbox(() => now).restore(snap)).toBe(0);
|
||||
});
|
||||
|
||||
it('sweeps an expired message that is not first (clock stepped back)', () => {
|
||||
const late = post('posted first, stamped later');
|
||||
now -= 1000; // the clock steps back
|
||||
const early = post('posted second, stamped earlier');
|
||||
now += MESSAGE_TTL_MS + 1000 - (late.createdAt - early.createdAt) / 2;
|
||||
// `early` is past the TTL, `late` is not, and `early` sits behind `late`.
|
||||
expect(inbox.read(RX, { receiver: false }).messages.map((m) => m.id)).toEqual([late.id]);
|
||||
});
|
||||
|
||||
it('expiry fires onChange so the snapshot catches up', () => {
|
||||
post('m');
|
||||
let changes = 0;
|
||||
inbox.onChange = () => changes++;
|
||||
now += MESSAGE_TTL_MS + 1;
|
||||
inbox.summary();
|
||||
expect(changes).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summary', () => {
|
||||
it('reports counts and the oldest unseen time, never text', () => {
|
||||
const a = post('secret one');
|
||||
now += 10;
|
||||
post('secret two');
|
||||
inbox.read(RX, { receiver: true });
|
||||
now += 10;
|
||||
const c = post('secret three');
|
||||
const row = inbox.summary().get(RX);
|
||||
expect(row).toEqual({ pending: 3, unseen: 1, oldestUnseenAt: c.createdAt });
|
||||
expect(JSON.stringify(row)).not.toContain('secret');
|
||||
expect(a.createdAt).toBeLessThan(c.createdAt);
|
||||
});
|
||||
|
||||
it('leaves out sessions with an empty inbox', () => {
|
||||
const a = post('m');
|
||||
inbox.ack(RX, [a.id]);
|
||||
expect(inbox.summary().has(RX)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('drop discards the inbox and fires onChange', () => {
|
||||
post('m');
|
||||
let changes = 0;
|
||||
inbox.onChange = () => changes++;
|
||||
inbox.drop(RX);
|
||||
expect(inbox.pendingCount(RX)).toBe(0);
|
||||
expect(changes).toBe(1);
|
||||
});
|
||||
|
||||
it('pruneExcept drops only inboxes of sessions not kept', () => {
|
||||
inbox.post('keep-me', null, 'a');
|
||||
inbox.post('gone', null, 'b');
|
||||
expect(inbox.pruneExcept(['keep-me'])).toBe(1);
|
||||
expect(inbox.pendingCount('keep-me')).toBe(1);
|
||||
expect(inbox.pendingCount('gone')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapshot / restore', () => {
|
||||
it('round-trips messages, senders and the seen state', () => {
|
||||
const a = post('from a session', 'sender-1');
|
||||
post('from the user', null);
|
||||
inbox.read(RX, { receiver: true });
|
||||
const c = post('unseen');
|
||||
const restored = new AgentInbox(() => now);
|
||||
expect(restored.restore(JSON.parse(JSON.stringify(inbox.snapshot())))).toBe(3);
|
||||
const read = restored.read(RX, { receiver: false });
|
||||
expect(read.messages.map((m) => [m.id, m.from, m.seen])).toEqual([
|
||||
[a.id, 'sender-1', true],
|
||||
[expect.any(String), null, true],
|
||||
[c.id, 'sender', false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips malformed entries and files instead of throwing', () => {
|
||||
const restored = new AgentInbox(() => now);
|
||||
expect(restored.restore(null)).toBe(0);
|
||||
expect(restored.restore({ version: 2, inboxes: {} })).toBe(0);
|
||||
expect(restored.restore({ version: 1, inboxes: { s: 'nope' } })).toBe(0);
|
||||
const good = { id: 'g', from: null, text: 'ok', createdAt: now, seenAt: null };
|
||||
expect(
|
||||
restored.restore({
|
||||
version: 1,
|
||||
inboxes: {
|
||||
s: [
|
||||
good,
|
||||
{ id: 1 },
|
||||
{ ...good, id: 'big', text: 'x'.repeat(MAX_MESSAGE_BYTES + 1) },
|
||||
{ ...good, text: '' },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('restores oldest first whatever the file order, so the caps keep the oldest', () => {
|
||||
const msg = (id: string, createdAt: number) => ({ id, from: null, text: 'x', createdAt, seenAt: null });
|
||||
const restored = new AgentInbox(() => now);
|
||||
restored.restore({ version: 1, inboxes: { s: [msg('new', now - 10), msg('old', now - 20)] } });
|
||||
expect(restored.read('s', { receiver: false }).messages.map((m) => m.id)).toEqual(['old', 'new']);
|
||||
});
|
||||
|
||||
it('holds the count cap on restore', () => {
|
||||
const many = Array.from({ length: MAX_MESSAGES_PER_INBOX + 5 }, (_, i) => ({
|
||||
id: `m${i}`,
|
||||
from: null,
|
||||
text: 'x',
|
||||
createdAt: now,
|
||||
seenAt: null,
|
||||
}));
|
||||
const restored = new AgentInbox(() => now);
|
||||
expect(restored.restore({ version: 1, inboxes: { s: many } })).toBe(MAX_MESSAGES_PER_INBOX);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/** @fileoverview Bounded JSONL inspection: cursors, byte budgets and file generations. */
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest';
|
||||
import { mkdtemp, writeFile, appendFile, rename, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { INSPECT_CURSORS_PER_SCOPE, InspectionReader } from '../src/agent-inspection.js';
|
||||
let dir: string;
|
||||
let file: string;
|
||||
let reader: InspectionReader;
|
||||
const scope = { owner: 'alice', sessionId: 's', generation: 'pane-1', conversationId: 'thread-1' };
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'inspect-'));
|
||||
file = join(dir, 'rollout.jsonl');
|
||||
reader = new InspectionReader();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
it('returns complete UTF8 records within the byte budget and resumes exactly', async () => {
|
||||
const row = JSON.stringify({ text: 'é😀' }) + '\n';
|
||||
await writeFile(file, row + row);
|
||||
const first = await reader.read(file, scope, Buffer.byteLength(row));
|
||||
expect(first.evidence).toBe(row);
|
||||
expect(first.truncated).toBe(true);
|
||||
await appendFile(file, row);
|
||||
const next = await reader.read(file, scope, 1024, first.cursor);
|
||||
expect(next.evidence).toBe(row);
|
||||
expect(next.truncated).toBe(false);
|
||||
});
|
||||
it('does not consume partial final records', async () => {
|
||||
await writeFile(file, '{"text":"hello');
|
||||
const first = await reader.read(file, scope, 100);
|
||||
expect(first.evidence).toBe('');
|
||||
await appendFile(file, '"}\n');
|
||||
const next = await reader.read(file, scope, 100, first.cursor);
|
||||
expect(next.evidence).toBe('{"text":"hello"}\n');
|
||||
});
|
||||
it('reports an oversized incremental record without splitting it or advancing past it', async () => {
|
||||
await writeFile(file, '{}\n');
|
||||
const initial = await reader.read(file, scope, 100);
|
||||
await appendFile(file, JSON.stringify({ text: 'x'.repeat(300000) }) + '\n');
|
||||
const result = await reader.read(file, scope, 100, initial.cursor);
|
||||
expect(result.evidence).toBe('');
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.blockedReason).toBe('record-exceeds-scan-limit');
|
||||
expect(result.scannedBytes).toBeLessThanOrEqual(262144);
|
||||
});
|
||||
it('rejects malformed, cross-owner and changed-session cursors', async () => {
|
||||
await writeFile(file, '{}\n');
|
||||
const first = await reader.read(file, scope, 100);
|
||||
for (const changed of [
|
||||
{ ...scope, owner: 'bob' },
|
||||
{ ...scope, sessionId: 'other' },
|
||||
{ ...scope, generation: 'new' },
|
||||
{ ...scope, conversationId: 'new' },
|
||||
]) {
|
||||
await expect(reader.read(file, changed, 100, first.cursor)).rejects.toThrow(/cursor/i);
|
||||
}
|
||||
await expect(reader.read(file, scope, 100, 'garbage')).rejects.toThrow(/cursor/i);
|
||||
});
|
||||
it('rejects truncation, replacement and rewritten consumed evidence', async () => {
|
||||
await writeFile(file, '{}\n{}\n');
|
||||
const first = await reader.read(file, scope, 100);
|
||||
await writeFile(file, '{}\n');
|
||||
await expect(reader.read(file, scope, 100, first.cursor)).rejects.toThrow(/cursor/i);
|
||||
await writeFile(file, '{}\n{}\n');
|
||||
const second = await reader.read(file, scope, 100);
|
||||
await writeFile(file, '[]\n[]\n');
|
||||
await expect(reader.read(file, scope, 100, second.cursor)).rejects.toThrow(/cursor/i);
|
||||
const third = await reader.read(file, scope, 100);
|
||||
await rename(file, join(dir, 'old'));
|
||||
await writeFile(file, '[]\n[]\n');
|
||||
await expect(reader.read(file, scope, 100, third.cursor)).rejects.toThrow(/cursor/i);
|
||||
});
|
||||
it('expires cursors and bounds retained state', async () => {
|
||||
await writeFile(file, '{}\n');
|
||||
let now = 0;
|
||||
reader = new InspectionReader(() => now, 2, 1000);
|
||||
const first = await reader.read(file, scope, 100);
|
||||
await reader.read(file, scope, 100);
|
||||
await reader.read(file, scope, 100);
|
||||
await expect(reader.read(file, scope, 100, first.cursor)).rejects.toThrow(/cursor/i);
|
||||
const last = await reader.read(file, scope, 100);
|
||||
now = 1001;
|
||||
await expect(reader.read(file, scope, 100, last.cursor)).rejects.toThrow(/cursor/i);
|
||||
});
|
||||
it('bounds strict attribution itself and refuses an incomplete inventory', async () => {
|
||||
const { mkdir } = await import('node:fs/promises');
|
||||
const { findExactCodexConversation } = await import('../src/agent-codex-identity.js');
|
||||
const home = join(dir, 'codex');
|
||||
const sessions = join(home, 'sessions');
|
||||
await mkdir(sessions, { recursive: true });
|
||||
const id = '11111111-1111-4111-8111-111111111111';
|
||||
await writeFile(
|
||||
join(sessions, `rollout-${id}.jsonl`),
|
||||
JSON.stringify({ type: 'session_meta', payload: { id, cwd: dir, originator: 'codeman_s' } }) + '\n'
|
||||
);
|
||||
await writeFile(join(sessions, 'other.txt'), 'other');
|
||||
const options = { codexHome: home, sessionId: 's', workingDir: dir };
|
||||
expect((await findExactCodexConversation(options))?.id).toBe(id);
|
||||
expect(await findExactCodexConversation(options, { maxEntries: 1, maxBytes: 16384 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('starts at recent complete records past oversized bootstrap evidence, labeling all omission', async () => {
|
||||
const old = JSON.stringify({ system: 'x'.repeat(300000) }) + '\n';
|
||||
const latest = JSON.stringify({ assistant: 'latest 😀' }) + '\n';
|
||||
await writeFile(file, old + latest);
|
||||
const result = await reader.read(file, scope, Buffer.byteLength(latest));
|
||||
expect(result.evidence).toBe(latest);
|
||||
expect(result.omittedBeforeBytes).toBe(Buffer.byteLength(old));
|
||||
expect(result.truncated).toBe(true);
|
||||
await appendFile(file, '{"assistant":"next"}\n');
|
||||
expect((await reader.read(file, scope, 100, result.cursor)).evidence).toBe('{"assistant":"next"}\n');
|
||||
});
|
||||
it('attributes a real-sized session_meta row rather than requiring a tiny header', async () => {
|
||||
const { mkdir } = await import('node:fs/promises');
|
||||
const { findExactCodexConversation } = await import('../src/agent-codex-identity.js');
|
||||
const home = join(dir, 'codex');
|
||||
await mkdir(join(home, 'sessions'), { recursive: true });
|
||||
const id = '11111111-1111-4111-8111-111111111111';
|
||||
await writeFile(
|
||||
join(home, 'sessions', `rollout-${id}.jsonl`),
|
||||
JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: { id, cwd: dir, originator: 'codeman_s', instructions: 'x'.repeat(20000) },
|
||||
}) + '\n'
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await findExactCodexConversation(
|
||||
{ codexHome: home, sessionId: 's', workingDir: dir },
|
||||
{ maxEntries: 8192, maxBytes: 1048576 }
|
||||
)
|
||||
)?.id
|
||||
).toBe(id);
|
||||
});
|
||||
it('rejects same-size rewrites outside the bounded cursor anchors', async () => {
|
||||
const { utimes } = await import('node:fs/promises');
|
||||
const row = JSON.stringify({ text: 'a'.repeat(20000) }) + '\n';
|
||||
await writeFile(file, row);
|
||||
await utimes(file, 1, 1);
|
||||
const first = await reader.read(file, scope, 30000);
|
||||
await writeFile(file, row.slice(0, 10000) + 'b' + row.slice(10001));
|
||||
await expect(reader.read(file, scope, 30000, first.cursor)).rejects.toThrow(/cursor/i);
|
||||
});
|
||||
it('keeps cursor capacity bounded across concurrent reads', async () => {
|
||||
await writeFile(file, '{}\n');
|
||||
reader = new InspectionReader(Date.now, 2);
|
||||
const results = await Promise.all(Array.from({ length: 16 }, () => reader.read(file, scope, 100)));
|
||||
const resumed = await Promise.allSettled(results.map((result) => reader.read(file, scope, 100, result.cursor)));
|
||||
expect(resumed.filter((result) => result.status === 'fulfilled').length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
it('caps cursors per owner and session, so one polling scope cannot evict another', async () => {
|
||||
await writeFile(file, '{}\n');
|
||||
const other = { ...scope, owner: 'bob', sessionId: 't' };
|
||||
const bobs = await reader.read(file, other, 100);
|
||||
// Alice polls every second for well past the old global FIFO of 1,024.
|
||||
let last = await reader.read(file, scope, 100);
|
||||
for (let i = 0; i < 1100; i++) last = await reader.read(file, scope, 100, last.cursor);
|
||||
// Bob's cursor survives, and so does a retry of Alice's last call.
|
||||
await expect(reader.read(file, other, 100, bobs.cursor)).resolves.toMatchObject({ evidence: '' });
|
||||
await expect(reader.read(file, scope, 100, last.cursor)).resolves.toMatchObject({ evidence: '' });
|
||||
// Alice keeps only her newest cursors: the oldest of one more than the cap is retired.
|
||||
const issued: string[] = [];
|
||||
for (let i = 0; i <= INSPECT_CURSORS_PER_SCOPE; i++) issued.push((await reader.read(file, scope, 100)).cursor);
|
||||
await expect(reader.read(file, scope, 100, issued[0])).rejects.toThrow(/cursor/i);
|
||||
await expect(reader.read(file, scope, 100, issued[issued.length - 1])).resolves.toBeDefined();
|
||||
// The cap counts owner and session: a new pane generation of Alice's session shares
|
||||
// it, Bob's session does not.
|
||||
const nextPane = { ...scope, generation: 'pane-2' };
|
||||
for (let i = 0; i < INSPECT_CURSORS_PER_SCOPE; i++) await reader.read(file, nextPane, 100);
|
||||
await expect(reader.read(file, scope, 100, issued[issued.length - 1])).rejects.toThrow(/cursor/i);
|
||||
await expect(reader.read(file, other, 100, bobs.cursor)).resolves.toBeDefined();
|
||||
});
|
||||
it('keeps an incomplete UTF8 sequence pending until its JSONL record is complete', async () => {
|
||||
const row = Buffer.from('{"text":"😀"}\n');
|
||||
const split = row.indexOf(Buffer.from('😀')) + 2;
|
||||
await writeFile(file, row.subarray(0, split));
|
||||
const first = await reader.read(file, scope, 100);
|
||||
expect(first.evidence).toBe('');
|
||||
await appendFile(file, row.subarray(split));
|
||||
expect((await reader.read(file, scope, 100, first.cursor)).evidence).toBe(row.toString('utf8'));
|
||||
});
|
||||
|
||||
it('treats a full incremental page as pageable rather than a blocked oversized record', async () => {
|
||||
await writeFile(file, '{}\n');
|
||||
const initial = await reader.read(file, scope, 100);
|
||||
await appendFile(file, '{}\n{}\n');
|
||||
const page = await reader.read(file, scope, 3, initial.cursor);
|
||||
expect(page.evidence).toBe('{}\n');
|
||||
expect(page.truncated).toBe(true);
|
||||
expect(page.blockedReason).toBeUndefined();
|
||||
const next = await reader.read(file, scope, 3, page.cursor);
|
||||
expect(next.evidence).toBe('{}\n');
|
||||
});
|
||||
|
||||
it('still labels the first selected oversized tail record after omitted older bytes', async () => {
|
||||
await writeFile(file, '{}\n' + JSON.stringify({ text: 'x'.repeat(100) }) + '\n');
|
||||
const page = await reader.read(file, scope, 10);
|
||||
expect(page.evidence).toBe('');
|
||||
expect(page.blockedReason).toBe('record-exceeds-output-budget');
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @fileoverview `sendwait` in the packaged agent preamble sends one line.
|
||||
*
|
||||
* Runs the REAL `skills/codeman/preamble.sh` in bash with `CURL` replaced by a stub
|
||||
* that records the request body, so it checks what the function puts on the wire,
|
||||
* not a copy of its logic. The server pastes a multi-line prompt as one block, and
|
||||
* neither sendwait's Enter loop nor the server's submit check can recognise a pasted
|
||||
* block still sitting on the composer, so the prompt must arrive as one line, the
|
||||
* way `toSingleLine` joins it for the other unattended senders.
|
||||
*
|
||||
* Port: N/A (no server; the stub answers every request).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { toSingleLine } from '../src/utils/single-line.js';
|
||||
|
||||
const PREAMBLE = join(__dirname, '..', 'skills', 'codeman', 'preamble.sh');
|
||||
|
||||
// The preamble builds every request body with jq.
|
||||
let hasJq = true;
|
||||
try {
|
||||
execFileSync('jq', ['--version'], { stdio: 'ignore' });
|
||||
} catch {
|
||||
hasJq = false;
|
||||
}
|
||||
|
||||
/** The `input` field of the first body sendwait posts for `prompt`. */
|
||||
function sentInput(prompt: string): string {
|
||||
// sendwait calls CURL inside $(...), a subshell, so the stub records to a file.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-sendwait-'));
|
||||
const bodies = join(dir, 'bodies');
|
||||
const script = `
|
||||
. "$PREAMBLE" >/dev/null
|
||||
stub() {
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = --data-binary ]; then printf '%s\\n' "$2" >> "$BODIES"; shift; fi
|
||||
shift
|
||||
done
|
||||
printf '%s' '{"data":{"delivered":true,"wait":{"timedOut":false}}}'
|
||||
}
|
||||
CURL=(stub)
|
||||
sendwait worker1234 "$PROMPT" 7 >/dev/null
|
||||
`;
|
||||
try {
|
||||
execFileSync('bash', ['-c', script], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: process.env.HOME,
|
||||
PREAMBLE,
|
||||
PROMPT: prompt,
|
||||
BODIES: bodies,
|
||||
CODEMAN_API_URL: 'http://127.0.0.1:9',
|
||||
CODEMAN_SESSION_ID: 'caller-session-0000',
|
||||
},
|
||||
});
|
||||
const first = readFileSync(bodies, 'utf-8').split('\n')[0];
|
||||
return (JSON.parse(first) as { input: string }).input;
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!hasJq)('preamble sendwait', () => {
|
||||
it('joins a multi-line prompt onto one line before sending it', () => {
|
||||
expect(sentInput('Review this:\n- item 1\n- item 2')).toBe('Review this: - item 1 - item 2\r');
|
||||
});
|
||||
|
||||
it('joins the same way as toSingleLine (trimmed lines, blank lines dropped, \\r\\n as a break)', () => {
|
||||
const prompt = ' first line \r\n\n\t second\n\n \nthird\n';
|
||||
expect(sentInput(prompt)).toBe(toSingleLine(prompt) + '\r');
|
||||
});
|
||||
|
||||
it('refuses a prompt made only of blank lines instead of sending a bare Enter', () => {
|
||||
expect(() => sentInput(' \n \t\n')).toThrow(/sendwait: empty prompt/);
|
||||
});
|
||||
|
||||
it('leaves a single-line prompt, glob characters included, as it was', () => {
|
||||
expect(sentInput('fix * in ?.ts ')).toBe('fix * in ?.ts \r');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* @fileoverview Agent skill per CLI: which workspace directory each CLI's sessions get
|
||||
* the one packaged skill in (`capabilities.agentSkillDir`), and the
|
||||
* `codeman skill install|uninstall` resolution that follows the registry.
|
||||
*
|
||||
* The skill is read from the REAL packaged source (`skills/codeman/`), so this also
|
||||
* guards that it meets the Agent Skills format every CLI (and the `npx skills`
|
||||
* installer) parses: a `name` equal to its directory, lowercase with hyphens, and a
|
||||
* description of at most 1024 characters.
|
||||
*
|
||||
* Pure filesystem and registry tests in a per-test temp dir. Port: N/A.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, readFile, readdir } from 'node:fs/promises';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { agentSkillDir, agentSkillDirFor, applyAgentSkill, CLAUDE_AGENT_SKILL_DIR } from '../src/hooks-config.js';
|
||||
import { getCli, listClis } from '../src/config/cli-registry/registry.js';
|
||||
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
|
||||
import { registryAgentSkillDirs, resolveSkillInstallDir } from '../src/cli.js';
|
||||
|
||||
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
|
||||
const AGENTS_DIR = '.agents/skills' as const;
|
||||
|
||||
/** The frontmatter fields the Agent Skills format defines, with a `>-` folded description joined. */
|
||||
function frontmatter(skillMd: string): { name?: string; description?: string } {
|
||||
const match = /^---\n([\s\S]*?)\n---\n/.exec(skillMd);
|
||||
if (!match) return {};
|
||||
const lines = match[1].split('\n');
|
||||
const out: { name?: string; description?: string } = {};
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const name = /^name:\s*(.+)$/.exec(lines[i]);
|
||||
if (name) out.name = name[1].trim();
|
||||
const desc = /^description:\s*(.*)$/.exec(lines[i]);
|
||||
if (desc) {
|
||||
if (desc[1].trim() === '>-' || desc[1].trim() === '>') {
|
||||
const folded: string[] = [];
|
||||
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) folded.push(lines[++i].trim());
|
||||
out.description = folded.join(' ');
|
||||
} else {
|
||||
out.description = desc[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let casePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
casePath = await mkdtemp(join(tmpdir(), 'codeman-agent-skill-dirs-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(casePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('the packaged skill meets the Agent Skills format', () => {
|
||||
it('skills/codeman/SKILL.md: name equals the directory, description within limits', () => {
|
||||
const fm = frontmatter(readFileSync(join(process.cwd(), 'skills', 'codeman', 'SKILL.md'), 'utf-8'));
|
||||
expect(fm.name).toBe('codeman');
|
||||
expect(fm.name).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
||||
expect(fm.description, 'description').toBeTruthy();
|
||||
expect(fm.description!.length).toBeLessThanOrEqual(1024);
|
||||
// It refuses outside Codeman, and says so where every CLI reads first.
|
||||
expect(fm.description).toContain('CODEMAN_MUX=1');
|
||||
});
|
||||
|
||||
it('is the only packaged skill (one skill for every CLI)', async () => {
|
||||
expect(await readdir(join(process.cwd(), 'skills'))).toEqual(['codeman']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyAgentSkill with a directory', () => {
|
||||
it('installs into <case>/.agents/skills/codeman, marker appended, and nothing under .claude', async () => {
|
||||
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('installed');
|
||||
const skillDir = join(casePath, '.agents', 'skills', 'codeman');
|
||||
const skillMd = await readFile(join(skillDir, 'SKILL.md'), 'utf-8');
|
||||
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
|
||||
expect(skillMd).toContain(MARKER_PREFIX);
|
||||
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
|
||||
expect(existsSync(join(casePath, '.claude'))).toBe(false);
|
||||
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('removes it again and prunes the emptied skills dir, never the CLI dir above it', async () => {
|
||||
await applyAgentSkill(casePath, true, AGENTS_DIR);
|
||||
expect(await applyAgentSkill(casePath, false, AGENTS_DIR)).toBe('removed');
|
||||
expect(existsSync(join(casePath, '.agents', 'skills'))).toBe(false);
|
||||
expect(existsSync(join(casePath, '.agents'))).toBe(true);
|
||||
});
|
||||
|
||||
it('two CLIs sharing one workspace each get an identical copy where they look', async () => {
|
||||
await applyAgentSkill(casePath, true, CLAUDE_AGENT_SKILL_DIR);
|
||||
await applyAgentSkill(casePath, true, AGENTS_DIR);
|
||||
const claudeCopy = await readFile(join(casePath, '.claude', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
|
||||
const agentsCopy = await readFile(join(casePath, '.agents', 'skills', 'codeman', 'SKILL.md'), 'utf-8');
|
||||
expect(agentsCopy).toBe(claudeCopy);
|
||||
});
|
||||
|
||||
it("the default directory is still Claude Code's", async () => {
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('installed');
|
||||
expect(existsSync(agentSkillDirFor(casePath, CLAUDE_AGENT_SKILL_DIR))).toBe(true);
|
||||
});
|
||||
|
||||
it('writes nothing outside the target directory', async () => {
|
||||
await mkdir(join(casePath, 'keep'), { recursive: true });
|
||||
await applyAgentSkill(casePath, true, AGENTS_DIR);
|
||||
expect((await readdir(casePath)).sort()).toEqual(['.agents', 'keep']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agentSkillDir', () => {
|
||||
it('is null when the CLI takes no skill', () => {
|
||||
expect(agentSkillDir({ agentSkillInjection: false, agentSkillDir: AGENTS_DIR })).toBeNull();
|
||||
expect(agentSkillDir(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to Claude Code's directory when only the old boolean is set (pre-field clis.json)", () => {
|
||||
expect(agentSkillDir({ agentSkillInjection: true })).toBe(CLAUDE_AGENT_SKILL_DIR);
|
||||
});
|
||||
|
||||
it('uses the declared directory otherwise', () => {
|
||||
expect(agentSkillDir({ agentSkillInjection: true, agentSkillDir: AGENTS_DIR })).toBe(AGENTS_DIR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the stock registry', () => {
|
||||
it('pins the per-CLI table (measured 2026-10-11, see AGENTS_DIR_SKILL in stock.ts)', () => {
|
||||
const table = Object.fromEntries(
|
||||
['claude', 'codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek', 'omp', 'shell'].map((id) => [
|
||||
id,
|
||||
agentSkillDir(getCli(id)?.capabilities),
|
||||
])
|
||||
);
|
||||
expect(table).toEqual({
|
||||
claude: '.claude/skills',
|
||||
codex: AGENTS_DIR,
|
||||
gemini: AGENTS_DIR,
|
||||
opencode: AGENTS_DIR,
|
||||
antigravity: AGENTS_DIR,
|
||||
// A project-level skill makes pi ask "Trust project folder?" at startup.
|
||||
pi: null,
|
||||
grok: AGENTS_DIR,
|
||||
deepseek: AGENTS_DIR,
|
||||
omp: AGENTS_DIR,
|
||||
shell: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilities.agentSkillDir validation (clis.json is user-editable)', () => {
|
||||
const withDir = (dir: unknown) => {
|
||||
const claude = getCli('claude')!;
|
||||
return CliEntrySchema.safeParse({ ...claude, capabilities: { ...claude.capabilities, agentSkillDir: dir } });
|
||||
};
|
||||
|
||||
it('accepts a listed directory', () => {
|
||||
expect(withDir('.agents/skills').success).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a free path, traversal included', () => {
|
||||
expect(withDir('../../.ssh').success).toBe(false);
|
||||
expect(withDir('/etc').success).toBe(false);
|
||||
expect(withDir('.pi/skills').success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codeman skill install|uninstall follow the registry', () => {
|
||||
it('--mode claude (the default) installs into .claude/skills, user-level or per case', () => {
|
||||
expect(resolveSkillInstallDir({})).toEqual({ skillDir: CLAUDE_AGENT_SKILL_DIR });
|
||||
expect(resolveSkillInstallDir({ mode: 'claude', case: 'x' })).toEqual({ skillDir: CLAUDE_AGENT_SKILL_DIR });
|
||||
});
|
||||
|
||||
it('--mode codex installs per case into .agents/skills, and points a user-level install at npx skills', () => {
|
||||
expect(resolveSkillInstallDir({ mode: 'codex', case: 'x' })).toEqual({ skillDir: AGENTS_DIR });
|
||||
expect(resolveSkillInstallDir({ mode: 'codex' }).error).toContain('npx skills add Ark0N/Codeman --skill codeman -g');
|
||||
});
|
||||
|
||||
it('refuses a mode without a skill and an unknown mode', () => {
|
||||
expect(resolveSkillInstallDir({ mode: 'shell', case: 'x' }).error).toContain('takes no Codeman agent skill');
|
||||
expect(resolveSkillInstallDir({ mode: 'nope', case: 'x' }).error).toContain('Unknown mode');
|
||||
});
|
||||
|
||||
it('uninstall --case covers every distinct directory the registry can write, once each', () => {
|
||||
const dirs = registryAgentSkillDirs();
|
||||
expect(new Set(dirs).size).toBe(dirs.length);
|
||||
expect(dirs).toContain(CLAUDE_AGENT_SKILL_DIR);
|
||||
for (const cli of listClis()) {
|
||||
const dir = agentSkillDir(cli.capabilities);
|
||||
if (dir) expect(dirs, cli.id).toContain(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -163,6 +163,30 @@ describe('preamble single-source (seed + §0 heredoc parity)', () => {
|
||||
expect(preamble).toBe(heredoc);
|
||||
});
|
||||
|
||||
it("SKILL.md's shell survives Claude Code's skill-argument substitution unchanged", async () => {
|
||||
// When Claude Code loads a skill WITH arguments (the Skill tool passes the user's request words),
|
||||
// it rewrites the skill body before the model sees it. Measured on 2.1.300: every `$ARGUMENTS[N]`,
|
||||
// `$ARGUMENTS` and bare `$N` (digits, then no word character) becomes the matching argument, 0-based,
|
||||
// and a backslash before `$` is consumed as an escape. A bare `$1` in §0 arrived as
|
||||
// `sessions/5/terminal` and `timeout=claude` for "start 5 claude workers", so an agent pasting §0
|
||||
// wrote a broken preamble. `${1}` is the same bash and is never touched; keep it that way.
|
||||
const skillMd = await readFile(join(packagedDir, 'SKILL.md'), 'utf-8');
|
||||
const substitute = (body: string, args: string[]): string =>
|
||||
body
|
||||
.replace(/(?<!\\)\\\$(?=\d|ARGUMENTS)/g, '$')
|
||||
.replace(/\$ARGUMENTS\[(\d+)\]/g, (m, i: string) => args[Number(i)] ?? m)
|
||||
.replace(/\$(\d+)(?!\w)/g, (m, i: string) => args[Number(i)] ?? m)
|
||||
.replaceAll('$ARGUMENTS', args.join(' '));
|
||||
const args = 'start 5 claude workers, one per TODO item'.split(/\s+/);
|
||||
expect(substitute(skillMd, args)).toBe(skillMd);
|
||||
|
||||
// And the heredoc an agent pastes from the substituted body is still the seeded preamble.
|
||||
const rendered = substitute(skillMd, args);
|
||||
const start = rendered.indexOf("<<'PREAMBLE'\n") + "<<'PREAMBLE'\n".length;
|
||||
const preamble = await readFile(join(packagedDir, 'preamble.sh'), 'utf-8');
|
||||
expect(rendered.slice(start, rendered.indexOf('\nPREAMBLE\n', start) + 1)).toBe(preamble);
|
||||
});
|
||||
|
||||
it('seedAgentSessionPreamble writes the stamped preamble to the XDG cache path, 0600', async () => {
|
||||
const prevXdg = process.env.XDG_CACHE_HOME;
|
||||
const cacheDir = join(casePath, 'xdg-cache');
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
/** @fileoverview Guarded input observes all production write and lifecycle primitives. */
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { formatPaneSnapshot } from '../src/tmux-manager.js';
|
||||
import { Session } from '../src/session.js';
|
||||
import { promptSha256 } from '../src/agent-staging.js';
|
||||
it('fences writes and reused-pid pane lifecycles without persisting prompt content', async () => {
|
||||
let screen = '› \n GPT-6-Luna default · ~/case\n';
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
capturePaneBuffer: (
|
||||
_name: string,
|
||||
_pane?: string,
|
||||
opts?: { capturedGeometry?: { cols: number; rows: number } }
|
||||
) => {
|
||||
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
|
||||
return formatPaneSnapshot(screen.trimEnd().split('\n'), {
|
||||
cols: 100,
|
||||
rows: 20,
|
||||
cursorX: screen.startsWith('› hello') ? 7 : 2,
|
||||
cursorY: 0,
|
||||
});
|
||||
},
|
||||
sendInput: async () => true,
|
||||
};
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
mux,
|
||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 12345 },
|
||||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||||
const before = session.guardedInputSnapshot();
|
||||
expect(before?.composerSha).toBe(promptSha256(''));
|
||||
await session.writeViaMux('hello');
|
||||
screen = '› hello\n GPT-6-Luna default · ~/case\n';
|
||||
expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 1);
|
||||
session.trackUserInput('x');
|
||||
expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 2);
|
||||
await session.performAuxiliaryInput(async () => {});
|
||||
expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 3);
|
||||
const generation = session.inspectionGeneration;
|
||||
await (session as unknown as { _withPaneLifecycle(op: () => Promise<void>): Promise<void> })._withPaneLifecycle(
|
||||
async () => {
|
||||
expect(session.guardedInputSnapshot()).toBeNull();
|
||||
}
|
||||
);
|
||||
expect(session.inspectionGeneration).not.toBe(generation);
|
||||
session.markPromptSubmitted();
|
||||
expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 4);
|
||||
await session.stop(false);
|
||||
expect(session.guardedInputSnapshot()).toBeNull();
|
||||
});
|
||||
|
||||
it('checks conditional Enter at the dispatch boundary and holds concurrent input until after it', async () => {
|
||||
let dispatch: (() => void) | undefined;
|
||||
const writes: string[] = [];
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
capturePaneBuffer: (
|
||||
_name: string,
|
||||
_pane?: string,
|
||||
opts?: { capturedGeometry?: { cols: number; rows: number } }
|
||||
) => {
|
||||
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
|
||||
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
|
||||
cols: 100,
|
||||
rows: 20,
|
||||
cursorX: 7,
|
||||
cursorY: 0,
|
||||
});
|
||||
},
|
||||
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
dispatch = resolve;
|
||||
});
|
||||
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
|
||||
writes.push(text);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
mux,
|
||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
|
||||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||||
try {
|
||||
const settle = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const expected = session.guardedInputSnapshot()!;
|
||||
const sending = session.submitGuardedInput(expected);
|
||||
// Held behind the lease, not refused: it still invalidates the pending Enter, and
|
||||
// it goes out once the lease is released.
|
||||
const other = session.writeViaMux('other');
|
||||
dispatch!();
|
||||
expect(await sending).toBe(false);
|
||||
expect(writes).toEqual([]);
|
||||
await settle();
|
||||
dispatch!();
|
||||
expect(await other).toBe(true);
|
||||
expect(writes).toEqual(['other']);
|
||||
const next = session.submitGuardedInput(session.guardedInputSnapshot()!);
|
||||
dispatch!();
|
||||
expect(await next).toBe(true);
|
||||
expect(writes).toEqual(['other', '\r']);
|
||||
const restarting = session.submitGuardedInput(session.guardedInputSnapshot()!);
|
||||
await (session as unknown as { _withPaneLifecycle(op: () => Promise<void>): Promise<void> })._withPaneLifecycle(
|
||||
async () => {}
|
||||
);
|
||||
dispatch!();
|
||||
expect(await restarting).toBe(false);
|
||||
expect(writes).toEqual(['other', '\r']);
|
||||
const auxiliary = session.submitGuardedInput(session.guardedInputSnapshot()!);
|
||||
const key = session.performAuxiliaryInput(async () => {
|
||||
writes.push('aux');
|
||||
});
|
||||
await settle();
|
||||
// The named key waits for the lease instead of failing, and never runs inside it.
|
||||
expect(writes).toEqual(['other', '\r']);
|
||||
dispatch!();
|
||||
expect(await auxiliary).toBe(false);
|
||||
await key;
|
||||
expect(writes).toEqual(['other', '\r', 'aux']);
|
||||
} finally {
|
||||
await session.stop(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels an older automatic submit verifier before staging an identical prompt', async () => {
|
||||
vi.useFakeTimers();
|
||||
let composer = '';
|
||||
const writes: string[] = [];
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
capturePaneBuffer: (
|
||||
_name: string,
|
||||
_pane?: string,
|
||||
opts?: { capturedGeometry?: { cols: number; rows: number } }
|
||||
) => {
|
||||
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
|
||||
return formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], {
|
||||
cols: 100,
|
||||
rows: 20,
|
||||
cursorX: 2 + composer.length,
|
||||
cursorY: 0,
|
||||
});
|
||||
},
|
||||
capturePaneText: () => `› ${composer}\n GPT-6-Luna default · ~/case`,
|
||||
sendInput: async (_id: string, text: string) => {
|
||||
writes.push(text);
|
||||
composer += text;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
mux,
|
||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
|
||||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||||
try {
|
||||
session.verifySubmitted('hello');
|
||||
expect(await session.stageGuardedInput('hello')).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(writes).toEqual(['hello']);
|
||||
} finally {
|
||||
await session.stop(false);
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('routes automatic verifier Enter through the shared guarded input boundary', async () => {
|
||||
vi.useFakeTimers();
|
||||
let release!: () => void;
|
||||
const writes: string[] = [];
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
capturePaneBuffer: (
|
||||
_name: string,
|
||||
_pane?: string,
|
||||
opts?: { capturedGeometry?: { cols: number; rows: number } }
|
||||
) => {
|
||||
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
|
||||
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
|
||||
cols: 100,
|
||||
rows: 20,
|
||||
cursorX: 7,
|
||||
cursorY: 0,
|
||||
});
|
||||
},
|
||||
capturePaneText: () => '› hello\n GPT-6-Luna default · ~/case',
|
||||
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
|
||||
if (options?.beforeDispatch) {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
if (!options.beforeDispatch()) return false;
|
||||
}
|
||||
writes.push(text);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
mux,
|
||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
|
||||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||||
try {
|
||||
session.verifySubmitted('hello');
|
||||
const submission = session.submitGuardedInput(session.guardedInputSnapshot()!);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
release();
|
||||
expect(await submission).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(60000);
|
||||
expect(writes).toEqual([]);
|
||||
} finally {
|
||||
await session.stop(false);
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses legacy one-shot prompt entrypoints during a guarded submission before spawning', async () => {
|
||||
let release!: () => void;
|
||||
const writes: string[] = [];
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
capturePaneBuffer: (
|
||||
_name: string,
|
||||
_pane?: string,
|
||||
opts?: { capturedGeometry?: { cols: number; rows: number } }
|
||||
) => {
|
||||
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
|
||||
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
|
||||
cols: 100,
|
||||
rows: 20,
|
||||
cursorX: 7,
|
||||
cursorY: 0,
|
||||
});
|
||||
},
|
||||
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
|
||||
writes.push(text);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
mux,
|
||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
|
||||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||||
const access = session as unknown as { ptyProcess: { write(data: string): void } | null };
|
||||
try {
|
||||
const submission = session.submitGuardedInput(session.guardedInputSnapshot()!);
|
||||
// This also prevents a real spawn if the lease guard regresses.
|
||||
access.ptyProcess = {
|
||||
write: () => {
|
||||
throw new Error('must not write');
|
||||
},
|
||||
};
|
||||
await expect(session.runPrompt('other')).rejects.toThrow('Guarded submission');
|
||||
await expect(session.sendInput('other')).rejects.toThrow('Guarded submission');
|
||||
access.ptyProcess = null;
|
||||
release();
|
||||
expect(await submission).toBe(false);
|
||||
expect(writes).toEqual([]);
|
||||
} finally {
|
||||
access.ptyProcess = null;
|
||||
await session.stop(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('counts an ordinary transport pending window before any awaited acknowledgement', async () => {
|
||||
let release!: () => void;
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
mux: {
|
||||
isAvailable: () => true,
|
||||
sendInput: async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
|
||||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||||
try {
|
||||
const writing = session.writeViaMux('hello');
|
||||
expect(session.guardedInputPendingWrites).toBe(1);
|
||||
release();
|
||||
await writing;
|
||||
expect(session.guardedInputPendingWrites).toBe(0);
|
||||
} finally {
|
||||
release?.();
|
||||
await session.stop(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses fresh normalized pane rows for working/dialog denial before exact composer proof', async () => {
|
||||
let working = false;
|
||||
const capture = vi.fn(() =>
|
||||
formatPaneSnapshot(
|
||||
[working ? '• Working (1s · esc to interrupt)' : 'ready', '› hello', ' GPT-6-Luna default · ~/case'],
|
||||
{ cols: 100, rows: 20, cursorX: 7, cursorY: 1 }
|
||||
)
|
||||
);
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
mux: {
|
||||
isAvailable: () => true,
|
||||
capturePaneBuffer: (
|
||||
_name: string,
|
||||
_pane?: string,
|
||||
opts?: { capturedGeometry?: { cols: number; rows: number } }
|
||||
) => {
|
||||
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
|
||||
return capture();
|
||||
},
|
||||
capturePaneText: () => {
|
||||
throw Error('must use same frame');
|
||||
},
|
||||
},
|
||||
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
|
||||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||||
try {
|
||||
const blocked = vi.spyOn(session as unknown as { _paneShowsBlocked(text: string): boolean }, '_paneShowsBlocked');
|
||||
expect(session.guardedInputSnapshot()).not.toBeNull();
|
||||
expect(blocked.mock.calls[0][0]).toContain('ready\n› hello\n');
|
||||
working = true;
|
||||
expect(session.guardedInputSnapshot()).toBeNull();
|
||||
blocked.mockReturnValue(true);
|
||||
working = false;
|
||||
expect(session.guardedInputSnapshot()).toBeNull();
|
||||
} finally {
|
||||
await session.stop(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps raw user input synchronous and preserves receipts for focus/mouse/noop reports', async () => {
|
||||
const writes: string[] = [];
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
mux: {
|
||||
isAvailable: () => true,
|
||||
capturePaneBuffer: (
|
||||
_name: string,
|
||||
_pane?: string,
|
||||
opts?: { capturedGeometry?: { cols: number; rows: number } }
|
||||
) => {
|
||||
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
|
||||
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
|
||||
cols: 100,
|
||||
rows: 20,
|
||||
cursorX: 7,
|
||||
cursorY: 0,
|
||||
});
|
||||
},
|
||||
},
|
||||
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
|
||||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||||
const access = session as unknown as {
|
||||
ptyProcess: { write(data: string): void; resize(cols: number, rows: number): void } | null;
|
||||
};
|
||||
access.ptyProcess = {
|
||||
write: (text) => {
|
||||
writes.push(text);
|
||||
},
|
||||
resize: () => {},
|
||||
};
|
||||
try {
|
||||
const before = session.guardedInputSnapshot()!;
|
||||
for (const data of ['\x1b[I', '\x1b[O', '\x1b[<0;10;5M', '\x1b[M !!', '']) {
|
||||
expect(session.write(data, { fromUser: true })).toBe(true);
|
||||
expect(writes.at(-1)).toBe(data);
|
||||
expect(session.guardedInputSnapshot()?.revision).toBe(before.revision);
|
||||
}
|
||||
session.write('\x1b[Oedit');
|
||||
expect(session.guardedInputSnapshot()?.revision).toBe(before.revision + 1);
|
||||
const generation = session.inspectionGeneration;
|
||||
session.owner = 'other';
|
||||
session.owner = undefined;
|
||||
expect(session.inspectionGeneration).not.toBe(generation);
|
||||
const priorResize = session.guardedInputGeneration;
|
||||
session.resize(80, 24);
|
||||
expect(session.guardedInputGeneration).not.toBe(priorResize);
|
||||
} finally {
|
||||
access.ptyProcess = null;
|
||||
await session.stop(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a message that would wrap using the actual captured indent and width before any write', async () => {
|
||||
const writes: string[] = [];
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
mux: {
|
||||
isAvailable: () => true,
|
||||
capturePaneBuffer: (
|
||||
_name: string,
|
||||
_pane: string,
|
||||
opts: { capturedGeometry?: { cols: number; rows: number } }
|
||||
) => {
|
||||
if (opts) opts.capturedGeometry = { cols: 30, rows: 20 };
|
||||
return formatPaneSnapshot([' › ', ' GPT-6 default · ~/case'], {
|
||||
cols: 30,
|
||||
rows: 20,
|
||||
cursorX: 6,
|
||||
cursorY: 0,
|
||||
});
|
||||
},
|
||||
sendInput: async (_id: string, text: string) => {
|
||||
writes.push(text);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
|
||||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||||
const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js');
|
||||
const { getCli } = await import('../src/config/cli-registry/registry.js');
|
||||
try {
|
||||
const store = new GuardedStageStore();
|
||||
const target = {
|
||||
id: session.id,
|
||||
chrome: () => guardedComposerChrome(getCli('codex')),
|
||||
snapshot: () => session.guardedInputSnapshot(),
|
||||
write: (text: string) => session.stageGuardedInput(text),
|
||||
submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap),
|
||||
};
|
||||
await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow(/wrap/);
|
||||
expect(writes).toEqual([]);
|
||||
} finally {
|
||||
await session.stop(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps inspection generations stable across resize but invalidates a staged snapshot', async () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
|
||||
const access = session as unknown as { ptyProcess: { resize(cols: number, rows: number): void } | null };
|
||||
access.ptyProcess = { resize: () => {} };
|
||||
try {
|
||||
const inspection = session.inspectionGeneration;
|
||||
const staging = session.guardedInputGeneration;
|
||||
session.resize(80, 24);
|
||||
expect(session.inspectionGeneration).toBe(inspection);
|
||||
expect(session.guardedInputGeneration).not.toBe(staging);
|
||||
} finally {
|
||||
access.ptyProcess = null;
|
||||
await session.stop(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('records a verifier re-press as the latest submit time', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(10000);
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
mux: { isAvailable: () => true, capturePaneText: () => '› hello\n', sendInput: async () => true },
|
||||
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
|
||||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||||
try {
|
||||
await session.writeViaMux('hello\r');
|
||||
const initial = session.lastSubmitAt;
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(session.lastSubmitAt).toBe(initial + 2000);
|
||||
} finally {
|
||||
await session.stop(false);
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('stages through a real session whose first post-write capture is the torn frame codex produces', async () => {
|
||||
// Reproduces the live 0.162.0 race: right after the keys land, the screen still
|
||||
// shows the empty composer while the caret is already past the new text. That
|
||||
// frame proves nothing; the repaint a few ms later does.
|
||||
let composer = '';
|
||||
let torn = 0;
|
||||
const writes: string[] = [];
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
mux: {
|
||||
isAvailable: () => true,
|
||||
capturePaneBuffer: (
|
||||
_name: string,
|
||||
_pane?: string,
|
||||
opts?: { capturedGeometry?: { cols: number; rows: number } }
|
||||
) => {
|
||||
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
|
||||
const shown = torn > 0 ? '' : composer;
|
||||
if (torn > 0) torn--;
|
||||
return formatPaneSnapshot([`› ${shown}`, ' GPT-6-Luna default · ~/case'], {
|
||||
cols: 100,
|
||||
rows: 20,
|
||||
cursorX: 2 + composer.length,
|
||||
cursorY: 0,
|
||||
});
|
||||
},
|
||||
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
|
||||
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
|
||||
writes.push(text);
|
||||
if (text !== '\r') {
|
||||
composer += text;
|
||||
torn = 2;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
|
||||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||||
const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js');
|
||||
const { getCli } = await import('../src/config/cli-registry/registry.js');
|
||||
const sleeps: number[] = [];
|
||||
const store = new GuardedStageStore(Date.now, 1024, 120000, {
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms);
|
||||
},
|
||||
});
|
||||
const target = {
|
||||
id: session.id,
|
||||
chrome: () => guardedComposerChrome(getCli('codex')),
|
||||
snapshot: () => session.guardedInputSnapshot(),
|
||||
write: (text: string) => session.stageGuardedInput(text),
|
||||
submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap),
|
||||
};
|
||||
try {
|
||||
const receipt = await store.stage(target, 'alice', 'hello');
|
||||
expect(receipt.sha256).toBe(promptSha256('hello'));
|
||||
expect(sleeps).toHaveLength(2);
|
||||
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).resolves.toMatchObject({
|
||||
submitted: true,
|
||||
});
|
||||
expect(writes).toEqual(['hello', '\r']);
|
||||
} finally {
|
||||
await session.stop(false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
/** @fileoverview Guarded staging one-shot and content-verification safety. */
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
|
||||
import {
|
||||
GuardedStageStore,
|
||||
STAGE_SETTLE_INTERVAL_MS,
|
||||
STAGE_SETTLE_TIMEOUT_MS,
|
||||
SUBMIT_QUIET_MS,
|
||||
composerTriggerRefusal,
|
||||
guardedComposerChrome,
|
||||
readGuardedComposer,
|
||||
readGuardedSnapshot,
|
||||
type GuardedInputTarget,
|
||||
} from '../src/agent-staging.js';
|
||||
import { getCli, listClis } from '../src/config/cli-registry/registry.js';
|
||||
/** Codex's composer chrome exactly as the registry declares it: no copy of the glyph or footer here. */
|
||||
const CODEX = guardedComposerChrome(getCli('codex'))!;
|
||||
const readGuardedCodexComposer = (screen: string) => readGuardedComposer(screen, CODEX);
|
||||
const readGuardedCodexSnapshot = (snapshot: string) => readGuardedSnapshot(snapshot, CODEX);
|
||||
const sha = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
let store: GuardedStageStore;
|
||||
let time: number;
|
||||
let generation: string;
|
||||
let revision: number;
|
||||
let composer: string | null;
|
||||
let writes: string[];
|
||||
let fail: boolean;
|
||||
let target: GuardedInputTarget;
|
||||
/** Every settle-window wait, on the fake clock: no real timer runs in this file. */
|
||||
let sleeps: number[];
|
||||
const fakeSleep = async (ms: number) => {
|
||||
sleeps.push(ms);
|
||||
time += ms;
|
||||
};
|
||||
beforeEach(() => {
|
||||
sleeps = [];
|
||||
time = 0;
|
||||
generation = 'pane-1';
|
||||
revision = 0;
|
||||
composer = '';
|
||||
writes = [];
|
||||
fail = false;
|
||||
store = new GuardedStageStore(() => time, 2, 1000, { sleep: fakeSleep });
|
||||
target = {
|
||||
id: 's',
|
||||
chrome: () => CODEX,
|
||||
snapshot: () =>
|
||||
composer === null ? null : { generation, revision, composerSha: sha(composer), availableColumns: 100 },
|
||||
submit: async () => target.write('\r'),
|
||||
write: async (text) => {
|
||||
writes.push(text);
|
||||
revision++;
|
||||
if (fail) return false;
|
||||
composer = text === '\r' ? '' : composer + text;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
});
|
||||
it('stages without Enter, stores only a hash, and submits exactly once', async () => {
|
||||
const receipt = await store.stage(target, 'alice', 'hello');
|
||||
expect(writes).toEqual(['hello']);
|
||||
expect(receipt.sha256).toBe(sha('hello'));
|
||||
expect(JSON.stringify(receipt)).not.toContain('hello');
|
||||
await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
|
||||
expect(writes).toEqual(['hello', '\r']);
|
||||
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
|
||||
});
|
||||
it('refuses multiline, controls, oversized prompts and nonempty or unprovable composers before writing', async () => {
|
||||
for (const text of ['', 'one\ntwo', 'one\rtwo', '\u001b', '\u0085', 'x'.repeat(20000), '\ud800'])
|
||||
await expect(store.stage(target, 'alice', text)).rejects.toThrow();
|
||||
composer = 'existing';
|
||||
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow();
|
||||
composer = null;
|
||||
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
it('refuses altered composer including whitespace-only differences', async () => {
|
||||
const receipt = await store.stage(target, 'alice', 'hello world');
|
||||
composer = 'helloworld';
|
||||
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
|
||||
expect(writes).toEqual(['hello world']);
|
||||
});
|
||||
it('refuses changed generation, intervening input, wrong owner/session/hash and expiry', async () => {
|
||||
for (const change of [
|
||||
() => {
|
||||
generation = 'pane-2';
|
||||
},
|
||||
() => {
|
||||
revision++;
|
||||
},
|
||||
() => {
|
||||
time += 1001;
|
||||
},
|
||||
]) {
|
||||
composer = '';
|
||||
const receipt = await store.stage(target, 'alice', 'hello');
|
||||
change();
|
||||
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
|
||||
}
|
||||
composer = '';
|
||||
const receipt = await store.stage(target, 'alice', 'hello');
|
||||
await expect(store.submit(target, 'bob', receipt.stageId, receipt.sha256)).rejects.toThrow();
|
||||
await expect(store.submit({ ...target, id: 'other' }, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
|
||||
await expect(store.submit(target, 'alice', receipt.stageId, sha('other'))).rejects.toThrow();
|
||||
expect(writes.filter((s) => s === '\r')).toHaveLength(0);
|
||||
});
|
||||
it('consumes before write so concurrent and failed deliveries never replay Enter', async () => {
|
||||
const receipt = await store.stage(target, 'alice', 'hello');
|
||||
fail = true;
|
||||
const results = await Promise.allSettled([
|
||||
store.submit(target, 'alice', receipt.stageId, receipt.sha256),
|
||||
store.submit(target, 'alice', receipt.stageId, receipt.sha256),
|
||||
]);
|
||||
expect(results.every((r) => r.status === 'rejected')).toBe(true);
|
||||
expect(writes).toEqual(['hello', '\r']);
|
||||
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
|
||||
});
|
||||
it('does not issue a receipt on failed staging or intervening input during its write', async () => {
|
||||
fail = true;
|
||||
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow();
|
||||
fail = false;
|
||||
composer = '';
|
||||
const altered = {
|
||||
...target,
|
||||
write: async (s: string) => {
|
||||
await target.write(s);
|
||||
revision++;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
await expect(store.stage(altered, 'alice', 'hello')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('reads exact composer spaces and refuses wrapped or modal frames', async () => {
|
||||
expect(readGuardedCodexComposer('› hello world\n GPT-6-Luna default · ~/case\n')).toBe('hello world');
|
||||
expect(
|
||||
readGuardedCodexComposer(
|
||||
'› \x1b[2mAsk Codex to do anything\x1b[0m\n GPT-6-Luna default · ~/case\n ← for agents · ? for shortcuts'
|
||||
)
|
||||
).toBe('');
|
||||
expect(readGuardedCodexComposer('› wrapped\n more text\n GPT-6-Luna default · ~/case')).toBeNull();
|
||||
expect(readGuardedCodexComposer('› 1. Yes\n Press enter to confirm')).toBeNull();
|
||||
expect(readGuardedCodexComposer('› hello\n')).toBeNull();
|
||||
});
|
||||
it('requires capture cursor geometry, rejecting invisible trailing edits and moved cursors', async () => {
|
||||
const { formatPaneSnapshot } = await import('../src/tmux-manager.js');
|
||||
const frame = (text: string, x: number, y = 0) =>
|
||||
formatPaneSnapshot([`› ${text}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: x, cursorY: y });
|
||||
expect(readGuardedCodexSnapshot(frame('hello', 7))).toBe('hello');
|
||||
expect(readGuardedCodexSnapshot(frame('hello', 8))).toBeNull();
|
||||
expect(readGuardedCodexSnapshot(frame('hello', 4))).toBeNull();
|
||||
expect(readGuardedCodexSnapshot(frame('hello', 7, 1))).toBeNull();
|
||||
expect(readGuardedCodexSnapshot('› hello\n GPT-6-Luna default · ~/case')).toBeNull();
|
||||
expect(readGuardedCodexSnapshot(frame('你好', 6))).toBe('你好');
|
||||
expect(readGuardedCodexSnapshot(frame('\x1b[38;2;2;100;100mhello\x1b[0m', 7))).toBe('hello');
|
||||
});
|
||||
it('reserves receipt capacity before concurrent stages type', async () => {
|
||||
store = new GuardedStageStore(() => time, 1, 1000, { sleep: fakeSleep });
|
||||
let release: (() => void) | undefined;
|
||||
const firstTarget = {
|
||||
...target,
|
||||
write: async (text: string) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return target.write(text);
|
||||
},
|
||||
};
|
||||
const first = store.stage(firstTarget, 'alice', 'hello');
|
||||
const otherTarget = { ...target, id: 'other' };
|
||||
await expect(store.stage(otherTarget, 'alice', 'other')).rejects.toThrow(/capacity/i);
|
||||
expect(writes).toEqual([]);
|
||||
release!();
|
||||
await first;
|
||||
});
|
||||
|
||||
it.each([...CODEX_REASONING_EFFORTS, 'default'])(
|
||||
'accepts the shared Codex effort word %s in the footer',
|
||||
async (word) => {
|
||||
expect(readGuardedCodexComposer(`› hello\n GPT-6 ${word} · ~/case\n`)).toBe('hello');
|
||||
}
|
||||
);
|
||||
|
||||
it('refuses messages that reach the available pane columns before typing', async () => {
|
||||
target.snapshot = () => ({ generation, revision, composerSha: sha(composer ?? ''), availableColumns: 4 });
|
||||
for (const text of ['four', '界界']) await expect(store.stage(target, 'alice', text)).rejects.toThrow();
|
||||
expect(writes).toEqual([]);
|
||||
await expect(store.stage(target, 'alice', 'abc')).resolves.toMatchObject({ submitted: false });
|
||||
});
|
||||
|
||||
it('takes the composer glyph and footer from the registry, and only codex declares a footer', () => {
|
||||
const codex = getCli('codex')!.capabilities.workDetect!;
|
||||
expect(CODEX.promptGlyph).toBe(codex.promptGlyph);
|
||||
expect(CODEX.footerLine.source).toBe(codex.composerFooterLine);
|
||||
// Another glyph drives the same reader: nothing in it is codex-specific.
|
||||
const other = { promptGlyph: '>>', footerLine: CODEX.footerLine };
|
||||
expect(readGuardedComposer('>> hello\n GPT-6 default · ~/case\n', other)).toBe('hello');
|
||||
expect(readGuardedComposer('› hello\n GPT-6 default · ~/case\n', other)).toBeNull();
|
||||
const declaring = listClis()
|
||||
.filter((entry) => entry.capabilities.workDetect?.composerFooterLine)
|
||||
.map((entry) => entry.id);
|
||||
expect(declaring).toEqual(['codex']);
|
||||
expect(guardedComposerChrome(getCli('claude'))).toBeNull();
|
||||
// A pattern the ReDoS guard refuses yields no chrome, so no guarded input.
|
||||
const unsafe = structuredClone(getCli('codex')!);
|
||||
unsafe.capabilities.workDetect!.composerFooterLine = '(a+)+b';
|
||||
expect(guardedComposerChrome(unsafe)).toBeNull();
|
||||
});
|
||||
|
||||
describe('the settle window after typing (a live codex pane repaints 30 to 60 ms after the keys)', () => {
|
||||
/** Serve these post-write frames in order, then the target's own snapshot. */
|
||||
function afterWrite(frames: Array<(() => ReturnType<GuardedInputTarget['snapshot']>) | null>) {
|
||||
let written = false;
|
||||
const own = target.snapshot;
|
||||
const write = target.write;
|
||||
target.write = async (text) => {
|
||||
const ok = await write(text);
|
||||
written = true;
|
||||
return ok;
|
||||
};
|
||||
target.snapshot = () => {
|
||||
if (!written || frames.length === 0) return own();
|
||||
const next = frames.shift()!;
|
||||
return next ? next() : null;
|
||||
};
|
||||
}
|
||||
const stale = () => ({ generation, revision, composerSha: sha(''), availableColumns: 100 });
|
||||
|
||||
it('names its bounds', () => {
|
||||
expect(STAGE_SETTLE_TIMEOUT_MS).toBe(1000);
|
||||
expect(STAGE_SETTLE_INTERVAL_MS).toBe(50);
|
||||
});
|
||||
it('issues a receipt once a null first frame is followed by a valid one', async () => {
|
||||
afterWrite([null]);
|
||||
const receipt = await store.stage(target, 'alice', 'hello');
|
||||
expect(receipt.sha256).toBe(sha('hello'));
|
||||
expect(sleeps).toEqual([STAGE_SETTLE_INTERVAL_MS]);
|
||||
});
|
||||
it('issues a receipt once a stale frame (the old, empty composer) is followed by a valid one', async () => {
|
||||
afterWrite([stale, stale]);
|
||||
await expect(store.stage(target, 'alice', 'hello')).resolves.toMatchObject({ submitted: false });
|
||||
expect(sleeps).toHaveLength(2);
|
||||
});
|
||||
it('refuses, saying the text may be in the composer, when no frame verifies within the window', async () => {
|
||||
target.snapshot = () => (writes.length ? null : stale());
|
||||
const refusal = await store.stage(target, 'alice', 'hello').catch((error: Error) => error.message);
|
||||
expect(refusal).toMatch(/within 1000 ms; no receipt was issued/);
|
||||
expect(refusal).toMatch(/may already be in the composer.*Ctrl\+U/);
|
||||
expect(sleeps.length).toBeGreaterThanOrEqual(STAGE_SETTLE_TIMEOUT_MS / STAGE_SETTLE_INTERVAL_MS);
|
||||
});
|
||||
it('issues no receipt for a frame whose composer text differs from the message', async () => {
|
||||
const write = target.write;
|
||||
target.write = async (text) => {
|
||||
await write(text);
|
||||
composer = 'hellp'; // codex shows something else: a transformed paste, a dropped key
|
||||
return true;
|
||||
};
|
||||
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(/did not show the staged message/);
|
||||
// Nothing to submit: no receipt was stored for the frame that did not match.
|
||||
composer = 'hello';
|
||||
await expect(store.submit(target, 'alice', '11111111-1111-4111-8111-111111111111', sha('hello'))).rejects.toThrow(
|
||||
/Unknown/
|
||||
);
|
||||
});
|
||||
it('refuses at once, without waiting, when another writer moved the input during the window', async () => {
|
||||
afterWrite([() => ({ generation, revision: revision + 1, composerSha: sha('hello'), availableColumns: 100 })]);
|
||||
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(
|
||||
/Input or pane changed while staging; no receipt was issued\. The message may already be in the composer/
|
||||
);
|
||||
expect(sleeps).toEqual([]);
|
||||
});
|
||||
it('words a failed typing delivery like the submit refusal: it cannot know whether the text landed', async () => {
|
||||
fail = true;
|
||||
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(
|
||||
/^Staging delivery failed or ambiguous; no receipt was issued\. The message may already be in the composer/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('popups and commands that change what Enter does (codex rust-v0.162.0 bottom_pane)', () => {
|
||||
const footer = ' gpt-5.6-terra default · ~/case';
|
||||
/** A frame as codex lays it out: transcript, popup, the composer's blank top row, composer, blank, footer. */
|
||||
const frame = (popup: string[], draft: string) =>
|
||||
['• Earlier answer from the agent.', '', ...popup, '', `› ${draft}`, '', footer].join('\n');
|
||||
|
||||
it('reads the idle frame with transcript above it', () => {
|
||||
expect(readGuardedCodexComposer(frame([], 'hello'))).toBe('hello');
|
||||
});
|
||||
it('refuses the slash-command list codex opens above `› /stat` (Enter would run /status)', () => {
|
||||
const popup = [
|
||||
'› /status show current session configuration and token usage',
|
||||
' /statusline configure which items appear in the status line',
|
||||
];
|
||||
expect(readGuardedCodexComposer(frame(popup, '/stat'))).toBeNull();
|
||||
// A wrapped description row at the bottom of the list does not hide it.
|
||||
expect(readGuardedCodexComposer(frame(['› /tier-one Use faster', ' inference'], '/tier'))).toBeNull();
|
||||
});
|
||||
it('refuses the skill, mention and file-search popups', () => {
|
||||
const skill = ['› 1password [Skill] Example skill used in tests.', '', ' enter insert · esc close'];
|
||||
const mention = [
|
||||
' Mentions',
|
||||
' All Results Filesystem Only Plugins',
|
||||
' sa',
|
||||
'› Sample Plugin Plugin with skills and an MCP server Plugin',
|
||||
' enter/tab insert · esc close · ↑/↓ select · ←/→ filter',
|
||||
];
|
||||
expect(readGuardedCodexComposer(frame(skill, '$1p'))).toBeNull();
|
||||
expect(readGuardedCodexComposer(frame(mention, '@sa'))).toBeNull();
|
||||
expect(readGuardedCodexComposer(frame([' loading...', ''], '@src'))).toBeNull();
|
||||
expect(readGuardedCodexComposer(frame(['no matches'], '/zz'))).toBeNull();
|
||||
});
|
||||
it('looks only at the block directly above the composer, not the transcript further up', () => {
|
||||
const screen = [' /status shows the status', '', '• Answer.', '', '', '', '› hello', '', footer].join('\n');
|
||||
expect(readGuardedCodexComposer(screen)).toBe('hello');
|
||||
});
|
||||
it('takes the popup pattern and the trigger characters from the registry', () => {
|
||||
const codex = getCli('codex')!.capabilities.workDetect!;
|
||||
expect(CODEX.popupLine?.source).toBe(new RegExp(codex.composerPopupLine!).source);
|
||||
expect(CODEX.commandPrefixes).toBe('/!');
|
||||
expect(CODEX.completionSigils).toBe('@$');
|
||||
// A popup pattern the ReDoS guard refuses yields no chrome, rather than chrome without it.
|
||||
const unsafe = structuredClone(getCli('codex')!);
|
||||
unsafe.capabilities.workDetect!.composerPopupLine = '(a+)+b';
|
||||
expect(guardedComposerChrome(unsafe)).toBeNull();
|
||||
});
|
||||
it.each([
|
||||
['/stat', '/'],
|
||||
['/status now', '/'],
|
||||
[' /review', '/'],
|
||||
['!ls -la', '!'],
|
||||
['review @src/app', '@'],
|
||||
['@notes', '@'],
|
||||
['run $skill', '$'],
|
||||
['costs $5', '$'],
|
||||
])('refuses %j before typing (trigger %s)', async (message, trigger) => {
|
||||
expect(composerTriggerRefusal(message, CODEX)).toContain(`"${trigger}"`);
|
||||
await expect(store.stage(target, 'alice', message)).rejects.toThrow(
|
||||
trigger === '/' || trigger === '!' ? /codeman agent send/ : /completion popup/
|
||||
);
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
it.each(['look at @src/app.ts please', 'mail a@b.example', 'pay $5 today', 'a/b and c!'])(
|
||||
'stages %j: no trigger at the start, and the caret word opens nothing',
|
||||
async (message) => {
|
||||
expect(composerTriggerRefusal(message, CODEX)).toBeNull();
|
||||
await expect(store.stage(target, 'alice', message)).resolves.toMatchObject({ submitted: false });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("the quiet time before Enter (codex's 120 ms paste-burst window turns Enter into a newline)", () => {
|
||||
it('waits out the rest of the window after the staged keys before checking and sending Enter', async () => {
|
||||
expect(SUBMIT_QUIET_MS).toBeGreaterThan(120);
|
||||
const receipt = await store.stage(target, 'alice', 'hello');
|
||||
time += 40;
|
||||
await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
|
||||
expect(sleeps).toEqual([SUBMIT_QUIET_MS - 40]);
|
||||
expect(writes).toEqual(['hello', '\r']);
|
||||
});
|
||||
it('does not wait once the window has passed', async () => {
|
||||
const receipt = await store.stage(target, 'alice', 'hello');
|
||||
time += SUBMIT_QUIET_MS;
|
||||
await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
|
||||
expect(sleeps).toEqual([]);
|
||||
});
|
||||
it('still refuses when a keystroke lands during the wait', async () => {
|
||||
store = new GuardedStageStore(() => time, 2, 1000, {
|
||||
sleep: async (ms) => {
|
||||
await fakeSleep(ms);
|
||||
revision++; // the user typed while submit waited
|
||||
},
|
||||
});
|
||||
const receipt = await store.stage(target, 'alice', 'hello');
|
||||
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(
|
||||
/changed; nothing submitted/
|
||||
);
|
||||
expect(writes).toEqual(['hello']);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
|
||||
@@ -80,6 +81,31 @@ describe('App Settings modal structure', () => {
|
||||
expect(system).toContain('id="appSettingsTunnelEnabled"');
|
||||
});
|
||||
|
||||
/**
|
||||
* Owner decision (2026-10-09): every animation setting has its own
|
||||
* Animations section, right after Appearance, so it is easy to find. The
|
||||
* selects are wired by id in entrance-animations.js, not by the load/save
|
||||
* path above, so they get their own check here.
|
||||
*/
|
||||
it('keeps every animation setting in its own Animations section, after Appearance', () => {
|
||||
const modal = settingsModal();
|
||||
const rail = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]);
|
||||
const order = [...modal.matchAll(/<section class="set-section" id="([a-z-]+)"/g)].map((m) => m[1]);
|
||||
for (const list of [rail, order]) {
|
||||
expect(list[list.indexOf('settings-appearance') + 1]).toBe('settings-animations');
|
||||
}
|
||||
const animations = modal.match(/id="settings-animations"([\s\S]*?)<\/section>/)?.[1] ?? '';
|
||||
for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) {
|
||||
expect(animations, `${id} belongs in the Animations section`).toContain(`id="${id}"`);
|
||||
}
|
||||
const appearance = modal.match(/id="settings-appearance"([\s\S]*?)<\/section>/)?.[1] ?? '';
|
||||
expect(appearance).not.toMatch(/id="appSettings[A-Za-z]*Anim"/);
|
||||
const anim = readFileSync(resolve(publicDir, 'entrance-animations.js'), 'utf8');
|
||||
for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) {
|
||||
expect(anim).toContain(`document.getElementById('${id}')`);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Local Echo the first row of the second section', () => {
|
||||
const terminal = settingsModal().match(/id="settings-terminal"([\s\S]*?)<\/section>/);
|
||||
const localEcho = terminal?.[1].indexOf('appSettingsLocalEcho') ?? -1;
|
||||
@@ -170,6 +196,47 @@ describe('App Settings modal structure', () => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Git fetch on an interval (src/git-fetch-scheduler.ts). Both keys are SYNCED, not display keys, because
|
||||
* the server's scheduler is what acts on them: kept per-device, the scheduler would read nobody's number.
|
||||
* Off by default, and the interval clamped on save to the same bounds the schema accepts.
|
||||
*/
|
||||
it('keeps the Git fetch rows synced, off by default, and inside the bounds the scheduler acts on', () => {
|
||||
const modal = settingsModal();
|
||||
const bar = modal.match(/<h4>Bottom bar<\/h4>([\s\S]*?)(?=<div class="set-group-head">)/)?.[1];
|
||||
expect(bar, 'the Git fetch rows are not in the Bottom bar group').toBeTruthy();
|
||||
for (const id of ['appSettingsGitFetchEnabled', 'appSettingsGitFetchIntervalSeconds']) {
|
||||
const at = modal.indexOf(`id="${id}"`);
|
||||
expect(at, `no element ${id}`).toBeGreaterThan(-1);
|
||||
const row = modal.slice(modal.lastIndexOf('<div class="set-row"', at), modal.indexOf('</div>', at));
|
||||
expect(row).toContain('<span class="set-scope">synced</span>');
|
||||
}
|
||||
// The number row's own bounds must agree with the schema's, or the browser sends a value the PUT rejects.
|
||||
expect(modal).toContain('id="appSettingsGitFetchIntervalSeconds" class="set-num" value="300" min="30" max="3600"');
|
||||
|
||||
// Display keys are the half of the per-device split that keeps a value off the server.
|
||||
const displayStart = settingsUi.indexOf('const displayKeys = new Set([');
|
||||
const displayBlock = settingsUi.slice(displayStart, settingsUi.indexOf(']);', displayStart));
|
||||
expect(displayBlock).not.toContain('gitFetchEnabled');
|
||||
expect(displayBlock).not.toContain('gitFetchIntervalSeconds');
|
||||
// The strip destructure is the other half: an aliased key here never reaches the settings PUT.
|
||||
expect(settingsUi).not.toMatch(/gitFetch(?:Enabled|IntervalSeconds):\s*_\w+/);
|
||||
|
||||
// Declared in the .strict() schema: an undeclared key 400s the whole settings PUT.
|
||||
expect(SettingsUpdateSchema.safeParse({ gitFetchEnabled: true, gitFetchIntervalSeconds: 300 }).success).toBe(true);
|
||||
|
||||
// Off by default (only an explicit true loads as on), and an empty box falls back to the default interval.
|
||||
expect(settingsUi).toContain(
|
||||
"document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true;"
|
||||
);
|
||||
expect(settingsUi).toContain(
|
||||
"document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300;"
|
||||
);
|
||||
expect(settingsUi).toMatch(
|
||||
/gitFetchIntervalSeconds: Math\.min\(\s*3600,\s*Math\.max\(30, parseInt\(document\.getElementById\('appSettingsGitFetchIntervalSeconds'\)\.value, 10\) \|\| 300\)\s*\),/
|
||||
);
|
||||
});
|
||||
|
||||
it('has retired the modal-tab chrome everywhere, not just here', () => {
|
||||
// Session Options and Add Case moved onto this same `set-*` surface, so the
|
||||
// old tab classes have no users left. A reappearance means a modal drifted
|
||||
|
||||
+15
-19
@@ -8,19 +8,15 @@
|
||||
* 6. Logout endpoint invalidates session
|
||||
* 7. Settings schema rejects unknown fields
|
||||
*
|
||||
* Port: 3160 (auth tests), 3161 (loopback no-auth tests), 3162 (network override tests)
|
||||
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
|
||||
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
|
||||
|
||||
const AUTH_PORT = 3160;
|
||||
const NOAUTH_PORT = 3161;
|
||||
const NETWORK_OVERRIDE_PORT = 3162;
|
||||
const AUTH_RATE_LIMIT_PORT = 3220;
|
||||
const NOAUTH_NETWORK_PORT = 3221;
|
||||
const TEST_USER = 'admin';
|
||||
const TEST_PASS = 'test-password-12345';
|
||||
|
||||
@@ -30,12 +26,12 @@ function basicAuthHeader(user: string, pass: string): string {
|
||||
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
}
|
||||
|
||||
async function startAuthServer(port: number): Promise<{ server: WebServer; baseUrl: string }> {
|
||||
async function startAuthServer(): Promise<{ server: WebServer; baseUrl: string }> {
|
||||
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
||||
process.env.CODEMAN_USERNAME = TEST_USER;
|
||||
const server = new WebServer(port, false, true);
|
||||
const server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
return { server, baseUrl: `http://localhost:${port}` };
|
||||
return { server, baseUrl: `http://localhost:${server.boundPort}` };
|
||||
}
|
||||
|
||||
async function getSessionCookie(baseUrl: string): Promise<string> {
|
||||
@@ -51,7 +47,7 @@ async function getSessionCookie(baseUrl: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function exhaustAuthFailures(baseUrl: string, prefix: string): Promise<void> {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let i = 0; i < AUTH_FAILURE_MAX; i++) {
|
||||
const res = await fetch(`${baseUrl}/api/status`, {
|
||||
headers: { Authorization: basicAuthHeader(TEST_USER, `${prefix}-${i}`) },
|
||||
});
|
||||
@@ -66,9 +62,9 @@ describe('Auth Security', () => {
|
||||
beforeAll(async () => {
|
||||
process.env.CODEMAN_PASSWORD = TEST_PASS;
|
||||
process.env.CODEMAN_USERNAME = TEST_USER;
|
||||
server = new WebServer(AUTH_PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${AUTH_PORT}`;
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -194,7 +190,7 @@ describe('Auth Security', () => {
|
||||
let rateBaseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer(AUTH_RATE_LIMIT_PORT));
|
||||
({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -347,7 +343,7 @@ describe('No-Auth Server Startup Policy', () => {
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
|
||||
server = new WebServer(NOAUTH_PORT, false, true, '127.0.0.1');
|
||||
server = new WebServer(0, false, true, '127.0.0.1');
|
||||
await server.start();
|
||||
});
|
||||
|
||||
@@ -359,7 +355,7 @@ describe('No-Auth Server Startup Policy', () => {
|
||||
});
|
||||
|
||||
it('allows loopback requests without auth when no password is configured', async () => {
|
||||
const res = await fetch(`http://localhost:${NOAUTH_PORT}/api/status`);
|
||||
const res = await fetch(`http://localhost:${server.boundPort}/api/status`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
@@ -368,10 +364,10 @@ describe('No-Auth Server Startup Policy', () => {
|
||||
// bind without a password no longer refuses to start — it starts and warns,
|
||||
// pointing at how to secure it. See docs/security-architecture.md.
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const networkServer = new WebServer(NOAUTH_NETWORK_PORT, false, true, '0.0.0.0');
|
||||
const networkServer = new WebServer(0, false, true, '0.0.0.0');
|
||||
|
||||
await expect(networkServer.start()).resolves.toBeUndefined();
|
||||
const res = await fetch(`http://localhost:${NOAUTH_NETWORK_PORT}/api/status`);
|
||||
const res = await fetch(`http://localhost:${networkServer.boundPort}/api/status`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const warned = warnSpy.mock.calls.flat().join('\n');
|
||||
@@ -393,10 +389,10 @@ describe('No-Auth Server Startup Policy', () => {
|
||||
});
|
||||
|
||||
it('allows non-loopback startup with the explicit unauthenticated-network override', async () => {
|
||||
const networkServer = new WebServer(NETWORK_OVERRIDE_PORT, false, true, '0.0.0.0', undefined, true);
|
||||
const networkServer = new WebServer(0, false, true, '0.0.0.0', undefined, true);
|
||||
|
||||
await networkServer.start();
|
||||
const res = await fetch(`http://localhost:${NETWORK_OVERRIDE_PORT}/api/status`);
|
||||
const res = await fetch(`http://localhost:${networkServer.boundPort}/api/status`);
|
||||
expect(res.status).toBe(200);
|
||||
await networkServer.stop();
|
||||
});
|
||||
|
||||
@@ -8,14 +8,12 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3197;
|
||||
|
||||
describe('reverse-proxy base path: server wiring', () => {
|
||||
let server: WebServer;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let app: any;
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true, '127.0.0.1', undefined, false, '/codeman');
|
||||
server = new WebServer(0, false, true, '127.0.0.1', undefined, false, '/codeman');
|
||||
await server.start();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
app = (server as any).app;
|
||||
@@ -66,7 +64,9 @@ describe('reverse-proxy base path: server wiring', () => {
|
||||
const { WebSocket } = await import('ws');
|
||||
const close = (path: string) =>
|
||||
new Promise<{ code: number; reason: string }>((resolve) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}${path}`, { headers: { origin: `http://127.0.0.1:${PORT}` } });
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${server.boundPort}${path}`, {
|
||||
headers: { origin: `http://127.0.0.1:${server.boundPort}` },
|
||||
});
|
||||
ws.on('close', (code, reason) => resolve({ code, reason: reason.toString() }));
|
||||
ws.on('error', (e) => resolve({ code: -1, reason: String(e) }));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* @fileoverview Tests for the bounded path probe (src/utils/bounded-path-probe.ts):
|
||||
* a stat() that never settles (an unreachable hard network mount) must not hold
|
||||
* the caller past the timeout, must read as "unknown" rather than "absent", must
|
||||
* not be re-issued while it is still pending, must not let stalled probes pile up
|
||||
* in libuv's shared threadpool, and must not make unrelated healthy paths unknown.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: { stat: vi.fn() },
|
||||
}));
|
||||
|
||||
// The kernel mount table the probe scopes a stall by. `/mnt/nas` and `/mnt/nas b`
|
||||
// (a mount point with a space, octal-escaped in the table) are network mounts;
|
||||
// everything else sits on the root filesystem. `null` = no table (not Linux).
|
||||
const mounts = vi.hoisted(() => ({
|
||||
table: null as string | null,
|
||||
default: [
|
||||
'sysfs /sys sysfs rw 0 0',
|
||||
'/dev/sda1 / ext4 rw 0 0',
|
||||
'nas:/export /mnt/nas nfs rw,hard 0 0',
|
||||
'nas:/other /mnt/nas\\040b nfs rw,hard 0 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
}));
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
const readFileSync = ((path: unknown, ...rest: unknown[]) => {
|
||||
if (String(path) === '/proc/self/mounts') {
|
||||
if (mounts.table === null) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
return mounts.table;
|
||||
}
|
||||
return (actual.readFileSync as (...a: unknown[]) => unknown)(path, ...rest);
|
||||
}) as typeof actual.readFileSync;
|
||||
return { ...actual, readFileSync, default: { ...actual, readFileSync } };
|
||||
});
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import {
|
||||
boundedPathExists,
|
||||
describeUnknownPath,
|
||||
isNearStalledPath,
|
||||
probePath,
|
||||
probePathKind,
|
||||
unknownPathReason,
|
||||
} from '../src/utils/bounded-path-probe.js';
|
||||
import { MAX_STALLED_PATH_PROBES, PATH_PROBE_STALL_CEILING, PATH_PROBE_TIMEOUT_MS } from '../src/config/path-probe.js';
|
||||
|
||||
const stat = vi.mocked(fs.stat);
|
||||
const dirStats = { isDirectory: () => true } as never;
|
||||
const fileStats = { isDirectory: () => false } as never;
|
||||
|
||||
let releases: Map<string, () => void>;
|
||||
let warn: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
/**
|
||||
* Make the first stat() of each given path hang until released (the mount is
|
||||
* down); every later stat, and every other path, answers "a directory exists".
|
||||
*/
|
||||
function hangOn(paths: string[]): Map<string, () => void> {
|
||||
stat.mockImplementation((path) => {
|
||||
if (!paths.includes(String(path)) || releases.has(String(path))) return Promise.resolve(dirStats);
|
||||
return new Promise((resolve) => {
|
||||
releases.set(String(path), () => resolve(dirStats));
|
||||
});
|
||||
});
|
||||
return releases;
|
||||
}
|
||||
|
||||
/** Start probes for `paths` and let them time out, leaving each one stalled. */
|
||||
async function stall(paths: string[]): Promise<void> {
|
||||
const pending = paths.map((p) => probePath(p));
|
||||
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
|
||||
expect(await Promise.all(pending)).toEqual(paths.map(() => 'unknown'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mounts.table = mounts.default;
|
||||
releases = new Map();
|
||||
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Settle every stalled stat so module state does not leak into the next test.
|
||||
releases.forEach((release) => release());
|
||||
if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(0);
|
||||
else await new Promise((r) => setTimeout(r, 0));
|
||||
vi.useRealTimers();
|
||||
stat.mockReset();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
describe('probePath', () => {
|
||||
it('tells present, absent and unreadable apart', async () => {
|
||||
stat.mockImplementation(async (path) => {
|
||||
if (String(path) === '/present') return dirStats;
|
||||
if (String(path) === '/eio') throw Object.assign(new Error('EIO'), { code: 'EIO' });
|
||||
if (String(path) === '/notdir/child') throw Object.assign(new Error('ENOTDIR'), { code: 'ENOTDIR' });
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
expect(await probePath('/present')).toBe('present');
|
||||
expect(await probePath('/missing')).toBe('absent');
|
||||
expect(await probePath('/notdir/child')).toBe('absent');
|
||||
// A soft mount that gave up answers EIO: that is not proof the path is gone.
|
||||
expect(await probePath('/eio')).toBe('unknown');
|
||||
expect(await boundedPathExists('/present')).toBe(true);
|
||||
expect(await boundedPathExists('/missing')).toBe(false);
|
||||
expect(await boundedPathExists('/eio')).toBe(false);
|
||||
// An error answer is neither a stall nor a refusal.
|
||||
expect(unknownPathReason('/eio')).toBe('unreadable');
|
||||
expect(describeUnknownPath('Case folder', '/eio')).toBe('Case folder is not responding or not readable: /eio');
|
||||
});
|
||||
|
||||
it('reports whether a present path is a directory', async () => {
|
||||
stat.mockImplementation(async (path) => (String(path) === '/dir' ? dirStats : fileStats));
|
||||
expect(await probePathKind('/dir')).toBe('directory');
|
||||
expect(await probePathKind('/file')).toBe('file');
|
||||
});
|
||||
|
||||
it('answers unknown (not absent) after the timeout, and does not re-probe until the stat settles', async () => {
|
||||
vi.useFakeTimers();
|
||||
hangOn(['/mnt/stalled/case']);
|
||||
|
||||
const result = probePath('/mnt/stalled/case');
|
||||
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
|
||||
expect(await result).toBe('unknown');
|
||||
|
||||
// A second caller gets the cached verdict immediately, without another stat.
|
||||
expect(await probePath('/mnt/stalled/case')).toBe('unknown');
|
||||
expect(stat).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Once the mount answers, the path is probed afresh.
|
||||
releases.get('/mnt/stalled/case')!();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(await probePath('/mnt/stalled/case')).toBe('present');
|
||||
expect(stat).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shares one in-flight stat between concurrent callers of the same path', async () => {
|
||||
hangOn(['/slow']);
|
||||
const a = probePath('/slow');
|
||||
const b = boundedPathExists('/slow');
|
||||
expect(stat).toHaveBeenCalledTimes(1);
|
||||
releases.get('/slow')!();
|
||||
expect(await a).toBe('present');
|
||||
expect(await b).toBe(true);
|
||||
});
|
||||
|
||||
it('does not give concurrent healthy probes a false negative', async () => {
|
||||
stat.mockImplementation(async () => dirStats);
|
||||
const results = await Promise.all(['/a', '/b', '/c', '/d', '/e'].map((p) => boundedPathExists(p)));
|
||||
expect(results).toEqual([true, true, true, true, true]);
|
||||
});
|
||||
|
||||
it('still probes a healthy path as present while fewer unrelated paths are stalled than the cap', async () => {
|
||||
vi.useFakeTimers();
|
||||
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES - 1 }, (_, i) => `/mnt/nas-${i}/project`);
|
||||
hangOn(dead);
|
||||
await stall(dead);
|
||||
|
||||
expect(await probePath('/home/user/codeman-cases/healthy')).toBe('present');
|
||||
expect(await boundedPathExists('/home/user/codeman-cases/healthy/CLAUDE.md')).toBe(true);
|
||||
expect(isNearStalledPath('/home/user/codeman-cases/healthy')).toBe(false);
|
||||
});
|
||||
|
||||
it('answers unknown, without a stat, for paths near a stalled one', async () => {
|
||||
vi.useFakeTimers();
|
||||
hangOn(['/mnt/nas/project-one']);
|
||||
await stall(['/mnt/nas/project-one']);
|
||||
stat.mockClear();
|
||||
|
||||
// Its own files, and a sibling linked case on the same mount.
|
||||
expect(await probePath('/mnt/nas/project-one/CLAUDE.md')).toBe('unknown');
|
||||
expect(await probePath('/mnt/nas/project-two')).toBe('unknown');
|
||||
expect(isNearStalledPath('/mnt/nas/project-two/.claude/settings.local.json')).toBe(true);
|
||||
expect(stat).not.toHaveBeenCalled();
|
||||
|
||||
releases.get('/mnt/nas/project-one')!();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(isNearStalledPath('/mnt/nas/project-two')).toBe(false);
|
||||
expect(await probePath('/mnt/nas/project-two')).toBe('present');
|
||||
});
|
||||
|
||||
it('reads octal-escaped mount points from the table', async () => {
|
||||
vi.useFakeTimers();
|
||||
hangOn(['/mnt/nas b/one']);
|
||||
await stall(['/mnt/nas b/one']);
|
||||
expect(isNearStalledPath('/mnt/nas b/two')).toBe(true);
|
||||
expect(isNearStalledPath('/mnt/nas/two')).toBe(false);
|
||||
});
|
||||
|
||||
it('never takes the root filesystem down with a stalled path on it, only that path', async () => {
|
||||
vi.useFakeTimers();
|
||||
hangOn(['/srv/projects/stuck']);
|
||||
await stall(['/srv/projects/stuck']);
|
||||
|
||||
expect(await probePath('/srv/projects/stuck/CLAUDE.md')).toBe('unknown');
|
||||
expect(await probePath('/srv/projects/other')).toBe('present');
|
||||
expect(await probePath('/home/user/codeman-cases/one')).toBe('present');
|
||||
});
|
||||
|
||||
it('narrows a stall on a local mount to the stalled path, even when that mount is not /', async () => {
|
||||
// /home is its own local filesystem; ~/nas is a symlink to a network mount, so
|
||||
// the stalled path is typed under /home. Only network and FUSE mounts widen.
|
||||
vi.useFakeTimers();
|
||||
mounts.table = [mounts.default, '/dev/sdb1 /home ext4 rw,relatime 0 0', ''].join('\n');
|
||||
hangOn(['/home/user/nas/project']);
|
||||
await stall(['/home/user/nas/project']);
|
||||
stat.mockClear();
|
||||
|
||||
expect(await probePath('/home/user/nas/project/CLAUDE.md')).toBe('unknown');
|
||||
expect(isNearStalledPath('/home/user/codeman-cases/one')).toBe(false);
|
||||
expect(await probePath('/home/user/codeman-cases/one')).toBe('present');
|
||||
expect(await probePath('/home/user/nas/other')).toBe('present');
|
||||
expect(stat).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('widens a stall to the whole mount for network and FUSE filesystems', async () => {
|
||||
vi.useFakeTimers();
|
||||
mounts.table = [
|
||||
mounts.default,
|
||||
'nas:/four /srv/nas4 nfs4 rw,hard 0 0',
|
||||
'user@host:/ /srv/sshfs fuse.sshfs rw 0 0',
|
||||
'//nas/share /srv/smb cifs rw 0 0',
|
||||
'',
|
||||
].join('\n');
|
||||
const dead = ['/srv/nas4/one', '/srv/sshfs/one'];
|
||||
hangOn(dead);
|
||||
await stall(dead);
|
||||
|
||||
expect(isNearStalledPath('/srv/nas4/two')).toBe(true);
|
||||
expect(isNearStalledPath('/srv/sshfs/two')).toBe(true);
|
||||
expect(isNearStalledPath('/srv/smb/two')).toBe(false);
|
||||
expect(isNearStalledPath('/srv/elsewhere')).toBe(false);
|
||||
});
|
||||
|
||||
it('narrows a stall to the stalled path when there is no mount table', async () => {
|
||||
vi.useFakeTimers();
|
||||
mounts.table = null;
|
||||
hangOn(['/mnt/nas/project-one']);
|
||||
await stall(['/mnt/nas/project-one']);
|
||||
|
||||
expect(await probePath('/mnt/nas/project-one/CLAUDE.md')).toBe('unknown');
|
||||
expect(await probePath('/mnt/nas/project-two')).toBe('present');
|
||||
});
|
||||
|
||||
it('refuses new stats once stalled probes would tie up the threadpool, answering unknown', async () => {
|
||||
vi.useFakeTimers();
|
||||
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/dead-${i}/case`);
|
||||
hangOn(dead);
|
||||
await stall(dead);
|
||||
stat.mockClear();
|
||||
|
||||
// Every slot is held by a stat that never returned: refuse another, but never
|
||||
// claim the path is absent.
|
||||
expect(await probePath('/healthy/elsewhere')).toBe('unknown');
|
||||
expect(stat).not.toHaveBeenCalled();
|
||||
|
||||
// Once the stalled stats settle, probing resumes normally.
|
||||
releases.forEach((release) => release());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(await probePath('/healthy/elsewhere')).toBe('present');
|
||||
expect(stat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lets a pastCap probe through the cap, still bounded and still recorded as stalled', async () => {
|
||||
vi.useFakeTimers();
|
||||
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/full-${i}/case`);
|
||||
hangOn([...dead, '/mnt/another-dead/case']);
|
||||
await stall(dead);
|
||||
stat.mockClear();
|
||||
|
||||
expect(await probePath('/healthy/explicit', { pastCap: true })).toBe('present');
|
||||
|
||||
const hung = probePath('/mnt/another-dead/case', { pastCap: true });
|
||||
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
|
||||
expect(await hung).toBe('unknown');
|
||||
// A retry is answered from the stall record, not with another stat.
|
||||
expect(await probePath('/mnt/another-dead/case', { pastCap: true })).toBe('unknown');
|
||||
expect(stat).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops pastCap probes at the threadpool ceiling, answering unknown without a stat', async () => {
|
||||
vi.useFakeTimers();
|
||||
// Fill the bulk cap, then let pastCap probes stall until the ceiling is reached.
|
||||
const dead = Array.from({ length: PATH_PROBE_STALL_CEILING + 1 }, (_, i) => `/mnt/ceiling-${i}/case`);
|
||||
hangOn(dead);
|
||||
await stall(dead.slice(0, MAX_STALLED_PATH_PROBES));
|
||||
for (const path of dead.slice(MAX_STALLED_PATH_PROBES, PATH_PROBE_STALL_CEILING)) {
|
||||
const hung = probePath(path, { pastCap: true });
|
||||
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
|
||||
expect(await hung).toBe('unknown');
|
||||
}
|
||||
stat.mockClear();
|
||||
|
||||
// One worker must stay free: no new stat, even for an explicit request.
|
||||
const refused = probePath(dead[PATH_PROBE_STALL_CEILING], { pastCap: true });
|
||||
await vi.advanceTimersByTimeAsync(PATH_PROBE_TIMEOUT_MS);
|
||||
expect(await refused).toBe('unknown');
|
||||
expect(stat).not.toHaveBeenCalled();
|
||||
expect(await probePath('/healthy/explicit', { pastCap: true })).toBe('unknown');
|
||||
expect(stat).not.toHaveBeenCalled();
|
||||
// ...and the message says the folder was never checked, rather than blaming it.
|
||||
expect(unknownPathReason('/healthy/explicit', { pastCap: true })).toBe('refused');
|
||||
expect(unknownPathReason(dead[0], { pastCap: true })).toBe('stalled');
|
||||
expect(describeUnknownPath('workingDir', '/healthy/explicit', { pastCap: true })).toMatch(
|
||||
/^workingDir was not checked: .*not answering.*: \/healthy\/explicit$/
|
||||
);
|
||||
expect(describeUnknownPath('workingDir', dead[0], { pastCap: true })).toBe(
|
||||
`workingDir is not responding or not readable: ${dead[0]}`
|
||||
);
|
||||
|
||||
// Once one stalled stat settles, an explicit request is probed again.
|
||||
releases.get(dead[0])!();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(await probePath('/healthy/explicit', { pastCap: true })).toBe('present');
|
||||
});
|
||||
|
||||
it('keeps the bulk cap below the ceiling, so a pastCap probe has room', async () => {
|
||||
// Read under a controlled environment: the limits are computed at import from
|
||||
// UV_THREADPOOL_SIZE and CODEMAN_PATH_PROBE_MAX_STALLED, which the test process
|
||||
// could otherwise inherit.
|
||||
const saved = { uv: process.env.UV_THREADPOOL_SIZE, max: process.env.CODEMAN_PATH_PROBE_MAX_STALLED };
|
||||
const limitsUnder = async (uv: string | undefined, max: string | undefined) => {
|
||||
if (uv === undefined) delete process.env.UV_THREADPOOL_SIZE;
|
||||
else process.env.UV_THREADPOOL_SIZE = uv;
|
||||
if (max === undefined) delete process.env.CODEMAN_PATH_PROBE_MAX_STALLED;
|
||||
else process.env.CODEMAN_PATH_PROBE_MAX_STALLED = max;
|
||||
vi.resetModules();
|
||||
return import('../src/config/path-probe.js');
|
||||
};
|
||||
try {
|
||||
const defaults = await limitsUnder(undefined, undefined);
|
||||
expect([defaults.MAX_STALLED_PATH_PROBES, defaults.PATH_PROBE_STALL_CEILING]).toEqual([2, 3]);
|
||||
const bigPool = await limitsUnder('8', undefined);
|
||||
expect([bigPool.MAX_STALLED_PATH_PROBES, bigPool.PATH_PROBE_STALL_CEILING]).toEqual([6, 7]);
|
||||
// An override may reach the ceiling but never pass it.
|
||||
const overridden = await limitsUnder(undefined, '64');
|
||||
expect(overridden.MAX_STALLED_PATH_PROBES).toBe(overridden.PATH_PROBE_STALL_CEILING);
|
||||
} finally {
|
||||
if (saved.uv === undefined) delete process.env.UV_THREADPOOL_SIZE;
|
||||
else process.env.UV_THREADPOOL_SIZE = saved.uv;
|
||||
if (saved.max === undefined) delete process.env.CODEMAN_PATH_PROBE_MAX_STALLED;
|
||||
else process.env.CODEMAN_PATH_PROBE_MAX_STALLED = saved.max;
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
|
||||
it('warns once when a path first stalls and once when the cap engages', async () => {
|
||||
vi.useFakeTimers();
|
||||
const dead = Array.from({ length: MAX_STALLED_PATH_PROBES }, (_, i) => `/mnt/gone-${i}/case`);
|
||||
hangOn(dead);
|
||||
await stall([dead[0]]);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(String(warn.mock.calls[0][0])).toContain('/mnt/gone-0/case');
|
||||
|
||||
// Asking again about the same stalled path does not warn again.
|
||||
await probePath(dead[0]);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await stall(dead.slice(1));
|
||||
warn.mockClear();
|
||||
await probePath('/healthy/one');
|
||||
await probePath('/healthy/two');
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(String(warn.mock.calls[0][0])).toMatch(/stalled/i);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@
|
||||
* because the mismatch itself needs two viewports to stage against live tmux.
|
||||
* Without the fix the first assertion below sees one fetch instead of two.
|
||||
*
|
||||
* Port: 3252 (capture geometry retry)
|
||||
* Port: ephemeral
|
||||
*
|
||||
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-geometry-retry.browser.test.ts
|
||||
*/
|
||||
@@ -29,15 +29,15 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3252;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
let baseUrl: string;
|
||||
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true); // testMode
|
||||
server = new WebServer(0, false, true); // testMode
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 60_000);
|
||||
|
||||
@@ -176,7 +176,7 @@ async function stubTerminalAtRequestedSize(page: Page, counter: { n: number; url
|
||||
const WIDER_THAN_ANY_TERMINAL_COLS = 500;
|
||||
|
||||
async function openSession(page: Page): Promise<string> {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
|
||||
// xterm is loaded from /vendor, so the terminal appears a beat after the app.
|
||||
// Without it `app.terminal.rows` reads 0 and every height comparison below
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* own `json()` call, which is the one place guaranteed to land after the
|
||||
* headers and before the chunked write.
|
||||
*
|
||||
* Port: 3256 (capture load window)
|
||||
* Port: ephemeral
|
||||
*
|
||||
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-load-window.browser.test.ts
|
||||
*/
|
||||
@@ -23,16 +23,16 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3256;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
let baseUrl: string;
|
||||
const MARKER = 'ARRIVED-AFTER-THE-CAPTURE';
|
||||
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true); // testMode
|
||||
server = new WebServer(0, false, true); // testMode
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 60_000);
|
||||
|
||||
@@ -115,7 +115,7 @@ async function runLoad(page: Page, sessionId: string, source: string): Promise<n
|
||||
}
|
||||
|
||||
async function openSession(page: Page): Promise<string> {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
|
||||
// xterm loads from /vendor, so the terminal appears a beat after the app.
|
||||
// Without it every buffer assertion below would throw rather than compare.
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/** @fileoverview Add Case → Create New → "Create in a custom folder", end to end: real server, real Chromium, real folders. */
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
declare const PathPicker: any; // evaluated inside the page, where it is a global
|
||||
|
||||
describe('Create a case in a custom folder', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
let parent: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
parent = mkdtempSync(join(homedir(), 'custom-case-'));
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
const open = async () => {
|
||||
await page.evaluate(() => {
|
||||
(window as any).app.showCreateCaseModal();
|
||||
(window as any).app.switchCaseModalTab('case-create');
|
||||
});
|
||||
};
|
||||
const toastText = () =>
|
||||
page.evaluate(() => [...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'));
|
||||
|
||||
it('hides the parent-folder field until the box is ticked, and shows what it will create', async () => {
|
||||
await open();
|
||||
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(false);
|
||||
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(true);
|
||||
await page.fill('#newCaseName', 'my-app');
|
||||
await page.fill('#newCasePath', '~/projects/');
|
||||
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: ~/projects/my-app');
|
||||
});
|
||||
|
||||
it('is exclusive with the Docker option, in both directions', async () => {
|
||||
await open();
|
||||
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||
expect(await page.isDisabled('#newCaseDocker')).toBe(true);
|
||||
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||
expect(await page.isDisabled('#newCaseDocker')).toBe(false);
|
||||
await page.click('label.checkbox-row:has(#newCaseDocker)');
|
||||
expect(await page.isDisabled('#newCaseCustomPathToggle')).toBe(true);
|
||||
await page.click('label.checkbox-row:has(#newCaseDocker)');
|
||||
});
|
||||
|
||||
it('Browse opens the folder picker for directories only and fills the field with the choice', async () => {
|
||||
await open();
|
||||
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||
await page.fill('#newCaseName', 'picked');
|
||||
const opts = await page.evaluate(() => {
|
||||
// A top-level `const` in a classic script: a global binding, not a window property.
|
||||
const picker = PathPicker;
|
||||
let captured: any = null;
|
||||
const original = picker.open;
|
||||
picker.open = (o: any) => (captured = o);
|
||||
(document.querySelector('#newCaseCustomPathRow .path-input-browse') as HTMLElement).click();
|
||||
picker.open = original;
|
||||
captured.onSelect('/srv/work');
|
||||
return { directoriesOnly: captured.directoriesOnly };
|
||||
});
|
||||
expect(opts.directoriesOnly).toBe(true);
|
||||
expect(await page.inputValue('#newCasePath')).toBe('/srv/work');
|
||||
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: /srv/work/picked');
|
||||
});
|
||||
|
||||
it('asks for a folder when the box is ticked and the field is empty, and creates nothing', async () => {
|
||||
await open();
|
||||
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||
await page.fill('#newCaseName', 'no-folder');
|
||||
await page.evaluate(() => (window as any).app.createCase());
|
||||
expect(await toastText()).toMatch(/Choose the folder/);
|
||||
});
|
||||
|
||||
it('creates the case in the chosen folder, scaffolds it, and lists it at that path', async () => {
|
||||
await open();
|
||||
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||
await page.fill('#newCaseName', 'in-custom');
|
||||
await page.fill('#newCasePath', parent);
|
||||
await page.evaluate(() => (window as any).app.submitCaseModal());
|
||||
await page.waitForFunction(() =>
|
||||
/created in/.test([...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'))
|
||||
);
|
||||
const target = join(parent, 'in-custom');
|
||||
expect(readFileSync(join(target, 'CLAUDE.md'), 'utf8')).toContain('in-custom');
|
||||
expect(existsSync(join(target, 'src'))).toBe(true);
|
||||
const cases = await page.evaluate(async () => {
|
||||
const body = await (await fetch('/api/cases')).json();
|
||||
return Array.isArray(body) ? body : body.data;
|
||||
});
|
||||
expect(cases.find((c: { name: string }) => c.name === 'in-custom')).toMatchObject({ path: target });
|
||||
expect(existsSync(join(homedir(), 'codeman-cases', 'in-custom'))).toBe(false);
|
||||
expect(await page.isVisible('#createCaseModal.active')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the server’s reason for a folder that already has files, and leaves it untouched', async () => {
|
||||
const busy = join(parent, 'busy');
|
||||
mkdirSync(busy);
|
||||
writeFileSync(join(busy, 'keep.txt'), 'mine');
|
||||
await open();
|
||||
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||
await page.fill('#newCaseName', 'busy');
|
||||
await page.fill('#newCasePath', parent);
|
||||
await page.evaluate(() => (window as any).app.createCase());
|
||||
await page.waitForFunction(() =>
|
||||
/Link Existing/.test([...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'))
|
||||
);
|
||||
expect(readFileSync(join(busy, 'keep.txt'), 'utf8')).toBe('mine');
|
||||
expect(existsSync(join(busy, 'CLAUDE.md'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rewords the "under ~/codeman-cases" hints while a custom folder is picked', async () => {
|
||||
await open();
|
||||
expect(await page.textContent('#newCaseNameHint')).toMatch(/Created in ~\/codeman-cases/);
|
||||
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||
expect(await page.textContent('#newCaseNameHint')).toMatch(/parent folder below/);
|
||||
expect(await page.textContent('#newCaseBlurb')).toMatch(/a folder you choose/);
|
||||
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||
expect(await page.textContent('#newCaseNameHint')).toMatch(/Created in ~\/codeman-cases/);
|
||||
expect(await page.textContent('#newCaseBlurb')).toMatch(/under ~\/codeman-cases/);
|
||||
});
|
||||
|
||||
it('keeps / as the root parent instead of sending an empty path', async () => {
|
||||
await open();
|
||||
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||
await page.fill('#newCaseName', 'at-root');
|
||||
await page.fill('#newCasePath', '/');
|
||||
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: /at-root');
|
||||
expect(await page.evaluate(() => (window as any).app._newCaseTargetPath())).toBe('/at-root');
|
||||
});
|
||||
|
||||
it('names the folder the server created in the success toast (~ expanded)', async () => {
|
||||
await open();
|
||||
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||
await page.fill('#newCaseName', 'via-tilde');
|
||||
await page.fill('#newCasePath', `~/${basename(parent)}`);
|
||||
await page.evaluate(() => (window as any).app.submitCaseModal());
|
||||
const target = join(realpathSync(parent), 'via-tilde');
|
||||
await page.waitForFunction(
|
||||
(t) => [...document.querySelectorAll('.toast')].some((el) => el.textContent?.includes(t)),
|
||||
target
|
||||
);
|
||||
expect(await toastText()).not.toMatch(/created in ~\//);
|
||||
});
|
||||
|
||||
it('starts unticked every time the modal opens', async () => {
|
||||
await open();
|
||||
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
|
||||
await page.fill('#newCasePath', '/tmp');
|
||||
await open();
|
||||
expect(await page.isChecked('#newCaseCustomPathToggle')).toBe(false);
|
||||
expect(await page.inputValue('#newCasePath')).toBe('');
|
||||
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @fileoverview "Create in a custom folder" (#535) on a parent folder that does not
|
||||
* answer (#516): `prepareNewCasePath` must ask the bounded path probe first, and give
|
||||
* up with UNREACHABLE within the probe timeout instead of reaching the realpath / stat /
|
||||
* lstat / readdir calls that would wait on a hard mount forever.
|
||||
*
|
||||
* Only the chosen dead paths hang; everything else is the real filesystem.
|
||||
* Port: none.
|
||||
*/
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const probe = vi.hoisted(() => {
|
||||
// Short probe timeout so a stall costs ~100 ms, read at import.
|
||||
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100';
|
||||
return { dead: '/mnt/dead-nas-case-parent', releases: [] as Array<() => void>, touched: [] as string[] };
|
||||
});
|
||||
|
||||
/** A call on the dead path never settles (a hard mount), until afterEach releases it. */
|
||||
function hangOnDead<F extends (...a: never[]) => unknown>(name: string, real: F): F {
|
||||
return ((path: string, ...rest: unknown[]) => {
|
||||
if (String(path) === probe.dead || String(path).startsWith(probe.dead + '/')) {
|
||||
probe.touched.push(`${name} ${String(path)}`);
|
||||
return new Promise((_resolve, reject) => {
|
||||
probe.releases.push(() => reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })));
|
||||
});
|
||||
}
|
||||
return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest);
|
||||
}) as unknown as F;
|
||||
}
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||
const stat = hangOnDead('stat', actual.stat);
|
||||
return { ...actual, stat, default: { ...actual, stat } };
|
||||
});
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
const promises = {
|
||||
...actual.promises,
|
||||
realpath: hangOnDead('realpath', actual.promises.realpath),
|
||||
stat: hangOnDead('stat', actual.promises.stat),
|
||||
lstat: hangOnDead('lstat', actual.promises.lstat),
|
||||
readdir: hangOnDead('readdir', actual.promises.readdir),
|
||||
};
|
||||
return { ...actual, promises, default: { ...actual, promises } };
|
||||
});
|
||||
|
||||
import { prepareNewCasePath } from '../src/web/case-path.js';
|
||||
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'case-path-unreachable-')));
|
||||
const ctx = { home: join(root, 'home'), dataDir: join(root, 'home', '.codeman'), casesDirs: [join(root, 'cases')] };
|
||||
|
||||
afterEach(async () => {
|
||||
probe.releases.splice(0).forEach((release) => release());
|
||||
probe.touched.length = 0;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
delete process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
describe('prepareNewCasePath on a parent folder that does not answer', () => {
|
||||
it('answers UNREACHABLE within the probe timeout and never touches the path unbounded', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const result = await Promise.race([
|
||||
prepareNewCasePath(`${probe.dead}/new-case`, ctx),
|
||||
new Promise<'hung'>((resolve) => setTimeout(() => resolve('hung'), 2_000)),
|
||||
]);
|
||||
warn.mockRestore();
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: 'UNREACHABLE' });
|
||||
expect((result as { reason: string }).reason).toBe(
|
||||
`The parent folder is not responding or not readable: ${probe.dead}`
|
||||
);
|
||||
// Only the bounded probe's own stat reached the dead mount.
|
||||
expect(probe.touched).toEqual([`stat ${probe.dead}`]);
|
||||
});
|
||||
|
||||
it('still reports a parent that definitely does not exist as NOT_FOUND', async () => {
|
||||
expect(await prepareNewCasePath(join(root, 'no-such-parent', 'new-case'), ctx)).toMatchObject({
|
||||
ok: false,
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
// @vitest-environment node
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { blockedReason, expandHome, prepareNewCasePath, type NewCasePathContext } from '../src/web/case-path.js';
|
||||
|
||||
let root: string;
|
||||
let home: string;
|
||||
let ctx: NewCasePathContext;
|
||||
|
||||
beforeEach(() => {
|
||||
// Resolved: prepareNewCasePath answers with symlink-resolved paths (macOS temp is under /private).
|
||||
root = realpathSync(mkdtempSync(join(tmpdir(), 'case-path-')));
|
||||
home = join(root, 'home');
|
||||
mkdirSync(join(home, 'code'), { recursive: true });
|
||||
ctx = { home, dataDir: join(home, '.codeman') };
|
||||
});
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('expandHome', () => {
|
||||
it('expands ~ and ~/x only', () => {
|
||||
expect(expandHome('~', '/h')).toBe('/h');
|
||||
expect(expandHome('~/code/app', '/h')).toBe('/h/code/app');
|
||||
expect(expandHome('~other/x', '/h')).toBe('~other/x');
|
||||
expect(expandHome('/abs/~/x', '/h')).toBe('/abs/~/x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('blockedReason', () => {
|
||||
const c = { home: '/home/u', dataDir: '/home/u/.codeman' };
|
||||
it.each([
|
||||
['/', /root/],
|
||||
['/etc', /system directory/],
|
||||
['/etc/cron.d/x', /system directory/],
|
||||
['/usr/local/src', /system directory/],
|
||||
['/proc/1', /system directory/],
|
||||
['/home/u', /home folder itself/],
|
||||
['/home/u/.ssh', /credentials/],
|
||||
['/home/u/.ssh/proj', /credentials/],
|
||||
['/home/u/.aws/x', /credentials/],
|
||||
['/home/u/.claude/skills/x', /configuration/],
|
||||
['/home/u/.codeman/cases/x', /data folder/],
|
||||
['/home/u/.codeman-beta/x', /data folder/],
|
||||
])('refuses %s', (p, why) => expect(blockedReason(p, c)).toMatch(why));
|
||||
|
||||
it.each(['/home/u/code/app', '/home/u/.config/app', '/srv/projects/x', '/opt/work', '/tmp/x', '/home/u/etc/app'])(
|
||||
'allows %s (a name that merely contains a blocked word is fine)',
|
||||
(p) => expect(blockedReason(p, c)).toBeNull()
|
||||
);
|
||||
|
||||
it('does not treat /etcetera or /usrlocal as the system directories', () => {
|
||||
expect(blockedReason('/etcetera/x', c)).toBeNull();
|
||||
expect(blockedReason('/usrlocal', c)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a cases directory and its direct children, when given', () => {
|
||||
const withCases = { ...c, casesDirs: ['/home/u/codeman-cases'] };
|
||||
expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/plain Create New/);
|
||||
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/plain Create New/);
|
||||
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toContain('/home/u/codeman-cases');
|
||||
// Two messages: the cases folder is not "in" itself, and a child that does not exist yet is not
|
||||
// "already" listed.
|
||||
expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/cases folder itself/);
|
||||
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/directly in the cases folder/);
|
||||
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).not.toMatch(/already/);
|
||||
expect(blockedReason('/home/u/codeman-cases-old/foo', withCases)).toBeNull();
|
||||
expect(blockedReason('/home/u/codeman-cases/foo', c)).toBeNull();
|
||||
});
|
||||
|
||||
it('allows a folder deeper than a direct child of the cases directory (it is not listed as a local case)', () => {
|
||||
// CODEMAN_CASES_PATH can be a broad root such as /mnt/user/Scripts; GET /api/cases lists only its
|
||||
// direct children, so a folder further down can be linked without appearing twice.
|
||||
const broad = { ...c, casesDirs: ['/mnt/user/Scripts'] };
|
||||
expect(blockedReason('/mnt/user/Scripts/GitHub', broad)).toMatch(/plain Create New/);
|
||||
expect(blockedReason('/mnt/user/Scripts/GitHub/opticon454', broad)).toBeNull();
|
||||
expect(blockedReason('/mnt/user/Scripts/GitHub/opticon454/app', broad)).toBeNull();
|
||||
});
|
||||
|
||||
it('judges against the system roots it is given', () => {
|
||||
expect(blockedReason('/private/etc/x', c)).toBeNull();
|
||||
expect(blockedReason('/private/etc/x', c, ['/private/etc'])).toMatch(/system directory/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareNewCasePath', () => {
|
||||
it('accepts a new folder under an existing parent and reports it does not exist yet', async () => {
|
||||
const r = await prepareNewCasePath(join(home, 'code', 'new-app'), ctx);
|
||||
expect(r).toMatchObject({ ok: true, existedEmpty: false });
|
||||
if (r.ok) expect(r.path).toMatch(/code\/new-app$/);
|
||||
});
|
||||
|
||||
it('accepts an existing EMPTY folder and says so', async () => {
|
||||
mkdirSync(join(home, 'code', 'empty'));
|
||||
expect(await prepareNewCasePath(join(home, 'code', 'empty'), ctx)).toMatchObject({ ok: true, existedEmpty: true });
|
||||
});
|
||||
|
||||
it('expands ~, tolerates a trailing slash and a ./ segment, and accepts spaces', async () => {
|
||||
const a = await prepareNewCasePath('~/code/from-tilde', ctx);
|
||||
expect(a.ok && a.path).toBe(join(home, 'code', 'from-tilde'));
|
||||
// A "./" segment and a trailing slash normalise away; the folder name may contain a space.
|
||||
const b = await prepareNewCasePath(`${join(home, 'code')}/./with space/`, ctx);
|
||||
expect(b.ok && b.path).toBe(join(home, 'code', 'with space'));
|
||||
// ...and a folder with a space in it can be the PARENT of the next one.
|
||||
mkdirSync(join(home, 'code', 'with space'));
|
||||
expect(await prepareNewCasePath(`${join(home, 'code', 'with space')}/proj/`, ctx)).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['empty', ''],
|
||||
['whitespace', ' '],
|
||||
['relative', 'code/app'],
|
||||
['dot-relative', './app'],
|
||||
['traversal', '/tmp/../etc/x'],
|
||||
['shell metacharacters', '/tmp/a;rm -rf /'],
|
||||
['command substitution', '/tmp/$(id)'],
|
||||
['quotes', "/tmp/it's"],
|
||||
['newline', '/tmp/a\nb'],
|
||||
])('rejects %s as invalid', async (_label, raw) => {
|
||||
expect(await prepareNewCasePath(raw, ctx)).toMatchObject({ ok: false, code: 'INVALID' });
|
||||
});
|
||||
|
||||
it('refuses system, home, credential and Codeman folders as BLOCKED', async () => {
|
||||
for (const raw of [
|
||||
'/etc/proj',
|
||||
'/usr/src/x',
|
||||
home,
|
||||
join(home, '.ssh', 'x'),
|
||||
join(home, '.codeman', 'cases', 'x'),
|
||||
]) {
|
||||
expect(await prepareNewCasePath(raw, ctx), raw).toMatchObject({ ok: false, code: 'BLOCKED' });
|
||||
}
|
||||
});
|
||||
|
||||
it('judges the symlink-resolved path too: a link into a blocked tree is not a way around it', async () => {
|
||||
symlinkSync('/etc', join(home, 'code', 'sneaky'));
|
||||
expect(await prepareNewCasePath(join(home, 'code', 'sneaky', 'proj'), ctx)).toMatchObject({
|
||||
ok: false,
|
||||
code: 'BLOCKED',
|
||||
});
|
||||
});
|
||||
|
||||
it('judges the resolved path against the resolved home too, when home is reached through a symlink', async () => {
|
||||
const realHome = join(root, 'realhome');
|
||||
mkdirSync(join(realHome, '.ssh'), { recursive: true });
|
||||
mkdirSync(join(realHome, 'code'));
|
||||
const linkHome = join(root, 'linkhome');
|
||||
symlinkSync(realHome, linkHome);
|
||||
// Typed, this reads as <link home>/code/innocent/x; resolved, it is <real home>/.ssh/x.
|
||||
symlinkSync(join(realHome, '.ssh'), join(realHome, 'code', 'innocent'));
|
||||
const viaLink = { home: linkHome, dataDir: join(linkHome, '.codeman') };
|
||||
expect(await prepareNewCasePath(join(linkHome, 'code', 'innocent', 'x'), viaLink)).toMatchObject({
|
||||
ok: false,
|
||||
code: 'BLOCKED',
|
||||
});
|
||||
// The same for Codeman's data dir given through the link.
|
||||
mkdirSync(join(realHome, '.codeman'));
|
||||
symlinkSync(join(realHome, '.codeman'), join(realHome, 'code', 'state'));
|
||||
expect(await prepareNewCasePath(join(linkHome, 'code', 'state', 'x'), viaLink)).toMatchObject({
|
||||
ok: false,
|
||||
code: 'BLOCKED',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a link into a cases directory, judged on its resolved form', async () => {
|
||||
const cases = join(home, 'codeman-cases');
|
||||
mkdirSync(cases);
|
||||
symlinkSync(cases, join(home, 'code', 'shortcut'));
|
||||
const r = await prepareNewCasePath(join(home, 'code', 'shortcut', 'app'), { ...ctx, casesDirs: [cases] });
|
||||
expect(r).toMatchObject({ ok: false, code: 'BLOCKED' });
|
||||
if (!r.ok) expect(r.reason).toMatch(/plain Create New/);
|
||||
});
|
||||
|
||||
it('reports a missing parent as NOT_FOUND and never makes a chain of folders', async () => {
|
||||
const r = await prepareNewCasePath(join(home, 'code', 'nope', 'deeper', 'app'), ctx);
|
||||
expect(r).toMatchObject({ ok: false, code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('refuses a parent that is a file', async () => {
|
||||
writeFileSync(join(home, 'code', 'afile'), 'x');
|
||||
expect(await prepareNewCasePath(join(home, 'code', 'afile', 'app'), ctx)).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it('refuses a folder that already has files in it, pointing at Link Existing', async () => {
|
||||
mkdirSync(join(home, 'code', 'mine'));
|
||||
writeFileSync(join(home, 'code', 'mine', 'README.md'), 'hello');
|
||||
const r = await prepareNewCasePath(join(home, 'code', 'mine'), ctx);
|
||||
expect(r).toMatchObject({ ok: false, code: 'EXISTS' });
|
||||
if (!r.ok) expect(r.reason).toMatch(/Link Existing/);
|
||||
});
|
||||
|
||||
it('refuses a target that is a file or a symbolic link', async () => {
|
||||
writeFileSync(join(home, 'code', 'plain'), 'x');
|
||||
expect(await prepareNewCasePath(join(home, 'code', 'plain'), ctx)).toMatchObject({ ok: false, code: 'INVALID' });
|
||||
mkdirSync(join(home, 'code', 'real'));
|
||||
symlinkSync(join(home, 'code', 'real'), join(home, 'code', 'link'));
|
||||
const r = await prepareNewCasePath(join(home, 'code', 'link'), ctx);
|
||||
expect(r).toMatchObject({ ok: false, code: 'INVALID' });
|
||||
if (!r.ok) expect(r.reason).toMatch(/symbolic link/);
|
||||
});
|
||||
|
||||
it('never creates anything', async () => {
|
||||
await prepareNewCasePath(join(home, 'code', 'dry-run'), ctx);
|
||||
const { existsSync } = await import('node:fs');
|
||||
expect(existsSync(join(home, 'code', 'dry-run'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @fileoverview A case rename racing a session create, against the real WebServer.
|
||||
*
|
||||
* A create constructs its `Session` (which checks the rename lock) and only puts it in the server's session
|
||||
* map later, after awaiting the owner's layout lock and a settings read. A rename that ran in that gap built its
|
||||
* list of open sessions without the new one, moved the folder, and the create then registered a session whose
|
||||
* pane would `cd` into a path that no longer existed. The rename now refuses the move while such a session is
|
||||
* still being created, and the server's registration is what lets go of it.
|
||||
*
|
||||
* The gap is widened by holding the settings read inside `TabLayoutService.sessionCreated()`
|
||||
* (`childrenFollowParent`), the await between the constructor and the registration callback.
|
||||
*
|
||||
* Port: 0 (ephemeral, read back from `server.boundPort`).
|
||||
*/
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { isCaseSessionStarting } from '../src/case-rename.js';
|
||||
import { safeRmHomeTree } from './mocks/test-helpers.js';
|
||||
|
||||
interface LayoutDeps {
|
||||
childrenFollowParent?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
let server: WebServer;
|
||||
const made: string[] = [];
|
||||
const url = (path: string) => `http://localhost:${server.boundPort}${path}`;
|
||||
const post = async (path: string, body: unknown) => {
|
||||
const res = await fetch(url(path), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, json: (await res.json()) as Record<string, any> };
|
||||
};
|
||||
const localCase = (name: string) => {
|
||||
const dir = join(homedir(), 'codeman-cases', name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'CLAUDE.md'), name);
|
||||
made.push(dir);
|
||||
return dir;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
for (const dir of made.splice(0)) safeRmHomeTree(dir);
|
||||
}, 60000);
|
||||
|
||||
describe('renaming a case while a session is being created in it', () => {
|
||||
it('refuses the move until the session is registered, then lists it like any open session', async () => {
|
||||
const dir = localCase('race-alpha');
|
||||
made.push(join(homedir(), 'codeman-cases', 'race-beta')); // only there if a rename got through
|
||||
const deps = (server as unknown as { tabLayouts: { deps: LayoutDeps } }).tabLayouts.deps;
|
||||
const original = deps.childrenFollowParent;
|
||||
let reached!: () => void;
|
||||
const atGap = new Promise<void>((resolve) => (reached = resolve));
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => (release = resolve));
|
||||
deps.childrenFollowParent = async () => {
|
||||
reached();
|
||||
await gate;
|
||||
return (await original?.()) === true;
|
||||
};
|
||||
try {
|
||||
const create = post('/api/sessions', { workingDir: dir, mode: 'shell' });
|
||||
await atGap; // constructed, not yet in the server's session map
|
||||
expect(isCaseSessionStarting(dir)).toBe(true);
|
||||
|
||||
// Without the refusal the rename moves the folder and then waits on the owner's layout lock, which the
|
||||
// held create owns: bound the wait so that shows up as a failure rather than a test timeout.
|
||||
const busy = await Promise.race([
|
||||
post('/api/cases/race-alpha/rename', { newName: 'race-beta', stopSessions: true }),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 5000)),
|
||||
]);
|
||||
expect(existsSync(dir), 'the folder moved under the session being created').toBe(true);
|
||||
expect(busy?.status).toBe(409);
|
||||
expect(busy?.json).toMatchObject({ errorCode: 'CONFLICT', renameCode: 'BUSY' });
|
||||
|
||||
deps.childrenFollowParent = original;
|
||||
release();
|
||||
const created = await create;
|
||||
expect(created.status).toBe(200);
|
||||
const id = created.json.data.session.id as string;
|
||||
expect(isCaseSessionStarting(dir)).toBe(false);
|
||||
|
||||
const listed = await post('/api/cases/race-alpha/rename', { newName: 'race-beta' });
|
||||
expect(listed.status).toBe(409);
|
||||
expect(listed.json).toMatchObject({ requiresStop: true, sessions: [{ id }] });
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
await fetch(url(`/api/sessions/${id}`), { method: 'DELETE' });
|
||||
} finally {
|
||||
deps.childrenFollowParent = original;
|
||||
release();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* @fileoverview The Rename dialog's flow in session-ui.js (renameCase / submitRenameCase): it is the app's
|
||||
* own modal, not a browser prompt; a linked case offers the folder checkbox; live sessions are listed inside
|
||||
* the dialog and only stopped after a second press; server errors stay in the dialog. Evaluates the real file
|
||||
* against a stub DOM.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const SRC = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
||||
const HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
|
||||
|
||||
type El = {
|
||||
value: string;
|
||||
textContent: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
classes: Set<string>;
|
||||
classList: {
|
||||
add(c: string): void;
|
||||
remove(c: string): void;
|
||||
toggle(c: string, on?: boolean): void;
|
||||
contains(c: string): boolean;
|
||||
};
|
||||
focus(): void;
|
||||
select(): void;
|
||||
};
|
||||
const el = (hidden = false): El => {
|
||||
const classes = new Set<string>(hidden ? ['hidden'] : []);
|
||||
return {
|
||||
value: '',
|
||||
textContent: '',
|
||||
checked: false,
|
||||
disabled: false,
|
||||
classes,
|
||||
classList: {
|
||||
add: (c) => void classes.add(c),
|
||||
remove: (c) => void classes.delete(c),
|
||||
toggle: (c, on) => void ((on ?? !classes.has(c)) ? classes.add(c) : classes.delete(c)),
|
||||
contains: (c) => classes.has(c),
|
||||
},
|
||||
focus() {},
|
||||
select() {},
|
||||
};
|
||||
};
|
||||
|
||||
function makeApp(opts: { responses: unknown[]; cases: unknown[]; current?: string }) {
|
||||
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
||||
const toasts: string[] = [];
|
||||
const store = new Map<string, string>([['caseSettings_alpha', '{"agentTeams":true}']]);
|
||||
const queue = [...opts.responses];
|
||||
const ids = [
|
||||
'renameCaseModal',
|
||||
'renameCaseInput',
|
||||
'renameCaseLabel',
|
||||
'renameCaseFolderRow',
|
||||
'renameCaseFolder',
|
||||
'renameCaseFolderHint',
|
||||
'renameCaseNote',
|
||||
'renameCaseError',
|
||||
'renameCaseSubmit',
|
||||
'quickStartCase',
|
||||
];
|
||||
const els: Record<string, El> = Object.fromEntries(ids.map((id) => [id, el()]));
|
||||
els.quickStartCase.value = opts.current ?? 'alpha';
|
||||
function CodemanApp(this: unknown) {}
|
||||
const ctx = vm.createContext({
|
||||
CodemanApp,
|
||||
window: {},
|
||||
document: { getElementById: (id: string) => els[id] ?? null },
|
||||
localStorage: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
fetch: async (url: string, init: { body: string }) => {
|
||||
calls.push({ url, body: JSON.parse(init.body) });
|
||||
return { json: async () => queue.shift() };
|
||||
},
|
||||
escapeHtml: (s: string) => s,
|
||||
});
|
||||
vm.runInContext(SRC, ctx);
|
||||
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, any>;
|
||||
const refreshed: unknown[] = [];
|
||||
Object.assign(app, {
|
||||
cases: opts.cases,
|
||||
showToast: (m: string) => void toasts.push(m),
|
||||
loadQuickStartCases: async (n: unknown) => void refreshed.push(n),
|
||||
renderCaseManageList: () => void refreshed.push('render'),
|
||||
});
|
||||
return { app, els, calls, toasts, store, refreshed };
|
||||
}
|
||||
|
||||
const ok = { success: true, data: {} };
|
||||
|
||||
describe('the Rename dialog is part of the page, not a browser prompt', () => {
|
||||
it('has its modal in index.html, with the name, folder and error elements the code drives', () => {
|
||||
for (const id of [
|
||||
'renameCaseModal',
|
||||
'renameCaseInput',
|
||||
'renameCaseFolder',
|
||||
'renameCaseError',
|
||||
'renameCaseSubmit',
|
||||
]) {
|
||||
expect(HTML).toContain(`id="${id}"`);
|
||||
}
|
||||
const fn = SRC.slice(SRC.indexOf('renameCase(name)'), SRC.indexOf('async deleteCase(name)'));
|
||||
expect(fn).not.toMatch(/\b(prompt|confirm|alert)\(/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameCase / submitRenameCase', () => {
|
||||
it('opens pre-filled, renames a local case, moves its run options and reselects it under the new name', async () => {
|
||||
const t = makeApp({ responses: [ok], cases: [{ name: 'alpha', location: 'local' }] });
|
||||
t.app.renameCase('alpha');
|
||||
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
|
||||
expect(t.els.renameCaseInput.value).toBe('alpha');
|
||||
expect(t.els.renameCaseFolderRow.classes.has('hidden')).toBe(true);
|
||||
|
||||
t.els.renameCaseInput.value = ' beta ';
|
||||
await t.app.submitRenameCase();
|
||||
|
||||
expect(t.calls).toEqual([{ url: '/api/cases/alpha/rename', body: { newName: 'beta' } }]);
|
||||
expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
|
||||
expect(t.store.get('caseSettings_beta')).toBe('{"agentTeams":true}');
|
||||
expect(t.store.has('caseSettings_alpha')).toBe(false);
|
||||
expect(t.refreshed).toEqual(['beta', 'render']);
|
||||
});
|
||||
|
||||
it('keeps the dialog open with a message for the same name or a name that is not allowed, and sends nothing', async () => {
|
||||
for (const entered of ['', 'alpha', 'a b', '../x']) {
|
||||
const t = makeApp({ responses: [], cases: [{ name: 'alpha' }] });
|
||||
t.app.renameCase('alpha');
|
||||
t.els.renameCaseInput.value = entered;
|
||||
await t.app.submitRenameCase();
|
||||
expect(t.calls, entered).toEqual([]);
|
||||
expect(t.els.renameCaseError.classes.has('hidden'), entered).toBe(false);
|
||||
expect(t.els.renameCaseModal.classes.has('active'), entered).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the folder checkbox for a linked case and sends its state', async () => {
|
||||
const t = makeApp({ responses: [ok], cases: [{ name: 'alpha', linked: true, path: '/srv/app' }] });
|
||||
t.app.renameCase('alpha');
|
||||
expect(t.els.renameCaseFolderRow.classes.has('hidden')).toBe(false);
|
||||
expect(t.els.renameCaseFolderHint.textContent).toContain('/srv/app');
|
||||
t.els.renameCaseInput.value = 'beta';
|
||||
t.els.renameCaseFolder.checked = true;
|
||||
await t.app.submitRenameCase();
|
||||
expect(t.calls[0].body).toEqual({ newName: 'beta', renameFolder: true });
|
||||
|
||||
const alias = makeApp({ responses: [ok], cases: [{ name: 'alpha', linked: true, path: '/srv/app' }] });
|
||||
alias.app.renameCase('alpha');
|
||||
alias.els.renameCaseInput.value = 'beta';
|
||||
await alias.app.submitRenameCase();
|
||||
expect(alias.calls[0].body).toEqual({ newName: 'beta', renameFolder: false });
|
||||
});
|
||||
|
||||
it('lists the live sessions in the dialog and stops them only on the second press', async () => {
|
||||
const refusal = {
|
||||
success: false,
|
||||
error: '1 live session has this folder open.',
|
||||
requiresStop: true,
|
||||
sessions: [{ id: 's', name: 'w1-alpha' }],
|
||||
};
|
||||
const t = makeApp({ responses: [refusal, ok], cases: [{ name: 'alpha' }] });
|
||||
t.app.renameCase('alpha');
|
||||
t.els.renameCaseInput.value = 'beta';
|
||||
|
||||
await t.app.submitRenameCase();
|
||||
expect(t.els.renameCaseError.textContent).toContain('w1-alpha');
|
||||
// "stop" closes them through the server's session cleanup: say that their tabs go away
|
||||
expect(t.els.renameCaseError.textContent).toContain('They will be closed (their tabs go away');
|
||||
expect(t.els.renameCaseSubmit.textContent).toBe('Stop sessions and rename');
|
||||
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
|
||||
expect(t.calls).toHaveLength(1);
|
||||
|
||||
await t.app.submitRenameCase();
|
||||
expect(t.calls.map((c) => c.body)).toEqual([{ newName: 'beta' }, { newName: 'beta', stopSessions: true }]);
|
||||
expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('says how many sessions were already closed when the rename fails after closing them', async () => {
|
||||
const refusal = { success: false, error: 'busy', requiresStop: true, sessions: [{ id: 's', name: 'w1-alpha' }] };
|
||||
const failed = { success: false, error: 'The registry could not be written.', stoppedSessions: 2 };
|
||||
const t = makeApp({ responses: [refusal, failed], cases: [{ name: 'alpha' }] });
|
||||
t.app.renameCase('alpha');
|
||||
t.els.renameCaseInput.value = 'beta';
|
||||
await t.app.submitRenameCase();
|
||||
await t.app.submitRenameCase();
|
||||
expect(t.els.renameCaseError.textContent).toBe(
|
||||
'The registry could not be written. (2 sessions were already closed.)'
|
||||
);
|
||||
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
|
||||
});
|
||||
|
||||
it('cancelling the dialog stops nothing and renames nothing', async () => {
|
||||
const t = makeApp({ responses: [], cases: [{ name: 'alpha' }] });
|
||||
t.app.renameCase('alpha');
|
||||
t.app.closeRenameCase();
|
||||
expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
|
||||
await t.app.submitRenameCase(); // nothing open: ignored
|
||||
expect(t.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows the server’s message inside the dialog when the folder is locked, and leaves the lists alone', async () => {
|
||||
const locked = { success: false, error: 'The folder is in use by another program (EBUSY).' };
|
||||
const t = makeApp({ responses: [locked], cases: [{ name: 'alpha' }] });
|
||||
t.app.renameCase('alpha');
|
||||
t.els.renameCaseInput.value = 'beta';
|
||||
await t.app.submitRenameCase();
|
||||
expect(t.els.renameCaseError.textContent).toBe('The folder is in use by another program (EBUSY).');
|
||||
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
|
||||
expect(t.els.renameCaseSubmit.disabled).toBe(false); // the name can be fixed and tried again
|
||||
expect(t.toasts).toEqual([]);
|
||||
expect(t.refreshed).toEqual([]);
|
||||
expect(t.store.has('caseSettings_alpha')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @fileoverview src/case-rename.ts: the in-process lock, the record of sessions still being created, the
|
||||
* retried folder move and Claude history migration. Real filesystem under test/setup.ts's temp HOME. Port: N/A.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
CaseRenameError,
|
||||
caseSessionSettled,
|
||||
claudeProjectDirName,
|
||||
isCasePathLocked,
|
||||
isCaseSessionStarting,
|
||||
moveCaseFolder,
|
||||
noteCaseSessionStarting,
|
||||
moveClaudeHistory,
|
||||
renameInOrder,
|
||||
siblingPath,
|
||||
withCaseLock,
|
||||
} from '../src/case-rename.js';
|
||||
import { Session } from '../src/session.js';
|
||||
|
||||
const roots: string[] = [];
|
||||
const tmp = () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'case-rename-'));
|
||||
roots.push(dir);
|
||||
return dir;
|
||||
};
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of roots.splice(0)) await fsp.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('withCaseLock', () => {
|
||||
it('holds both paths and everything inside them for the length of the call, then lets go', async () => {
|
||||
const root = tmp();
|
||||
const from = join(root, 'old');
|
||||
const to = join(root, 'new');
|
||||
let during: boolean[] = [];
|
||||
await withCaseLock(from, to, async () => {
|
||||
during = [
|
||||
isCasePathLocked(from),
|
||||
isCasePathLocked(join(from, 'src')),
|
||||
isCasePathLocked(to),
|
||||
isCasePathLocked(root),
|
||||
];
|
||||
});
|
||||
expect(during).toEqual([true, true, true, false]);
|
||||
expect(isCasePathLocked(from)).toBe(false);
|
||||
expect(isCasePathLocked(to)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses an overlapping rename with BUSY and releases on failure', async () => {
|
||||
const root = tmp();
|
||||
await withCaseLock(join(root, 'a'), join(root, 'b'), async () => {
|
||||
await expect(withCaseLock(join(root, 'b'), join(root, 'c'), async () => {})).rejects.toMatchObject({
|
||||
code: 'BUSY',
|
||||
});
|
||||
await expect(withCaseLock(join(root, 'a', 'inner'), join(root, 'x'), async () => {})).rejects.toBeInstanceOf(
|
||||
CaseRenameError
|
||||
);
|
||||
});
|
||||
await expect(
|
||||
withCaseLock(join(root, 'a'), join(root, 'b'), async () => {
|
||||
throw new Error('boom');
|
||||
})
|
||||
).rejects.toThrow('boom');
|
||||
expect(isCasePathLocked(join(root, 'a'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a session from starting in a folder that is being moved', async () => {
|
||||
const root = tmp();
|
||||
const from = join(root, 'case');
|
||||
mkdirSync(from);
|
||||
await withCaseLock(from, join(root, 'renamed'), async () => {
|
||||
expect(() => new Session({ workingDir: from })).toThrow(/being renamed/);
|
||||
expect(() => new Session({ workingDir: join(from, 'sub') })).toThrow(/being renamed/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessions still being created', () => {
|
||||
it('counts a constructed session as starting in its folder until the server settles it', () => {
|
||||
const root = tmp();
|
||||
const dir = join(root, 'case');
|
||||
const session = new Session({ workingDir: join(dir, 'sub') });
|
||||
expect(isCaseSessionStarting(dir)).toBe(true);
|
||||
expect(isCaseSessionStarting(join(dir, 'sub'))).toBe(true);
|
||||
expect(isCaseSessionStarting(join(dir, 'other'))).toBe(false);
|
||||
expect(isCaseSessionStarting(`${dir}-2`)).toBe(false);
|
||||
caseSessionSettled(session);
|
||||
expect(isCaseSessionStarting(dir)).toBe(false);
|
||||
});
|
||||
|
||||
it('lets go of a session that is stopped, and of one never registered once a while has passed', async () => {
|
||||
const root = tmp();
|
||||
const stopped = new Session({ workingDir: root });
|
||||
await stopped.stop();
|
||||
expect(isCaseSessionStarting(root)).toBe(false);
|
||||
// a create that failed before registering, or a one-shot prompt that never registers
|
||||
const lost = {};
|
||||
noteCaseSessionStarting(lost, root, 1000);
|
||||
expect(isCaseSessionStarting(root, 1000 + 29_999)).toBe(true);
|
||||
expect(isCaseSessionStarting(root, 1000 + 30_000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveCaseFolder', () => {
|
||||
it('moves a folder and its contents', async () => {
|
||||
const root = tmp();
|
||||
mkdirSync(join(root, 'old'));
|
||||
writeFileSync(join(root, 'old', 'CLAUDE.md'), 'x');
|
||||
await moveCaseFolder(join(root, 'old'), join(root, 'new'));
|
||||
expect(existsSync(join(root, 'old'))).toBe(false);
|
||||
expect(existsSync(join(root, 'new', 'CLAUDE.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('never replaces an existing folder, even an empty one that rename would silently overwrite', async () => {
|
||||
const root = tmp();
|
||||
mkdirSync(join(root, 'old'));
|
||||
mkdirSync(join(root, 'taken'));
|
||||
await expect(moveCaseFolder(join(root, 'old'), join(root, 'taken'))).rejects.toMatchObject({ code: 'EXISTS' });
|
||||
expect(existsSync(join(root, 'old'))).toBe(true);
|
||||
});
|
||||
|
||||
it('retries a lock error, and succeeds once the other program lets go', async () => {
|
||||
const root = tmp();
|
||||
mkdirSync(join(root, 'old'));
|
||||
const real = fsp.rename.bind(fsp);
|
||||
let calls = 0;
|
||||
vi.spyOn(fsp, 'rename').mockImplementation(async (a, b) => {
|
||||
if (++calls < 3) throw Object.assign(new Error('busy'), { code: 'EBUSY' });
|
||||
return real(a, b);
|
||||
});
|
||||
await moveCaseFolder(join(root, 'old'), join(root, 'new'), 4, 1);
|
||||
expect(calls).toBe(3);
|
||||
expect(existsSync(join(root, 'new'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a lock that never clears as LOCKED, with what to do about it', async () => {
|
||||
const root = tmp();
|
||||
mkdirSync(join(root, 'old'));
|
||||
vi.spyOn(fsp, 'rename').mockRejectedValue(Object.assign(new Error('perm'), { code: 'EPERM' }));
|
||||
const err = await moveCaseFolder(join(root, 'old'), join(root, 'new'), 2, 1).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(CaseRenameError);
|
||||
expect(err.code).toBe('LOCKED');
|
||||
expect(err.message).toMatch(/in use by another program/);
|
||||
expect(existsSync(join(root, 'old'))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to cross filesystems', async () => {
|
||||
const root = tmp();
|
||||
mkdirSync(join(root, 'old'));
|
||||
vi.spyOn(fsp, 'rename').mockRejectedValue(Object.assign(new Error('xdev'), { code: 'EXDEV' }));
|
||||
await expect(moveCaseFolder(join(root, 'old'), join(root, 'new'))).rejects.toMatchObject({ code: 'CROSS_DEVICE' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Claude history', () => {
|
||||
it('names a project directory the way Claude Code does', () => {
|
||||
expect(claudeProjectDirName('/home/devvyn/codeman-cases/my_case')).toBe('-home-devvyn-codeman-cases-my-case');
|
||||
expect(claudeProjectDirName('/home/devvyn/.dotcase')).toBe('-home-devvyn--dotcase');
|
||||
});
|
||||
|
||||
it('moves the old path’s history to the new one', async () => {
|
||||
const cfg = tmp();
|
||||
const projects = join(cfg, 'projects');
|
||||
const oldPath = join(tmp(), 'alpha');
|
||||
const newPath = join(tmp(), 'beta');
|
||||
mkdirSync(join(projects, claudeProjectDirName(oldPath)), { recursive: true });
|
||||
writeFileSync(join(projects, claudeProjectDirName(oldPath), 'c.jsonl'), '{}');
|
||||
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true);
|
||||
expect(existsSync(join(projects, claudeProjectDirName(newPath), 'c.jsonl'))).toBe(true);
|
||||
expect(existsSync(join(projects, claudeProjectDirName(oldPath)))).toBe(false);
|
||||
expect(await moveClaudeHistory(join(tmp(), 'none'), newPath, cfg)).toBe(false);
|
||||
});
|
||||
|
||||
it('merges into history already at the new path (a deleted case’s leftovers) without overwriting any of it', async () => {
|
||||
const cfg = tmp();
|
||||
const projects = join(cfg, 'projects');
|
||||
const oldPath = join(tmp(), 'alpha');
|
||||
const newPath = join(tmp(), 'beta');
|
||||
const from = join(projects, claudeProjectDirName(oldPath));
|
||||
const to = join(projects, claudeProjectDirName(newPath));
|
||||
mkdirSync(join(from, 'conv-a', 'subagents'), { recursive: true });
|
||||
writeFileSync(join(from, 'conv-a.jsonl'), 'alpha');
|
||||
writeFileSync(join(from, 'conv-a', 'subagents', 'agent-1.jsonl'), 'alpha agent');
|
||||
mkdirSync(join(from, 'memory'));
|
||||
writeFileSync(join(from, 'memory', 'MEMORY.md'), 'alpha memory');
|
||||
// Claude Code never removes a project directory, so a case deleted earlier under the new name left this.
|
||||
mkdirSync(join(to, 'memory'), { recursive: true });
|
||||
writeFileSync(join(to, 'conv-old.jsonl'), 'old beta');
|
||||
writeFileSync(join(to, 'memory', 'MEMORY.md'), 'old beta memory');
|
||||
|
||||
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true);
|
||||
|
||||
expect(readFileSync(join(to, 'conv-a.jsonl'), 'utf8')).toBe('alpha');
|
||||
expect(readFileSync(join(to, 'conv-a', 'subagents', 'agent-1.jsonl'), 'utf8')).toBe('alpha agent');
|
||||
expect(readFileSync(join(to, 'conv-old.jsonl'), 'utf8')).toBe('old beta');
|
||||
// an entry both have is never overwritten: it stays where it was
|
||||
expect(readFileSync(join(to, 'memory', 'MEMORY.md'), 'utf8')).toBe('old beta memory');
|
||||
expect(readFileSync(join(from, 'memory', 'MEMORY.md'), 'utf8')).toBe('alpha memory');
|
||||
expect(existsSync(join(from, 'conv-a.jsonl'))).toBe(false);
|
||||
});
|
||||
|
||||
it('removes the old project directory once a merge has emptied it, and never merges into a symlink', async () => {
|
||||
const cfg = tmp();
|
||||
const projects = join(cfg, 'projects');
|
||||
const oldPath = join(tmp(), 'alpha');
|
||||
const newPath = join(tmp(), 'beta');
|
||||
const from = join(projects, claudeProjectDirName(oldPath));
|
||||
const to = join(projects, claudeProjectDirName(newPath));
|
||||
mkdirSync(from, { recursive: true });
|
||||
mkdirSync(to, { recursive: true });
|
||||
writeFileSync(join(from, 'conv-a.jsonl'), 'alpha');
|
||||
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true);
|
||||
expect(existsSync(from)).toBe(false);
|
||||
|
||||
const elsewhere = tmp();
|
||||
const linkedPath = join(tmp(), 'gamma');
|
||||
mkdirSync(from);
|
||||
writeFileSync(join(from, 'conv-b.jsonl'), 'alpha');
|
||||
symlinkSync(elsewhere, join(projects, claudeProjectDirName(linkedPath)));
|
||||
expect(await moveClaudeHistory(oldPath, linkedPath, cfg)).toBe(false);
|
||||
expect(existsSync(join(from, 'conv-b.jsonl'))).toBe(true);
|
||||
expect(existsSync(join(elsewhere, 'conv-b.jsonl'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('small helpers', () => {
|
||||
it('renames in an order list keeping the position, and says nothing when absent', () => {
|
||||
expect(renameInOrder(['a', 'b', 'c'], 'b', 'z')).toEqual(['a', 'z', 'c']);
|
||||
expect(renameInOrder(['a'], 'q', 'z')).toBeNull();
|
||||
expect(renameInOrder(undefined, 'a', 'z')).toBeNull();
|
||||
});
|
||||
it('puts the new name next to the old folder', () => {
|
||||
expect(siblingPath('/srv/work/app', 'app2')).toBe('/srv/work/app2');
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,110 @@
|
||||
/**
|
||||
* @fileoverview Claude's `/resume` title belongs to Claude unless the user chose one.
|
||||
* @fileoverview The CLI's session name and the opt-in `pinCliSessionName` setting.
|
||||
*
|
||||
* `--name` sets the prompt-box label, the `/resume` picker entry and the terminal
|
||||
* title, and a pinned title stops Claude generating its own. Pinning the
|
||||
* `w1-myapp` placeholder therefore listed every conversation of a case under the
|
||||
* same name in `/resume`. Only a manual name is pinned now (`cliPinnedName`), and
|
||||
* a rename reaches the transcript as a `custom-title` row.
|
||||
* title, and a pinned title stops Claude generating its own. By default only a name
|
||||
* the user chose is pinned (`cliPinnedName`); with `pinCliSessionName` on, every tab's
|
||||
* name is pinned, placeholder and auto names included, so Claude's own session list
|
||||
* matches the tab. The setting is re-read at each spawn and respawn. A rename reaches
|
||||
* the transcript as a `custom-title` row.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { appendClaudeCustomTitle } from '../src/claude-session-title.js';
|
||||
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
|
||||
|
||||
type RespawnOptionsProbe = { _buildRespawnPaneOptions(): { name?: string; cliName?: string } };
|
||||
type RespawnOptionsProbe = {
|
||||
_buildRespawnPaneOptions(): { name?: string; cliName?: string };
|
||||
_buildRespawnPaneOptionsWithResumePin(): Promise<{ name?: string; cliName?: string }>;
|
||||
};
|
||||
|
||||
const probe = (session: Session) => session as unknown as RespawnOptionsProbe;
|
||||
|
||||
/** The respawn builder is the path every respawn takes; it re-reads the setting first. */
|
||||
const cliNameAtRespawn = async (session: Session) =>
|
||||
(await probe(session)._buildRespawnPaneOptionsWithResumePin()).cliName;
|
||||
|
||||
function writeSettings(settings: Record<string, unknown> | null) {
|
||||
if (settings === null) {
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
return;
|
||||
}
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
describe('Session.cliPinnedName', () => {
|
||||
it('pins nothing for a placeholder, so Claude titles the conversation itself', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(session.cliPinnedName).toBeUndefined();
|
||||
const options = (session as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions();
|
||||
// The tab keeps its name; only the CLI flag is withheld.
|
||||
expect(options.name).toBe('w1-demo');
|
||||
expect(options.cliName).toBeUndefined();
|
||||
afterEach(() => writeSettings(null));
|
||||
|
||||
describe('pinCliSessionName off (default)', () => {
|
||||
it.each([null, {}, { pinCliSessionName: false }])(
|
||||
'leaves a placeholder name unpinned (settings: %j)',
|
||||
async (settings) => {
|
||||
writeSettings(settings);
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(await cliNameAtRespawn(session)).toBeUndefined();
|
||||
expect(session.cliPinnedName).toBeUndefined();
|
||||
expect(probe(session)._buildRespawnPaneOptions().name).toBe('w1-demo');
|
||||
}
|
||||
);
|
||||
|
||||
it('leaves an auto name unpinned', async () => {
|
||||
writeSettings({ pinCliSessionName: false });
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true);
|
||||
expect(await cliNameAtRespawn(session)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still pins a name the user chose, at creation or by a rename', async () => {
|
||||
writeSettings({ pinCliSessionName: false });
|
||||
expect(await cliNameAtRespawn(new Session({ workingDir: '/tmp', name: 'msgtest-worker' }))).toBe(
|
||||
'msgtest-worker'
|
||||
);
|
||||
|
||||
const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
renamed.name = '登录修复';
|
||||
expect(await cliNameAtRespawn(renamed)).toBe('登录修复');
|
||||
});
|
||||
});
|
||||
|
||||
it('pins nothing for an auto name, whose cut of the prompt Claude beats', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true);
|
||||
expect(session.cliPinnedName).toBeUndefined();
|
||||
describe('pinCliSessionName on', () => {
|
||||
it('pins the placeholder name, so the CLI session matches the tab', async () => {
|
||||
writeSettings({ pinCliSessionName: true });
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(await cliNameAtRespawn(session)).toBe('w1-demo');
|
||||
expect(session.cliPinnedName).toBe('w1-demo');
|
||||
});
|
||||
|
||||
it('pins an auto name too, from the next spawn', async () => {
|
||||
writeSettings({ pinCliSessionName: true });
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true);
|
||||
expect(await cliNameAtRespawn(session)).toBe('w1-demo: fix the login redirect');
|
||||
});
|
||||
|
||||
it('pins a name the user chose', async () => {
|
||||
writeSettings({ pinCliSessionName: true });
|
||||
const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
renamed.name = '登录修复';
|
||||
expect(await cliNameAtRespawn(renamed)).toBe('登录修复');
|
||||
});
|
||||
|
||||
it('never pins a blank name', async () => {
|
||||
writeSettings({ pinCliSessionName: true });
|
||||
expect(await cliNameAtRespawn(new Session({ workingDir: '/tmp', name: ' ' }))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('pins a name the user chose, at creation or by a rename', () => {
|
||||
expect(new Session({ workingDir: '/tmp', name: 'msgtest-worker' }).cliPinnedName).toBe('msgtest-worker');
|
||||
|
||||
const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
renamed.name = '登录修复';
|
||||
expect(renamed.cliPinnedName).toBe('登录修复');
|
||||
expect((renamed as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions().cliName).toBe('登录修复');
|
||||
it('follows the setting on the next respawn of the same session, with no restart', async () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
writeSettings({ pinCliSessionName: true });
|
||||
expect(await cliNameAtRespawn(session)).toBe('w1-demo');
|
||||
writeSettings({ pinCliSessionName: false });
|
||||
expect(await cliNameAtRespawn(session)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../src/session.js';
|
||||
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode, isMuxMouseStripMode } from '../src/session.js';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
import { stripReplayBuffer } from '../src/web/routes/session-routes.js';
|
||||
|
||||
type SessionInternals = {
|
||||
_handleTerminalOutput(data: string): void;
|
||||
@@ -94,18 +96,96 @@ describe('Shell terminal output on a DIRECT PTY is NOT stripped (vim/less/htop n
|
||||
|
||||
describe('isMuxAltScreenOnlyStripMode', () => {
|
||||
it('covers exactly the modes the full strip does not, and only under tmux', () => {
|
||||
for (const mode of ['shell', 'opencode', 'antigravity'] as const) {
|
||||
for (const mode of ['shell', 'antigravity'] as const) {
|
||||
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(true);
|
||||
// Direct-PTY fallback: the program's own alt screen really does reach xterm.
|
||||
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
|
||||
}
|
||||
// The full strip already owns these; never double-gate them here.
|
||||
for (const mode of ['claude', 'codex', 'gemini'] as const) {
|
||||
// The full strip and the mouse strip already own their modes; never double-gate.
|
||||
for (const mode of ['claude', 'codex', 'gemini', 'opencode', 'copilot'] as const) {
|
||||
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* opencode's TUI is a mouse-capable full-screen app: it enables tracking DECSETs,
|
||||
* tmux `mouse off` passes them straight through to the tmux CLIENT, and xterm then
|
||||
* reports DRAGS to the TUI instead of selecting locally. That killed "mark text,
|
||||
* copy on select" intermittently — and the obvious fallback, Ctrl+C, is opencode's
|
||||
* `app_exit`, so the failure also ended sessions.
|
||||
*
|
||||
* It needs the alt-screen strip AND the mouse strip, but NOT `3J`: opencode is a
|
||||
* TUI, not a `clear` consumer, so keeping 3J is the conservative middle ground
|
||||
* between the full strip and the narrow one.
|
||||
*/
|
||||
describe('opencode: alt-screen + mouse DECSETs stripped, 3J kept', () => {
|
||||
it('is a mouse-strip mode under tmux, and only there', () => {
|
||||
expect(isMuxMouseStripMode('opencode', true)).toBe(true);
|
||||
// Direct-PTY fallback: the pane's own alt screen really does reach xterm.
|
||||
expect(isMuxMouseStripMode('opencode', false)).toBe(false);
|
||||
for (const mode of ['claude', 'codex', 'gemini', 'shell', 'antigravity'] as const) {
|
||||
expect(isMuxMouseStripMode(mode, true)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('drops mouse tracking so xterm keeps local text selection', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true });
|
||||
const emitted: string[] = [];
|
||||
session.on('terminal', (data) => emitted.push(data));
|
||||
|
||||
handleOutput(session, '\x1b[?1003h\x1b[?1006hTUI\x1b[?1006l\x1b[?1003l');
|
||||
|
||||
expect(emitted[0]).toBe('TUI');
|
||||
expect(session.terminalBuffer).toBe('TUI');
|
||||
});
|
||||
|
||||
it('still drops tmux’s attach-time smcup', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true });
|
||||
|
||||
handleOutput(session, '\x1b[?1049h\x1b[22;0;0t\x1b[H\x1b[2Jprompt');
|
||||
|
||||
expect(session.terminalBuffer).toBe('\x1b[22;0;0t\x1b[H\x1b[2Jprompt');
|
||||
});
|
||||
|
||||
it('KEEPS 3J, unlike the full strip', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true });
|
||||
|
||||
handleOutput(session, '\x1b[3Jtext');
|
||||
|
||||
expect(session.terminalBuffer).toBe('\x1b[3Jtext');
|
||||
});
|
||||
|
||||
it('publishes cliMouseTracking so the browser can hand-encode clicks', () => {
|
||||
// xterm can never see the DECSETs once they are stripped, so the click path
|
||||
// (_sendSyntheticSgrTap) is the only way a click still reaches opencode.
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true });
|
||||
|
||||
handleOutput(session, '\x1b[?1003h\x1b[?1006h');
|
||||
|
||||
expect(session.toState().cliMouseTracking).toBe(true);
|
||||
});
|
||||
|
||||
it('reassembles a mouse DECSET split across PTY chunks', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true });
|
||||
|
||||
handleOutput(session, 'before\x1b[?100');
|
||||
handleOutput(session, '3h after');
|
||||
|
||||
expect(session.terminalBuffer).toBe('before after');
|
||||
expect(session.toState().cliMouseTracking).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a direct-PTY opencode pane untouched', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: false });
|
||||
const out = '\x1b[?1049h\x1b[?1003h';
|
||||
|
||||
handleOutput(session, out);
|
||||
|
||||
expect(session.terminalBuffer).toBe(out);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tmux-backed shell: strip tmux’s own client smcup, keep everything else (#205)', () => {
|
||||
it('drops alt-screen toggles so xterm keeps a scrollback buffer', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||||
@@ -127,6 +207,17 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el
|
||||
expect(session.terminalBuffer).toBe('\x1b[3J\x1b[?1002h\x1b[?1006hhtop\x1b[?1006l\x1b[?1002l');
|
||||
});
|
||||
|
||||
it('never publishes cliMouseTracking for a mode whose DECSETs it keeps', () => {
|
||||
// The browser's `_shouldReportMouseToCli()` reads only this flag, with no mode
|
||||
// list: a flag set for a non-stripping mode would make it hand-encode a second
|
||||
// report on top of xterm's own. Only the mouse-strip branch may set it.
|
||||
for (const mode of ['shell', 'antigravity'] as const) {
|
||||
const session = new Session({ workingDir: '/tmp', mode, useMux: true });
|
||||
handleOutput(session, '\x1b[?1002h\x1b[?1006hmouse app');
|
||||
expect(session.toState().cliMouseTracking, mode).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it('reassembles alt-screen sequences split across PTY chunk boundaries', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||||
const emitted: string[] = [];
|
||||
@@ -139,12 +230,10 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el
|
||||
expect(emitted).toEqual(['before', ' after']);
|
||||
});
|
||||
|
||||
it('applies to opencode and antigravity too', () => {
|
||||
for (const mode of ['opencode', 'antigravity'] as const) {
|
||||
const session = new Session({ workingDir: '/tmp', mode, useMux: true });
|
||||
handleOutput(session, '\x1b[?1049hTUI\x1b[3J');
|
||||
expect(session.terminalBuffer).toBe('TUI\x1b[3J');
|
||||
}
|
||||
it('applies to antigravity too (opencode has its own strip — see below)', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'antigravity', useMux: true });
|
||||
handleOutput(session, '\x1b[?1049hTUI\x1b[3J');
|
||||
expect(session.terminalBuffer).toBe('TUI\x1b[3J');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,3 +327,74 @@ describe('stripped mouse-tracking state', () => {
|
||||
expect(session.terminalBuffer).toBe('\x1b[?1002hhtop');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The decision table in `CliCapabilities.altScreen`'s JSDoc, pinned for every stock CLI
|
||||
* with and without tmux, on both halves of the parity triangle that can drift apart: the
|
||||
* live stream (`_handleTerminalOutput`) and the replay of a stored buffer
|
||||
* (`stripReplayBuffer`, session-routes.ts). The frontend half reads only the published
|
||||
* `cliMouseTracking`, so the last column is what keeps it right.
|
||||
*/
|
||||
describe('strip decision table: live stream = replay, for every stock CLI', () => {
|
||||
const ALT = '\x1b[?1049h';
|
||||
const ERASE = '\x1b[3J';
|
||||
const MOUSE = '\x1b[?1002h\x1b[?1006h';
|
||||
const input = `A${ALT}B${ERASE}C${MOUSE}D`;
|
||||
|
||||
/** Read straight off the table, not off the predicates under test. */
|
||||
function expected(altScreen: string, useMux: boolean): { out: string; tracking: boolean } {
|
||||
const strip = { alt: false, erase: false, mouse: false };
|
||||
if (altScreen === 'strip-full') Object.assign(strip, { alt: true, erase: true, mouse: true });
|
||||
else if (useMux && altScreen === 'strip-mux-and-mouse') Object.assign(strip, { alt: true, mouse: true });
|
||||
else if (useMux) strip.alt = true; // strip-mux-only and preserve: the same runtime row
|
||||
return {
|
||||
out: `A${strip.alt ? '' : ALT}B${strip.erase ? '' : ERASE}C${strip.mouse ? '' : MOUSE}D`,
|
||||
tracking: strip.mouse,
|
||||
};
|
||||
}
|
||||
|
||||
for (const entry of STOCK_CLIS) {
|
||||
for (const useMux of [true, false]) {
|
||||
it(`${entry.id} (${entry.capabilities.altScreen}, ${useMux ? 'tmux' : 'direct PTY'})`, () => {
|
||||
const want = expected(entry.capabilities.altScreen, useMux);
|
||||
const session = new Session({ workingDir: '/tmp', mode: entry.id, useMux });
|
||||
handleOutput(session, input);
|
||||
expect(session.terminalBuffer).toBe(want.out);
|
||||
expect(stripReplayBuffer(input, entry.id, useMux)).toBe(want.out);
|
||||
expect(Boolean(session.toState().cliMouseTracking)).toBe(want.tracking);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GitHub Copilot CLI is the same shape as opencode: a full-screen TUI that turns mouse tracking
|
||||
* on itself. Left in, xterm reports a plain drag to the TUI instead of selecting, so Auto Copy and
|
||||
* copy-on-select did nothing (only Shift+drag selected). Same strip, same reason.
|
||||
*/
|
||||
describe('copilot: alt-screen + mouse DECSETs stripped, 3J kept', () => {
|
||||
it('is a mouse-strip mode under tmux, and only there', () => {
|
||||
expect(isMuxMouseStripMode('copilot', true)).toBe(true);
|
||||
expect(isMuxMouseStripMode('copilot', false)).toBe(false);
|
||||
});
|
||||
|
||||
it('drops mouse tracking so xterm keeps local text selection, and keeps 3J', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: true });
|
||||
handleOutput(session, '\x1b[?1003h\x1b[?1006h\x1b[3JTUI\x1b[?1006l\x1b[?1003l');
|
||||
expect(session.terminalBuffer).toBe('\x1b[3JTUI');
|
||||
});
|
||||
|
||||
it('publishes cliMouseTracking so the browser can forward the wheel and hand-encode clicks', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: true });
|
||||
handleOutput(session, '\x1b[?1002h\x1b[?1006hTUI');
|
||||
expect(session.toState().cliMouseTracking).toBe(true);
|
||||
handleOutput(session, '\x1b[?1002l\x1b[?1006l');
|
||||
expect(session.toState().cliMouseTracking).toBeFalsy();
|
||||
});
|
||||
|
||||
it('leaves a direct-PTY copilot pane untouched', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: false });
|
||||
handleOutput(session, '\x1b[?1049h\x1b[?1002hTUI');
|
||||
expect(session.terminalBuffer).toBe('\x1b[?1049h\x1b[?1002hTUI');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/** @fileoverview Agent inspect/stage/submit transport, refusal and file boundaries. */
|
||||
import { expect, it } from 'vitest';
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
agentInspect,
|
||||
agentStage,
|
||||
agentSubmit,
|
||||
registerAgentCommands,
|
||||
type AgentDeps,
|
||||
type RequestOptions,
|
||||
} from '../src/cli-agent.js';
|
||||
import { Command } from 'commander';
|
||||
const SELF = '11111111-1111-4111-8111-111111111111';
|
||||
const OTHER = '22222222-2222-4222-8222-222222222222';
|
||||
function deps() {
|
||||
const calls: RequestOptions[] = [],
|
||||
output: string[] = [];
|
||||
const deps: AgentDeps = {
|
||||
ctx: { apiUrl: 'http://localhost:1', selfId: SELF },
|
||||
json: true,
|
||||
now: () => 1,
|
||||
io: { out: (line) => output.push(line), err: () => {} },
|
||||
request: async (_ctx, options) => {
|
||||
calls.push(options);
|
||||
return {
|
||||
status: 200,
|
||||
text: '',
|
||||
json: { success: true, data: { evidence: 'hello', stageId: OTHER, sha256: 'a'.repeat(64) } },
|
||||
};
|
||||
},
|
||||
};
|
||||
return { deps, calls, output };
|
||||
}
|
||||
it('inspect uses the ownership-checked versioned endpoint with exact bounds and cursor', async () => {
|
||||
const d = deps();
|
||||
expect(await agentInspect(d.deps, { id: OTHER, maxBytes: 42, since: 'cursor' })).toBe(0);
|
||||
expect(d.calls[0]).toMatchObject({
|
||||
method: 'GET',
|
||||
path: `/api/v1/sessions/${OTHER}/inspect`,
|
||||
query: { maxBytes: 42, since: 'cursor' },
|
||||
});
|
||||
expect(JSON.parse(d.output[0]).evidence).toBe('hello');
|
||||
expect(await agentInspect(d.deps, { id: OTHER, maxBytes: 65537 })).toBe(4);
|
||||
expect(d.calls).toHaveLength(1);
|
||||
});
|
||||
it('stage reads a bounded UTF8 file and never adds Enter; submit sends only receipt and hash', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-'));
|
||||
try {
|
||||
const path = join(dir, 'message');
|
||||
await writeFile(path, 'hello');
|
||||
const d = deps();
|
||||
expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(0);
|
||||
expect(d.calls[0]).toMatchObject({
|
||||
method: 'POST',
|
||||
path: `/api/v1/sessions/${OTHER}/stage`,
|
||||
body: { message: 'hello' },
|
||||
});
|
||||
expect(await agentSubmit(d.deps, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(0);
|
||||
expect(d.calls[1].body).toEqual({ stageId: OTHER, confirmSha: 'a'.repeat(64) });
|
||||
await writeFile(path, 'hello\n');
|
||||
expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4);
|
||||
await writeFile(path, Buffer.from([0xff]));
|
||||
expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4);
|
||||
await writeFile(path, 'x'.repeat(8193));
|
||||
expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4);
|
||||
expect(d.calls).toHaveLength(2);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it('refuses self-target and malformed submission before transport', async () => {
|
||||
const d = deps();
|
||||
expect(await agentStage(d.deps, { id: SELF, messageFile: '/nonexistent' })).toBe(4);
|
||||
expect(await agentSubmit(d.deps, { id: SELF, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(4);
|
||||
expect(await agentSubmit(d.deps, { id: OTHER, stageId: OTHER, confirmSha: 'bad' })).toBe(4);
|
||||
expect(d.calls).toHaveLength(0);
|
||||
});
|
||||
it('registers required file and confirmation options and bounded inspect flags', () => {
|
||||
const program = new Command();
|
||||
registerAgentCommands(program);
|
||||
const commands = program.commands.find((command) => command.name() === 'agent')!.commands;
|
||||
for (const name of ['inspect', 'stage', 'submit'])
|
||||
expect(commands.some((command) => command.name() === name)).toBe(true);
|
||||
expect(
|
||||
commands.find((command) => command.name() === 'stage')!.options.find((option) => option.long === '--message-file')
|
||||
?.mandatory
|
||||
).toBe(true);
|
||||
expect(
|
||||
commands.find((command) => command.name() === 'submit')!.options.find((option) => option.long === '--confirm-sha')
|
||||
?.mandatory
|
||||
).toBe(true);
|
||||
});
|
||||
it('shows the opt-in 403 verbatim on inspect, stage and submit while the setting is off', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-'));
|
||||
try {
|
||||
const path = join(dir, 'message');
|
||||
await writeFile(path, 'hello');
|
||||
const message =
|
||||
'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.';
|
||||
for (const json of [false, true]) {
|
||||
const lines: string[] = [];
|
||||
const refusing: AgentDeps = {
|
||||
ctx: { apiUrl: 'http://localhost:1', selfId: SELF },
|
||||
json,
|
||||
now: () => 1,
|
||||
io: { out: (line) => lines.push(line), err: (line) => lines.push(line) },
|
||||
request: async () => ({
|
||||
status: 403,
|
||||
text: '',
|
||||
json: { success: false, error: message, errorCode: 'FORBIDDEN' },
|
||||
}),
|
||||
};
|
||||
expect(await agentInspect(refusing, { id: OTHER, maxBytes: 10 })).toBe(1);
|
||||
expect(await agentStage(refusing, { id: OTHER, messageFile: path })).toBe(1);
|
||||
expect(await agentSubmit(refusing, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(1);
|
||||
expect(lines).toHaveLength(3);
|
||||
for (const line of lines) {
|
||||
const shown = json ? (JSON.parse(line) as { success: boolean; error: string }).error : line;
|
||||
expect(shown).toContain(`FORBIDDEN: ${message} (HTTP 403)`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
agentSpawn,
|
||||
isHomeDirectory,
|
||||
registerAgentCommands,
|
||||
EXIT,
|
||||
type AgentDeps,
|
||||
type ApiResponse,
|
||||
type RequestOptions,
|
||||
} from '../src/cli-agent.js';
|
||||
|
||||
const SELF = '058ee7b5-b2aa-4c33-8cc1-e900eb0b28af';
|
||||
const OTHER = '94990c6d-e461-4a29-aa83-89275327732c';
|
||||
const dirs: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
const ok = (data: unknown): ApiResponse => ({ status: 200, json: { success: true, data }, text: '' });
|
||||
function deps(responses: ApiResponse[]) {
|
||||
const calls: RequestOptions[] = [];
|
||||
const out: string[] = [];
|
||||
const err: string[] = [];
|
||||
const d: AgentDeps = {
|
||||
ctx: { selfId: SELF, apiUrl: 'http://127.0.0.1:1' },
|
||||
json: true,
|
||||
now: Date.now,
|
||||
io: { out: (s) => out.push(s), err: (s) => err.push(s) },
|
||||
request: async (_ctx, options) => {
|
||||
calls.push(options);
|
||||
return responses.shift() ?? ok({});
|
||||
},
|
||||
};
|
||||
return { d, calls, out, err };
|
||||
}
|
||||
const base = { mode: 'codex', ready: false, timeoutMs: 1000 };
|
||||
describe('project-preserving agent spawn', () => {
|
||||
it('uses existing session create and interactive endpoints with exact directory and lineage', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'cod549-project-'));
|
||||
dirs.push(directory);
|
||||
const t = deps([ok({ session: { id: OTHER } }), ok({})]);
|
||||
expect(await agentSpawn(t.d, { ...base, workingDir: directory, name: 'review' })).toBe(EXIT.ok);
|
||||
expect(t.calls).toHaveLength(2);
|
||||
expect(t.calls[0]).toMatchObject({
|
||||
method: 'POST',
|
||||
path: '/api/v1/sessions',
|
||||
body: { workingDir: directory, mode: 'codex', name: 'review', parentSessionId: SELF },
|
||||
});
|
||||
expect(t.calls[0].headers ?? {}).not.toHaveProperty('X-Codeman-Agent-Origin');
|
||||
expect(t.calls[1]).toMatchObject({ method: 'POST', path: `/api/v1/sessions/${OTHER}/interactive` });
|
||||
expect(JSON.parse(t.out[0])).toMatchObject({ sessionId: OTHER, workingDir: directory });
|
||||
});
|
||||
it('defaults to the current directory when no case is supplied', async () => {
|
||||
const t = deps([ok({ session: { id: OTHER } }), ok({})]);
|
||||
expect(await agentSpawn(t.d, base)).toBe(EXIT.ok);
|
||||
expect(t.calls[0].body).toMatchObject({ workingDir: process.cwd() });
|
||||
});
|
||||
it('keeps explicit case quick-start behavior', async () => {
|
||||
const t = deps([ok({ sessionId: OTHER })]);
|
||||
expect(await agentSpawn(t.d, { ...base, caseName: 'scratch' })).toBe(EXIT.ok);
|
||||
expect(t.calls).toHaveLength(1);
|
||||
expect(t.calls[0].path).toBe('/api/v1/quick-start');
|
||||
});
|
||||
it('refuses case and working directory together before network IO', async () => {
|
||||
const t = deps([]);
|
||||
expect(await agentSpawn(t.d, { ...base, caseName: 'scratch', workingDir: process.cwd() })).toBe(EXIT.refused);
|
||||
expect(t.calls).toEqual([]);
|
||||
});
|
||||
it('refuses missing directories, regular files and unknown modes before creating a session', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'cod549-project-'));
|
||||
dirs.push(directory);
|
||||
const file = join(directory, 'file');
|
||||
await writeFile(file, 'x');
|
||||
for (const options of [{ workingDir: join(directory, 'absent') }, { workingDir: file }, { mode: 'not-a-cli' }]) {
|
||||
const t = deps([]);
|
||||
expect(await agentSpawn(t.d, { ...base, ...options })).toBe(EXIT.refused);
|
||||
expect(t.calls).toEqual([]);
|
||||
}
|
||||
});
|
||||
it('reports the created session ID when startup fails instead of retrying or silently leaking it', async () => {
|
||||
const t = deps([
|
||||
ok({ session: { id: OTHER } }),
|
||||
{ status: 400, text: '', json: { success: false, error: 'cannot start', errorCode: 'OPERATION_FAILED' } },
|
||||
]);
|
||||
expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
|
||||
expect(t.out).toHaveLength(1);
|
||||
expect(JSON.parse(t.out.join('\n'))).toMatchObject({ sessionId: OTHER, created: true, ready: false });
|
||||
expect(t.calls).toHaveLength(2);
|
||||
});
|
||||
it('does not request startup when creation is refused', async () => {
|
||||
const t = deps([{ status: 403, text: 'Forbidden' }]);
|
||||
expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
|
||||
expect(t.calls).toHaveLength(1);
|
||||
});
|
||||
it('retains the exact created ID on an ambiguous startup transport failure without retrying', async () => {
|
||||
const t = deps([ok({ session: { id: OTHER } })]);
|
||||
const request = t.d.request;
|
||||
t.d.request = async (context, options) => {
|
||||
if (options.path.endsWith('/interactive')) {
|
||||
t.calls.push(options);
|
||||
throw new Error('connection lost');
|
||||
}
|
||||
return request(context, options);
|
||||
};
|
||||
expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
|
||||
expect(t.out).toHaveLength(1);
|
||||
expect(JSON.parse(t.out.join('\n'))).toMatchObject({
|
||||
sessionId: OTHER,
|
||||
created: true,
|
||||
startupError: 'connection lost',
|
||||
});
|
||||
expect(t.calls).toHaveLength(2);
|
||||
});
|
||||
it('treats an empty startup exception as a failure and keeps the created ID', async () => {
|
||||
const t = deps([ok({ session: { id: OTHER } })]);
|
||||
const request = t.d.request;
|
||||
t.d.request = async (context, options) => {
|
||||
if (options.path.endsWith('/interactive')) throw new Error('');
|
||||
return request(context, options);
|
||||
};
|
||||
expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
|
||||
expect(t.out).toHaveLength(1);
|
||||
expect(JSON.parse(t.out[0])).toMatchObject({
|
||||
sessionId: OTHER,
|
||||
ready: false,
|
||||
startupError: 'Startup request failed',
|
||||
});
|
||||
});
|
||||
it('exposes an optional case and working directory in the CLI', () => {
|
||||
const spawn = registerAgentCommands(new Command()).commands.find((command) => command.name() === 'spawn')!;
|
||||
expect(spawn.registeredArguments[0].required).toBe(false);
|
||||
expect(spawn.options.some((option) => option.long === '--working-dir')).toBe(true);
|
||||
});
|
||||
it('starts a shell through its registered startMode endpoint', async () => {
|
||||
const t = deps([ok({ session: { id: OTHER } }), ok({})]);
|
||||
expect(await agentSpawn(t.d, { ...base, mode: 'shell' })).toBe(EXIT.ok);
|
||||
expect(t.calls[1].path).toBe(`/api/v1/sessions/${OTHER}/shell`);
|
||||
});
|
||||
it('a create answer without session.id is a failure, and nothing is started', async () => {
|
||||
// `POST /api/sessions` answers `{ session: { id } }`; a bare `{ id }` is not that route.
|
||||
const t = deps([ok({ id: OTHER })]);
|
||||
expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
|
||||
expect(t.calls).toHaveLength(1);
|
||||
});
|
||||
it('an empty case name goes to quick-start (which refuses it), never to the caller cwd', async () => {
|
||||
const t = deps([
|
||||
{
|
||||
status: 400,
|
||||
text: '',
|
||||
json: { success: false, error: 'Invalid case name format', errorCode: 'INVALID_INPUT' },
|
||||
},
|
||||
]);
|
||||
expect(await agentSpawn(t.d, { ...base, caseName: '' })).toBe(EXIT.error);
|
||||
expect(t.calls).toHaveLength(1);
|
||||
expect(t.calls[0]).toMatchObject({ method: 'POST', path: '/api/v1/quick-start', body: { caseName: '' } });
|
||||
expect(t.calls[0].body).not.toHaveProperty('workingDir');
|
||||
});
|
||||
it('an empty case name with --working-dir is refused like any case plus --working-dir', async () => {
|
||||
const t = deps([]);
|
||||
expect(await agentSpawn(t.d, { ...base, caseName: '', workingDir: process.cwd() })).toBe(EXIT.refused);
|
||||
expect(t.calls).toEqual([]);
|
||||
});
|
||||
it('an empty or whitespace-only --working-dir is refused before any request, never the caller cwd', async () => {
|
||||
for (const workingDir of ['', ' ', '\t']) {
|
||||
const t = deps([]);
|
||||
expect(await agentSpawn(t.d, { ...base, workingDir })).toBe(EXIT.refused);
|
||||
expect(t.calls).toEqual([]);
|
||||
expect(t.out.join('\n')).toContain('--working-dir is empty');
|
||||
}
|
||||
});
|
||||
it('with ready on claude: create, then /interactive, then wait-output on the created id', async () => {
|
||||
const t = deps([ok({ session: { id: OTHER } }), ok({}), ok({ wait: { matched: true } })]);
|
||||
expect(await agentSpawn(t.d, { ...base, mode: 'claude', ready: true })).toBe(EXIT.ok);
|
||||
expect(t.calls.map((c) => `${c.method} ${c.path}`)).toEqual([
|
||||
'POST /api/v1/sessions',
|
||||
`POST /api/v1/sessions/${OTHER}/interactive`,
|
||||
`GET /api/v1/sessions/${OTHER}/wait-output`,
|
||||
]);
|
||||
expect(t.calls[2].query).toMatchObject({ match: 'shift+tab', from: 'buffer' });
|
||||
expect(JSON.parse(t.out[0])).toMatchObject({ sessionId: OTHER, created: true, ready: true });
|
||||
});
|
||||
it('without --json a failed start prints the id alone on stdout, the reason on stderr, and exits 1', async () => {
|
||||
const t = deps([
|
||||
ok({ session: { id: OTHER } }),
|
||||
{ status: 400, text: '', json: { success: false, error: 'cannot start', errorCode: 'OPERATION_FAILED' } },
|
||||
]);
|
||||
t.d.json = false;
|
||||
expect(await agentSpawn(t.d, { ...base, ready: true })).toBe(EXIT.error);
|
||||
expect(t.out).toEqual([OTHER]);
|
||||
expect(t.err).toHaveLength(1);
|
||||
expect(t.err[0]).toContain(`Session ${OTHER} was created but startup failed`);
|
||||
expect(t.err[0]).toContain('cannot start');
|
||||
expect(t.calls).toHaveLength(2); // no readiness wait after a failed start
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent spawn through the real argument parser and transport', () => {
|
||||
async function runSpawn(args: string[]) {
|
||||
const seen: Array<{ method?: string; url?: string; body: unknown }> = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c: Buffer) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString();
|
||||
seen.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : undefined });
|
||||
res
|
||||
.writeHead(400, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify({ success: false, error: 'refused', errorCode: 'INVALID_INPUT' }));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done));
|
||||
const { port } = server.address() as { port: number };
|
||||
const saved = { ...process.env };
|
||||
const savedExit = process.exitCode;
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
Object.assign(process.env, {
|
||||
CODEMAN_MUX: '1',
|
||||
CODEMAN_API_URL: `http://127.0.0.1:${port}`,
|
||||
CODEMAN_SESSION_ID: SELF,
|
||||
});
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommands(program);
|
||||
await program.parseAsync(['node', 'codeman', 'agent', 'spawn', ...args, '--no-ready', '--json']);
|
||||
return { seen, exitCode: process.exitCode };
|
||||
} finally {
|
||||
process.exitCode = savedExit;
|
||||
for (const key of ['CODEMAN_MUX', 'CODEMAN_API_URL', 'CODEMAN_SESSION_ID', 'CODEMAN_PASSWORD']) {
|
||||
if (saved[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = saved[key];
|
||||
}
|
||||
log.mockRestore();
|
||||
error.mockRestore();
|
||||
await new Promise<void>((done) => server.close(() => done()));
|
||||
}
|
||||
}
|
||||
|
||||
it('`spawn ""` (as `spawn "$CASE"` with CASE unset) reaches quick-start with caseName "" and exits 1', async () => {
|
||||
const { seen, exitCode } = await runSpawn(['']);
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/quick-start', body: { caseName: '' } });
|
||||
expect(exitCode).toBe(EXIT.error);
|
||||
});
|
||||
it('`spawn --working-dir ""` (as `--working-dir "$WT"` with WT unset) sends nothing and exits 4', async () => {
|
||||
for (const value of ['', ' ']) {
|
||||
const { seen, exitCode } = await runSpawn(['--working-dir', value]);
|
||||
expect(seen).toEqual([]);
|
||||
expect(exitCode).toBe(EXIT.refused);
|
||||
}
|
||||
});
|
||||
it('`spawn` with no case argument creates in the caller cwd', async () => {
|
||||
const { seen, exitCode } = await runSpawn([]);
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/sessions', body: { workingDir: process.cwd() } });
|
||||
expect(exitCode).toBe(EXIT.error);
|
||||
});
|
||||
it('bare `spawn` from a HOME cwd sends nothing and exits 4; `--working-dir <HOME>` is sent', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'cod549-home-'));
|
||||
dirs.push(home);
|
||||
vi.stubEnv('HOME', home);
|
||||
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(home);
|
||||
try {
|
||||
const bare = await runSpawn([]);
|
||||
expect(bare.seen).toEqual([]);
|
||||
expect(bare.exitCode).toBe(EXIT.refused);
|
||||
const explicit = await runSpawn(['--working-dir', home]);
|
||||
expect(explicit.seen).toHaveLength(1);
|
||||
expect(explicit.seen[0]).toMatchObject({ url: '/api/v1/sessions', body: { workingDir: home } });
|
||||
} finally {
|
||||
cwd.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent spawn never treats an implicit HOME cwd as a project', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
async function homeDir(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'cod549-home-'));
|
||||
dirs.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
it('refuses a cwd that is HOME with exit 4 before any request and names the explicit flag', async () => {
|
||||
const home = await homeDir();
|
||||
vi.stubEnv('HOME', home);
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(home);
|
||||
const t = deps([]);
|
||||
expect(await agentSpawn(t.d, { ...base, mode: 'claude' })).toBe(EXIT.refused);
|
||||
expect(t.calls).toEqual([]);
|
||||
expect(JSON.parse(t.out[0]).error).toContain('pass --working-dir ~');
|
||||
});
|
||||
it('compares realpaths, so a symlinked HOME still matches the cwd it points at', async () => {
|
||||
const real = await homeDir();
|
||||
const link = join(await homeDir(), 'home-link');
|
||||
await symlink(real, link);
|
||||
vi.stubEnv('HOME', link);
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(real);
|
||||
const t = deps([]);
|
||||
expect(await agentSpawn(t.d, base)).toBe(EXIT.refused);
|
||||
expect(t.calls).toEqual([]);
|
||||
});
|
||||
it('allows an explicit --working-dir that is HOME, as POST /api/sessions does', async () => {
|
||||
const home = await homeDir();
|
||||
vi.stubEnv('HOME', home);
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(home);
|
||||
const t = deps([ok({ session: { id: OTHER } }), ok({})]);
|
||||
expect(await agentSpawn(t.d, { ...base, workingDir: home })).toBe(EXIT.ok);
|
||||
expect(t.calls[0].body).toMatchObject({ workingDir: home });
|
||||
});
|
||||
it('allows a cwd below HOME', async () => {
|
||||
const home = await homeDir();
|
||||
const project = join(home, 'src', 'app');
|
||||
await mkdir(project, { recursive: true });
|
||||
vi.stubEnv('HOME', home);
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(project);
|
||||
const t = deps([ok({ session: { id: OTHER } }), ok({})]);
|
||||
expect(await agentSpawn(t.d, base)).toBe(EXIT.ok);
|
||||
expect(t.calls[0].body).toMatchObject({ workingDir: project });
|
||||
});
|
||||
it('never throws when HOME cannot be resolved, and compares the resolved paths instead', async () => {
|
||||
const missing = join(await homeDir(), 'absent');
|
||||
vi.stubEnv('HOME', missing);
|
||||
await expect(isHomeDirectory(process.cwd())).resolves.toBe(false);
|
||||
await expect(isHomeDirectory(missing)).resolves.toBe(true);
|
||||
vi.stubEnv('HOME', '');
|
||||
await expect(isHomeDirectory(process.cwd())).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the directory-spawn --json shape is documented the same way everywhere', () => {
|
||||
// The wiki wraps lines, so whitespace is normalized before comparing.
|
||||
const clause =
|
||||
'`{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed';
|
||||
for (const file of ['README.md', 'docs/api-reference.md', 'docs/wiki/Driving-Codeman-From-An-Agent.md']) {
|
||||
it(file, () => {
|
||||
expect(readFileSync(new URL(`../${file}`, import.meta.url), 'utf8').replace(/\s+/g, ' ')).toContain(clause);
|
||||
});
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -36,6 +36,7 @@ describe('per-mode capability predicates', () => {
|
||||
['grok', true, false, false],
|
||||
['deepseek', true, false, true],
|
||||
['omp', true, false, false],
|
||||
['copilot', true, false, false],
|
||||
] as Array<[SessionMode, boolean, boolean, boolean]>)(
|
||||
'%s: external=%s altScreenStrip=%s hooks=%s',
|
||||
(mode, external, altScreen, hooks) => {
|
||||
@@ -48,7 +49,7 @@ describe('per-mode capability predicates', () => {
|
||||
it('covers every enabled mode (sanity)', () => {
|
||||
// If a CLI is added without a row above, this fails rather than the table silently
|
||||
// describing a subset of reality.
|
||||
expect(MODES.length).toBe(10);
|
||||
expect(MODES.length).toBe(11);
|
||||
});
|
||||
|
||||
it('keeps the three predicates genuinely distinct', () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ function walk(cmd: Command, path: string[] = []): Array<{ path: string[]; cmd: C
|
||||
const TOP_LEVEL: Record<string, string[]> = {
|
||||
attach: [],
|
||||
skill: [],
|
||||
agent: [],
|
||||
session: ['s'],
|
||||
task: ['t'],
|
||||
ralph: ['r'],
|
||||
@@ -53,6 +54,7 @@ const SUBCOMMANDS: Record<string, Record<string, string[]>> = {
|
||||
task: { add: [], list: ['ls'], status: [], remove: ['rm'], clear: [] },
|
||||
ralph: { start: [], stop: [], status: [] },
|
||||
skill: { install: [], uninstall: [] },
|
||||
agent: { ls: ['list'], spawn: [], send: [], wait: [], read: [], interrupt: [], rm: [] },
|
||||
service: { install: [], uninstall: [], status: [] },
|
||||
users: { add: [], passwd: [], list: ['ls'], rm: [] },
|
||||
};
|
||||
@@ -206,3 +208,29 @@ describe('help text', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('unauthenticated tunnel launch option', () => {
|
||||
it.each(['web', 'service install'])('registers --allow-unauthenticated-tunnel on %s', (path) => {
|
||||
const command = path === 'web' ? find(program, 'web')! : find(find(program, 'service')!, 'install')!;
|
||||
const options = command.options.filter((option) => option.long === '--allow-unauthenticated-tunnel');
|
||||
expect(
|
||||
options.map((option) => ({
|
||||
flags: option.flags,
|
||||
attribute: option.attributeName(),
|
||||
required: option.required,
|
||||
optional: option.optional,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
flags: '--allow-unauthenticated-tunnel',
|
||||
attribute: 'allowUnauthenticatedTunnel',
|
||||
required: false,
|
||||
optional: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('documents --allow-unauthenticated-tunnel in web help', () => {
|
||||
expect(find(program, 'web')!.helpInformation()).toContain('--allow-unauthenticated-tunnel');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment node
|
||||
// capabilities.newline: the bytes Shift+Enter types into a CLI's pane. Data in the registry, not
|
||||
// a branch on the CLI id (test/cli-registry-no-id-branching.test.ts keeps the latter true).
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
import type { CliEntry } from '../src/config/cli-registry/types.js';
|
||||
|
||||
const claude = () => structuredClone(STOCK_CLIS.find((e) => (e.id as string) === 'claude')!) as CliEntry;
|
||||
|
||||
describe('capabilities.newline', () => {
|
||||
it('no stock CLI declares a chord: every one keeps the line feed', () => {
|
||||
// codex 0.147.0 takes a line feed (checked against a real tmux pane), so there is no CLI that
|
||||
// needs esc-enter yet. The capability exists for a user clis.json override and the next CLI.
|
||||
const declared = STOCK_CLIS.filter((e) => e.capabilities.newline).map((e) => e.id as string);
|
||||
expect(declared).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['line-feed', 'esc-enter'])('schema accepts %s', (value) => {
|
||||
const e = claude();
|
||||
(e.capabilities as Record<string, unknown>).newline = value;
|
||||
expect(CliEntrySchema.safeParse(e).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['lf', 'crlf', '\x1b\r', '', 0])('schema rejects %j (no free-form byte strings in config)', (value) => {
|
||||
const e = claude();
|
||||
(e.capabilities as Record<string, unknown>).newline = value;
|
||||
expect(CliEntrySchema.safeParse(e).success).toBe(false);
|
||||
});
|
||||
|
||||
it('is optional, so an entry that declares nothing keeps the line feed', () => {
|
||||
const e = claude();
|
||||
delete (e.capabilities as Record<string, unknown>).newline;
|
||||
expect(CliEntrySchema.safeParse(e).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* @fileoverview `codeman notify`: what it sends, what it prints and the exit code, driven with a
|
||||
* recording fake transport plus the real `postJson` against a local server. Port: ephemeral 127.0.0.1.
|
||||
*/
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { postJson, runNotify, type JsonPost } from '../src/cli-notify.js';
|
||||
|
||||
const ENV = { CODEMAN_SESSION_ID: 'abc12345-full-id', CODEMAN_API_URL: 'http://127.0.0.1:3000' };
|
||||
|
||||
function fake(status: number, error?: string, delivery?: { ok: boolean; error?: string }) {
|
||||
const post = vi.fn<JsonPost>(async () => ({ status, error, delivery }));
|
||||
return post;
|
||||
}
|
||||
|
||||
describe('runNotify', () => {
|
||||
it('refuses outside a Codeman session, before any request', async () => {
|
||||
const post = fake(200);
|
||||
const r = await runNotify({ message: 'hi' }, {}, '/work', post);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toMatch(/inside a Codeman session/);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts the message to this session with the session id URL-encoded', async () => {
|
||||
const post = fake(200);
|
||||
const r = await runNotify({ message: 'press the reset button' }, ENV, '/work', post);
|
||||
expect(r).toEqual({ code: 0, out: 'Posted to Discord' });
|
||||
expect(post).toHaveBeenCalledWith('http://127.0.0.1:3000', '/api/sessions/abc12345-full-id/discord-notify', {
|
||||
message: 'press the reset button',
|
||||
});
|
||||
});
|
||||
|
||||
it('adds urgent, and turns a relative --file into an absolute path under the working folder', async () => {
|
||||
const post = fake(200);
|
||||
await runNotify({ message: 'log', urgent: true, file: 'out/run.log' }, ENV, '/work', post);
|
||||
expect(post.mock.calls[0][2]).toEqual({ message: 'log', urgent: true, file: '/work/out/run.log' });
|
||||
await runNotify({ message: 'log', file: '/abs/run.log' }, ENV, '/work', post);
|
||||
expect(post.mock.calls[1][2]).toEqual({ message: 'log', file: '/abs/run.log' });
|
||||
});
|
||||
|
||||
it('lets --session and --url override the environment', async () => {
|
||||
const post = fake(200);
|
||||
await runNotify({ message: 'x', session: 'other', url: 'http://h:1' }, ENV, '/work', post);
|
||||
expect(post.mock.calls[0].slice(0, 2)).toEqual(['http://h:1', '/api/sessions/other/discord-notify']);
|
||||
});
|
||||
|
||||
it('exits 1 when Codeman answered 200 but Discord refused the post, so a blocked agent knows nobody was told', async () => {
|
||||
const r = await runNotify(
|
||||
{ message: 'x' },
|
||||
ENV,
|
||||
'/w',
|
||||
fake(200, undefined, { ok: false, error: 'The bot token was refused' })
|
||||
);
|
||||
expect(r).toEqual({ code: 1, err: 'Could not post to Discord: The bot token was refused' });
|
||||
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(200, undefined, { ok: true }))).code).toBe(0);
|
||||
});
|
||||
|
||||
it('postJson gives up on a server that never answers, with status 0', async () => {
|
||||
const server = createServer(() => undefined);
|
||||
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
||||
try {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const r = await postJson(`http://127.0.0.1:${port}`, '/x', {}, 100);
|
||||
expect(r.status).toBe(0);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('postJson settles when the server sends headers and then stalls the body', async () => {
|
||||
const server = createServer((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '500' });
|
||||
res.write('{"success":');
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
||||
try {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
expect((await postJson(`http://127.0.0.1:${port}`, '/x', {}, 100)).status).toBe(0);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a disabled bridge (409) as success that says so', async () => {
|
||||
const r = await runNotify({ message: 'x' }, ENV, '/work', fake(409));
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toMatch(/bridge is off/);
|
||||
});
|
||||
|
||||
it('fails with the server error, or the status, or a plain message when nothing answered', async () => {
|
||||
expect(
|
||||
(await runNotify({ message: 'x' }, ENV, '/w', fake(400, 'The file must be inside the session folder'))).err
|
||||
).toBe('Could not post to Discord: The file must be inside the session folder');
|
||||
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(429))).err).toMatch(/Too many messages/);
|
||||
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(500))).err).toBe('Could not post to Discord (HTTP 500)');
|
||||
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(0))).err).toBe('Could not post to Discord');
|
||||
});
|
||||
});
|
||||
|
||||
describe('postJson (real transport)', () => {
|
||||
let server: Server;
|
||||
afterEach(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
||||
|
||||
async function serve(status: number, reply: unknown) {
|
||||
const seen: { method?: string; url?: string; body: string; type?: string } = { body: '' };
|
||||
server = createServer((req, res) => {
|
||||
req.on('data', (c) => (seen.body += c));
|
||||
req.on('end', () => {
|
||||
seen.method = req.method;
|
||||
seen.url = req.url;
|
||||
seen.type = req.headers['content-type'];
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(reply));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
return { seen, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` };
|
||||
}
|
||||
|
||||
it('sends a JSON POST and reports the status and the server error text', async () => {
|
||||
const { seen, url } = await serve(400, { success: false, error: 'nope' });
|
||||
expect(await postJson(url, '/api/sessions/s1/discord-notify', { message: 'hi' })).toEqual({
|
||||
status: 400,
|
||||
error: 'nope',
|
||||
});
|
||||
expect(seen).toMatchObject({ method: 'POST', url: '/api/sessions/s1/discord-notify', type: 'application/json' });
|
||||
expect(JSON.parse(seen.body)).toEqual({ message: 'hi' });
|
||||
});
|
||||
|
||||
it('answers status 0 when nothing is listening', async () => {
|
||||
const { url } = await serve(200, {});
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
expect((await postJson(url, '/x', {})).status).toBe(0);
|
||||
server = createServer().listen(0); // so afterEach has something to close
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,13 @@
|
||||
* CAN DO, it belongs in `CliCapabilities` instead — and if it needs to run code, in
|
||||
* `config/cli-registry/profiles.ts` as a named profile.
|
||||
*
|
||||
* ⚠️ Each entry also carries how many times its expression occurs in that file, compared
|
||||
* EXACTLY. The key is only `<file>::<expression>`, so without a count an approved branch
|
||||
* approved every later copy of the same text in the same file: the codex launch defaults
|
||||
* added two more `mode === 'codex'` branches to session-routes.ts and passed silently,
|
||||
* because the legacy-plumbing entry already covered that string. A new copy now fails as
|
||||
* an unapproved branch would, and a removed one fails as stale until the count drops.
|
||||
*
|
||||
* Port: none (pure static analysis).
|
||||
*/
|
||||
|
||||
@@ -67,47 +74,65 @@ const EXEMPT_FILES = new Set(
|
||||
].map((p) => p.split('/').join(sep))
|
||||
);
|
||||
|
||||
/** One approved branch: how many copies of it the file holds, and why it is not a capability. */
|
||||
interface Allowance {
|
||||
count: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const allow = (count: number, reason: string): Allowance => ({ count, reason });
|
||||
|
||||
/**
|
||||
* Specific surviving branches, each with the reason it is not a capability.
|
||||
* Specific surviving branches, each with the reason it is not a capability and the exact
|
||||
* number of times the expression occurs in that file (see the header).
|
||||
* Keyed `<relative path>::<the matched expression>`.
|
||||
*/
|
||||
const ALLOWED_BRANCHES: Record<string, string> = {
|
||||
const ALLOWED_BRANCHES: Record<string, Allowance> = {
|
||||
// --- Legacy <Mode>Config plumbing (public wire shape, see the header) ---
|
||||
"web/routes/session-routes.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing',
|
||||
"web/routes/session-routes.ts::mode === 'codex'": 'legacy <Mode>Config plumbing',
|
||||
"web/routes/session-routes.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing',
|
||||
"web/routes/session-routes.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing',
|
||||
"web/routes/session-routes.ts::mode === 'pi'": 'legacy <Mode>Config plumbing',
|
||||
"web/routes/session-routes.ts::mode === 'grok'": 'legacy <Mode>Config plumbing',
|
||||
"web/routes/session-routes.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing',
|
||||
"web/server.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/server.ts::mode === 'codex'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/server.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/server.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/server.ts::mode === 'pi'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/server.ts::mode === 'grok'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/server.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/server.ts::mode === 'omp'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/routes/session-routes.ts::mode === 'opencode'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||
"web/routes/session-routes.ts::mode === 'codex'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||
"web/routes/session-routes.ts::mode === 'gemini'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||
"web/routes/session-routes.ts::mode === 'antigravity'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||
"web/routes/session-routes.ts::mode === 'pi'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||
"web/routes/session-routes.ts::mode === 'grok'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||
"web/routes/session-routes.ts::mode === 'copilot'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||
"web/routes/session-routes.ts::mode === 'deepseek'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||
"web/server.ts::mode === 'opencode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
"web/server.ts::mode === 'codex'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
"web/server.ts::mode === 'gemini'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
"web/server.ts::mode === 'antigravity'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
"web/server.ts::mode === 'pi'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
"web/server.ts::mode === 'grok'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
"web/server.ts::mode === 'copilot'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
"web/server.ts::mode === 'deepseek'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
"web/server.ts::mode === 'omp'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
|
||||
// --- Claude's remote/docker command construction ---
|
||||
"tmux-manager.ts::mode === 'claude'":
|
||||
"tmux-manager.ts::mode === 'claude'": allow(
|
||||
2,
|
||||
"claude's remote pane command carries per-session permission flags, and its docker form is " +
|
||||
'`--session-id … || resume`; neither fits a static overlays.command string',
|
||||
"tmux-manager.ts::mode === 'omp'":
|
||||
'`--session-id … || resume`; neither fits a static overlays.command string'
|
||||
),
|
||||
"tmux-manager.ts::mode === 'omp'": allow(
|
||||
1,
|
||||
'remote omp respawn needs the pinned/continue --resume override threaded through ' +
|
||||
'(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' +
|
||||
'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' +
|
||||
'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode',
|
||||
'(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' +
|
||||
'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' +
|
||||
'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode'
|
||||
),
|
||||
|
||||
// --- Per-CLI prose and launch handling not yet generalised ---
|
||||
"web/session-wait-registry.ts::mode === 'deepseek'":
|
||||
'an error message explaining why THIS mode in particular will never deliver a stop signal',
|
||||
"web/routes/approval-routes.ts::mode === 'deepseek'":
|
||||
'the DeepSeek status bridge is the only non-claude source of approval items',
|
||||
"cron/cron-service.ts::mode === 'claude'": 'cron launch handling, not yet generalised',
|
||||
"cron/cron-service.ts::mode === 'shell'": 'cron launch handling, not yet generalised',
|
||||
"web/routes/session-routes.ts::mode === 'claude'": 'docker case bookkeeping keyed on the claude conversation id',
|
||||
"cli.ts::mode === 'shell'": 'a CLI-table label, not behaviour',
|
||||
"web/session-wait-registry.ts::mode === 'deepseek'": allow(
|
||||
1,
|
||||
'an error message explaining why THIS mode in particular will never deliver a stop signal'
|
||||
),
|
||||
"cron/cron-service.ts::mode === 'claude'": allow(1, 'cron launch handling, not yet generalised'),
|
||||
"cron/cron-service.ts::mode === 'shell'": allow(1, 'cron launch handling, not yet generalised'),
|
||||
"web/routes/session-routes.ts::mode === 'claude'": allow(
|
||||
2,
|
||||
'docker case bookkeeping keyed on the claude conversation id'
|
||||
),
|
||||
"cli.ts::mode === 'shell'": allow(1, 'a CLI-table label, not behaviour'),
|
||||
|
||||
// --- Negated forms surfaced when BRANCH_PATTERN widened past `===` (see its comment) ---
|
||||
//
|
||||
@@ -121,43 +146,50 @@ const ALLOWED_BRANCHES: Record<string, string> = {
|
||||
// there, the shared predicate silently widened both to a mode with no transcript to read.
|
||||
// CLAUDE.md documents this as deliberate and `test/deepseek-mode.test.ts` pins it, so a
|
||||
// capability here would be actively wrong.
|
||||
"web/routes/readmymind-routes.ts::mode !== 'claude'":
|
||||
'deliberately mode-not-capability; pinned by deepseek-mode.test.ts',
|
||||
"web/server.ts::mode !== 'claude'":
|
||||
"web/routes/readmymind-routes.ts::mode !== 'claude'": allow(
|
||||
1,
|
||||
'deliberately mode-not-capability; pinned by deepseek-mode.test.ts'
|
||||
),
|
||||
"web/server.ts::mode !== 'claude'": allow(
|
||||
2,
|
||||
"intent capture reads Claude's own transcript, and the recovered-workspace hook sweep " +
|
||||
'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)',
|
||||
'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)'
|
||||
),
|
||||
|
||||
// The TUI is a CLIENT of the server, and these two are about what it can offer for a row:
|
||||
// resume builds a `claude --resume`, and the mode badge is suppressed for the default mode
|
||||
// purely so the common case reads clean. The badge one is cosmetic and not a capability at
|
||||
// all; the resume one would need a "resumable from a claude transcript" field that nothing
|
||||
// else would read.
|
||||
"tui/tui-app.ts::mode !== 'claude'": 'TUI resume builds a claude --resume; claude-transcript-only by construction',
|
||||
"tui/tui-render.ts::mode !== 'claude'": 'cosmetic: suppress the mode badge for the default mode',
|
||||
"tui/tui-app.ts::mode !== 'claude'": allow(
|
||||
1,
|
||||
'TUI resume builds a claude --resume; claude-transcript-only by construction'
|
||||
),
|
||||
"tui/tui-render.ts::mode !== 'claude'": allow(1, 'cosmetic: suppress the mode badge for the default mode'),
|
||||
|
||||
// Push approve/deny BUTTONS are withheld for dsh because the answer route refuses
|
||||
// keystrokes for its dialogs (third-party TUI, unmeasured contract) — a button whose
|
||||
// answer would be refused is worse than none. Arguably wants an "answerable dialogs"
|
||||
// capability; deliberately not invented here.
|
||||
"web/routes/hook-event-routes.ts::mode !== 'deepseek'":
|
||||
'push buttons withheld where the answer route refuses keystrokes',
|
||||
|
||||
// Legacy <Mode>Config plumbing, same category as the `===` entries above.
|
||||
"web/routes/session-routes.ts::mode !== 'omp'": 'legacy <Mode>Config plumbing (resolveOmpConfigForCreate)',
|
||||
"web/routes/session-routes.ts::mode !== 'omp'": allow(
|
||||
2,
|
||||
'legacy <Mode>Config plumbing (resolveOmpConfigForCreate), and one link of the scaffolded-case hooks ' +
|
||||
'chain (see the opencode entry below)'
|
||||
),
|
||||
|
||||
// ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its
|
||||
// own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a
|
||||
// Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this
|
||||
// change; expressing the chain as a capability would have to pick a side and would
|
||||
// therefore be a behaviour change. Left exactly as found, and named here so it is visible.
|
||||
"web/routes/session-routes.ts::mode !== 'opencode'":
|
||||
"web/routes/session-routes.ts::mode !== 'opencode'": allow(
|
||||
2,
|
||||
'scaffolded-case hooks + the COD-91 self-heal skip; the chain omits deepseek upstream, ' +
|
||||
'so any capability form would change behaviour — see PR discussion',
|
||||
"web/routes/session-routes.ts::mode !== 'codex'": 'scaffolded-case hooks (see the opencode entry)',
|
||||
"web/routes/session-routes.ts::mode !== 'gemini'": 'scaffolded-case hooks (see the opencode entry)',
|
||||
"web/routes/session-routes.ts::mode !== 'antigravity'": 'scaffolded-case hooks (see the opencode entry)',
|
||||
"web/routes/session-routes.ts::mode !== 'pi'": 'scaffolded-case hooks (see the opencode entry)',
|
||||
"web/routes/session-routes.ts::mode !== 'grok'": 'scaffolded-case hooks (see the opencode entry)',
|
||||
'so any capability form would change behaviour — see PR discussion'
|
||||
),
|
||||
"web/routes/session-routes.ts::mode !== 'codex'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
|
||||
"web/routes/session-routes.ts::mode !== 'gemini'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
|
||||
"web/routes/session-routes.ts::mode !== 'antigravity'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
|
||||
"web/routes/session-routes.ts::mode !== 'pi'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
|
||||
"web/routes/session-routes.ts::mode !== 'grok'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
|
||||
};
|
||||
|
||||
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
|
||||
@@ -246,6 +278,43 @@ function scan(): { findings: Finding[]; filesScanned: number } {
|
||||
|
||||
const { findings, filesScanned } = scan();
|
||||
|
||||
interface CountMismatch {
|
||||
key: string;
|
||||
allowed: number;
|
||||
found: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowlisted keys whose occurrence count differs from the approved one: `grown` holds the
|
||||
* keys with MORE copies than approved (a new branch riding an old approval), `shrunk` the
|
||||
* ones with fewer (a stale approval that would let the next copy back in unseen).
|
||||
* Unallowlisted keys are not this function's business; the offenders check covers them.
|
||||
*/
|
||||
function countMismatches(
|
||||
found: Finding[],
|
||||
allowed: Record<string, Allowance>
|
||||
): { grown: CountMismatch[]; shrunk: CountMismatch[] } {
|
||||
const byKey = new Map<string, Finding[]>();
|
||||
for (const f of found) byKey.set(f.key, [...(byKey.get(f.key) ?? []), f]);
|
||||
const grown: CountMismatch[] = [];
|
||||
const shrunk: CountMismatch[] = [];
|
||||
for (const [key, allowance] of Object.entries(allowed)) {
|
||||
const hits = byKey.get(key) ?? [];
|
||||
const mismatch = {
|
||||
key,
|
||||
allowed: allowance.count,
|
||||
found: hits.length,
|
||||
lines: hits.map((f) => `${f.file}:${f.line}`),
|
||||
};
|
||||
if (hits.length > allowance.count) grown.push(mismatch);
|
||||
else if (hits.length < allowance.count) shrunk.push(mismatch);
|
||||
}
|
||||
return { grown, shrunk };
|
||||
}
|
||||
|
||||
const { grown, shrunk } = countMismatches(findings, ALLOWED_BRANCHES);
|
||||
|
||||
describe('no CLI-id branching outside the stock catalog', () => {
|
||||
it('scans a meaningful number of source files (sanity)', () => {
|
||||
// If this collapses toward zero the walker or the exemption list drifted and every
|
||||
@@ -293,12 +362,56 @@ describe('no CLI-id branching outside the stock catalog', () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('has no new copy of an allowlisted branch', () => {
|
||||
// An approval covers the copies that were reviewed, not every later line that happens
|
||||
// to spell the same expression in the same file.
|
||||
const detail = grown
|
||||
.map((m) => ` ${m.key}: ${m.found} found, ${m.allowed} approved\n ${m.lines.join('\n ')}`)
|
||||
.join('\n');
|
||||
expect(
|
||||
grown,
|
||||
grown.length === 0
|
||||
? ''
|
||||
: `Found more copies of an allowlisted CLI-id branch than were approved:\n${detail}\n\n` +
|
||||
'Express the new copy as a CliCapabilities field (or a named profile) rather than raising ' +
|
||||
"the count. Raise it only for another branch of the SAME kind, and read this file's header first."
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('has no stale allowlist entries', () => {
|
||||
// An allowlisted branch that no longer exists is a lie about the codebase, and the next
|
||||
// person to reintroduce that exact branch would sail straight through.
|
||||
const present = new Set(findings.map((f) => f.key));
|
||||
const stale = Object.keys(ALLOWED_BRANCHES).filter((key) => !present.has(key));
|
||||
expect(stale, `ALLOWED_BRANCHES entries no longer present — delete them:\n ${stale.join('\n ')}`).toEqual([]);
|
||||
// person to reintroduce that exact branch would sail straight through. The same holds
|
||||
// for an approved count above what the file still has.
|
||||
const stale = shrunk.map((m) => `${m.key}: ${m.found} found, ${m.allowed} approved`);
|
||||
expect(
|
||||
stale,
|
||||
`ALLOWED_BRANCHES entries no longer (fully) present; delete them or lower the count:\n ${stale.join('\n ')}`
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts copies per key, so one extra copy of an approved branch fails (anti-vacuity)', () => {
|
||||
const at = (line: number): Finding => ({
|
||||
file: 'web/example.ts',
|
||||
expression: "mode === 'codex'",
|
||||
line,
|
||||
key: "web/example.ts::mode === 'codex'",
|
||||
});
|
||||
const approved = { "web/example.ts::mode === 'codex'": allow(1, 'synthetic') };
|
||||
expect(countMismatches([at(10)], approved)).toEqual({ grown: [], shrunk: [] });
|
||||
const extra = countMismatches([at(10), at(42)], approved);
|
||||
expect(extra.grown).toEqual([
|
||||
{
|
||||
key: "web/example.ts::mode === 'codex'",
|
||||
allowed: 1,
|
||||
found: 2,
|
||||
lines: ['web/example.ts:10', 'web/example.ts:42'],
|
||||
},
|
||||
]);
|
||||
expect(countMismatches([], approved).shrunk.map((m) => m.key)).toEqual(["web/example.ts::mode === 'codex'"]);
|
||||
// Every live entry carries a positive whole count, or the comparison means nothing.
|
||||
for (const [key, { count }] of Object.entries(ALLOWED_BRANCHES)) {
|
||||
expect(Number.isInteger(count) && count > 0, key).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -55,6 +55,65 @@ describe('customModelInjection.launchModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Mutates the shipped deepseek configDir recipe (the one real-home-linking entry) and expects rejection. */
|
||||
function expectRejectedOnDeepSeek(mutate: (recipe: Record<string, unknown>) => void, because: string): void {
|
||||
const entry = baseEntry('deepseek');
|
||||
const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>;
|
||||
mutate(recipe);
|
||||
const result = CliEntrySchema.safeParse(entry);
|
||||
expect(result.success, `expected rejection: ${because}`).toBe(false);
|
||||
}
|
||||
|
||||
describe('configDir real-home linking (linkFromRealHome / realHomeFallback)', () => {
|
||||
it('rejects a realHomeFallback that is not a single relative path segment', () => {
|
||||
// The consumer joins it under the user's home, so any of these escapes that home:
|
||||
// and bare `.` IS that home: linking through it would expose the whole tree
|
||||
// unshielded instead of just the declared segments.
|
||||
for (const bad of ['../x', '.dsh/evil', '/.dsh', '..', '.']) {
|
||||
expectRejectedOnDeepSeek((r) => {
|
||||
r.realHomeFallback = bad;
|
||||
}, `realHomeFallback ${JSON.stringify(bad)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a shipped-style dot-prefixed segment', () => {
|
||||
const entry = baseEntry('deepseek');
|
||||
expect(CliEntrySchema.safeParse(entry).success).toBe(true); // ships as `.dsh`
|
||||
const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>;
|
||||
recipe.realHomeFallback = '.dsh-relocated';
|
||||
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
|
||||
});
|
||||
|
||||
it('requires realHomeFallback whenever linkFromRealHome is declared', () => {
|
||||
expectRejectedOnDeepSeek((r) => {
|
||||
delete r.realHomeFallback;
|
||||
}, 'without it the consumer would have to hardcode a per-CLI home default');
|
||||
});
|
||||
|
||||
it('rejects a linked entry that collides with fileName', () => {
|
||||
// A symlink at the generated file's own name would write THROUGH into the user's real config.
|
||||
expectRejectedOnDeepSeek((r) => {
|
||||
r.linkFromRealHome = ['cordis.patch.yml', 'profiles'];
|
||||
}, 'linkFromRealHome containing fileName');
|
||||
});
|
||||
|
||||
it('requires linkFromRealHomeDirs to be a subset of linkFromRealHome', () => {
|
||||
// types.ts documents the rule; a name absent from the linked list names nothing,
|
||||
// so the create-missing-directory behaviour would silently never apply to it.
|
||||
expectRejectedOnDeepSeek((r) => {
|
||||
r.linkFromRealHome = ['profiles', 'settings.yaml'];
|
||||
r.linkFromRealHomeDirs = ['profiles', 'sessions'];
|
||||
}, 'linkFromRealHomeDirs naming an unlinked entry');
|
||||
// The shipped recipe is a valid subset (deepseek: profiles and sessions are dirs
|
||||
// of the three linked entries) and keeps loading.
|
||||
const entry = baseEntry('deepseek');
|
||||
const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>;
|
||||
expect(recipe.linkFromRealHome).toEqual(['profiles', 'sessions', 'settings.yaml']);
|
||||
expect(recipe.linkFromRealHomeDirs).toEqual(['profiles', 'sessions']);
|
||||
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the shipped catalog', () => {
|
||||
it('validates every stock entry exactly as shipped', () => {
|
||||
// If this fails, the catalog cannot load at all — every other test here is downstream.
|
||||
@@ -183,6 +242,126 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
|
||||
expect(compileVersionRegex(src), `${entry.id} declares an awaitingLine the guard refuses`).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the optional composerFooterLine to the same guard', () => {
|
||||
// Run over the rows under the composer each time guarded input reads the pane.
|
||||
for (const composerFooterLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
composerFooterLine,
|
||||
};
|
||||
}, 'guarded input tests it against pane rows a clis.json override can supply');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts every shipped composerFooterLine', () => {
|
||||
for (const entry of STOCK_CLIS) {
|
||||
const src = entry.capabilities.workDetect?.composerFooterLine;
|
||||
if (!src) continue;
|
||||
expect(compileVersionRegex(src), `${entry.id} declares a composerFooterLine the guard refuses`).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the optional composerPopupLine to the same guard, and accepts the shipped one', () => {
|
||||
// Run over the rows just above the composer each time guarded input reads the pane.
|
||||
for (const composerPopupLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
composerPopupLine,
|
||||
};
|
||||
}, 'guarded input tests it against pane rows a clis.json override can supply');
|
||||
}
|
||||
for (const entry of STOCK_CLIS) {
|
||||
const src = entry.capabilities.workDetect?.composerPopupLine;
|
||||
if (src)
|
||||
expect(compileVersionRegex(src), `${entry.id} declares a composerPopupLine the guard refuses`).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('takes only short runs of ASCII punctuation as composer trigger characters', () => {
|
||||
for (const field of ['composerCommandPrefixes', 'composerCompletionSigils']) {
|
||||
for (const value of ['', 'a', '/a', '/ ', '/\t', '\u00a7', '!!!!!!!!!']) {
|
||||
expectRejected(
|
||||
(e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
[field]: value,
|
||||
};
|
||||
},
|
||||
`${field} ${JSON.stringify(value)} would refuse letters, spaces or an unbounded set`
|
||||
);
|
||||
}
|
||||
const entry = baseEntry();
|
||||
(entry.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
[field]: '/!@$',
|
||||
};
|
||||
expect(CliEntrySchema.safeParse(entry).success, field).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the optional blockedLine to the same guard', () => {
|
||||
// Run over the foot of a capture at every settle and on a timer while a dialog is
|
||||
// up, and a clis.json override can set it.
|
||||
for (const blockedLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
|
||||
expectRejected(
|
||||
(e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
blockedLine,
|
||||
};
|
||||
},
|
||||
`blockedLine ${JSON.stringify(blockedLine.slice(0, 20))} must be refused at LOAD time`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('bounds the blockedLine window and refuses one with no pattern', () => {
|
||||
for (const blockedLines of [0, 25, 2.5]) {
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
blockedLine: 'Press enter to confirm$',
|
||||
blockedLines,
|
||||
};
|
||||
}, 'a config file must not be able to widen the search to the whole pane');
|
||||
}
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
blockedLines: 4,
|
||||
};
|
||||
}, 'a window with nothing to search is a typo whose failure is otherwise silent');
|
||||
});
|
||||
|
||||
it('accepts a blockedLine with its window, and every shipped one', () => {
|
||||
const entry = baseEntry();
|
||||
(entry.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
blockedLine: 'Press enter to confirm$',
|
||||
blockedLines: 24,
|
||||
};
|
||||
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
|
||||
let shipped = 0;
|
||||
for (const stock of STOCK_CLIS) {
|
||||
const src = stock.capabilities.workDetect?.blockedLine;
|
||||
if (!src) continue;
|
||||
shipped++;
|
||||
expect(compileVersionRegex(src), `${stock.id} declares a blockedLine the guard refuses`).not.toBeNull();
|
||||
expect(CliEntrySchema.safeParse(JSON.parse(JSON.stringify(stock))).success).toBe(true);
|
||||
}
|
||||
expect(shipped).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no shell text can reach the command line', () => {
|
||||
@@ -291,6 +470,57 @@ describe('cross-field integrity', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilities.launchDefaults', () => {
|
||||
/** Codex with its shipped launch defaults replaced by `launchDefaults` (or with them unchanged). */
|
||||
function codexWith(mutate: (entry: Record<string, unknown>) => void): boolean {
|
||||
const entry = baseEntry('codex');
|
||||
mutate(entry);
|
||||
return CliEntrySchema.safeParse(entry).success;
|
||||
}
|
||||
const setDefaults = (value: unknown) => (e: Record<string, unknown>) => {
|
||||
(e.capabilities as Record<string, unknown>).launchDefaults = value;
|
||||
};
|
||||
|
||||
it('ships on codex alone, keyed by launch param', () => {
|
||||
const declaring = STOCK_CLIS.filter((e) => e.capabilities.launchDefaults !== undefined).map((e) => e.id);
|
||||
expect(declaring).toEqual(['codex']);
|
||||
expect(codexWith(() => {})).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a param the entry never declared', () => {
|
||||
// Filled into the config object and then read by nothing: a silent no-op, like a
|
||||
// privilegedParams clamp naming the wrong param.
|
||||
expect(codexWith(setDefaults({ effort: 'codexReasoningEffort' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a settings key outside the closed list', () => {
|
||||
// An override must not be able to pour an arbitrary setting onto a command line.
|
||||
expect(codexWith(setDefaults({ model: 'claudeModel' }))).toBe(false);
|
||||
expect(codexWith(setDefaults({ model: 'codexmodel' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an empty map', () => {
|
||||
expect(codexWith(setDefaults({}))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses an entry with no legacyConfigField to fill', () => {
|
||||
// Without one the params are read off the request body itself, where `model` is
|
||||
// claude's per-session field, not this CLI's.
|
||||
expect(
|
||||
codexWith((e) => {
|
||||
delete (e.launch as Record<string, unknown>).legacyConfigField;
|
||||
})
|
||||
).toBe(false);
|
||||
// The same entry without launch defaults is fine: the refusal is about the pair.
|
||||
expect(
|
||||
codexWith((e) => {
|
||||
delete (e.launch as Record<string, unknown>).legacyConfigField;
|
||||
delete (e.capabilities as Record<string, unknown>).launchDefaults;
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the env allowlist cannot be widened by config', () => {
|
||||
it('requires a prefix to end with an underscore', () => {
|
||||
expectRejected((e) => {
|
||||
@@ -363,4 +593,70 @@ describe('capability shapes', () => {
|
||||
(e.capabilities as Record<string, unknown>).transcript = 'some-future-format';
|
||||
}, 'a transcript reader that does not exist would silently read nothing');
|
||||
});
|
||||
it('takes transcriptPageKeys as an optional boolean, absent meaning no paging', () => {
|
||||
for (const value of [true, false]) {
|
||||
const entry = baseEntry();
|
||||
(entry.capabilities as Record<string, unknown>).transcriptPageKeys = value;
|
||||
expect(CliEntrySchema.safeParse(entry).success, `transcriptPageKeys=${value}`).toBe(true);
|
||||
}
|
||||
const absent = baseEntry();
|
||||
delete (absent.capabilities as Record<string, unknown>).transcriptPageKeys;
|
||||
expect(CliEntrySchema.safeParse(absent).success).toBe(true);
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).transcriptPageKeys = 'yes';
|
||||
}, 'transcriptPageKeys is a boolean');
|
||||
});
|
||||
|
||||
it("takes keyboardHold as an optional 'inline', absent meaning the keyboard resizes", () => {
|
||||
const inline = baseEntry();
|
||||
(inline.capabilities as Record<string, unknown>).keyboardHold = 'inline';
|
||||
expect(CliEntrySchema.safeParse(inline).success).toBe(true);
|
||||
const absent = baseEntry();
|
||||
delete (absent.capabilities as Record<string, unknown>).keyboardHold;
|
||||
expect(CliEntrySchema.safeParse(absent).success).toBe(true);
|
||||
for (const value of [true, 'fullscreen', '']) {
|
||||
expectRejected(
|
||||
(e) => {
|
||||
(e.capabilities as Record<string, unknown>).keyboardHold = value;
|
||||
},
|
||||
`keyboardHold=${JSON.stringify(value)} names no renderer kind`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('declares keyboardHold for exactly the inline renderer measured losing lines on a height change', () => {
|
||||
// Claude Code 2.1.306's inline renderer was measured dropping transcript lines from
|
||||
// tmux on keyboard-driven height changes; every other stock entry resizes.
|
||||
const holding = STOCK_CLIS.filter((e) => e.capabilities.keyboardHold === 'inline').map((e) => e.id as string);
|
||||
expect(holding).toEqual(['claude']);
|
||||
});
|
||||
|
||||
it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => {
|
||||
// Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot
|
||||
// forward without a version gate. Unmeasured entries stay 'never'.
|
||||
const mode = (id: string) => STOCK_CLIS.find((e) => (e.id as string) === id)?.capabilities.wheelForward;
|
||||
expect(mode('copilot')).toEqual({ mode: 'always' });
|
||||
expect(mode('claude')).toEqual({ mode: 'version-gated', minVersion: '2.1.187' });
|
||||
const forwarding = STOCK_CLIS.filter((e) => e.capabilities.wheelForward.mode !== 'never').map(
|
||||
(e) => e.id as string
|
||||
);
|
||||
expect(forwarding.sort()).toEqual(['claude', 'codex', 'copilot']);
|
||||
expect(CliEntrySchema.safeParse(baseEntry('copilot')).success).toBe(true);
|
||||
});
|
||||
|
||||
it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => {
|
||||
// claude (repaint mode), codex, opencode and copilot (all on the alternate screen) were
|
||||
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
|
||||
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
|
||||
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('observed mouse-tracking wheel policy', () => {
|
||||
it('advertises and validates tracked Codex forwarding without a fictitious minimum version', () => {
|
||||
const codex = STOCK_CLIS.find((e) => e.id === 'codex')!;
|
||||
expect(codex.capabilities.wheelForward).toEqual({ mode: 'always' });
|
||||
expect(CliEntrySchema.safeParse(codex).success).toBe(true);
|
||||
expect(codex.capabilities.transcriptPageKeys).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getCli } from '../src/config/cli-registry/registry.js';
|
||||
import { buildSpawnCommandFromRegistry, type SpawnBridgeOptions } from '../src/session-cli-registry-bridge.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
|
||||
|
||||
/** A fixed session id, so `--session-id` is stable across runs. */
|
||||
const SID = '0f9c2b14-1111-2222-3333-444455556666';
|
||||
@@ -54,6 +55,20 @@ describe('claude', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a model as the quoted value of --model, even one that opens with a dash', () => {
|
||||
// POST /api/sessions refuses a leading '-' in `model`, but the registry's `model-claude`
|
||||
// pattern still admits one, so the builder must stay safe on its own: the value lands
|
||||
// quoted, and Claude's option parser takes the word after `--model` as its value whatever
|
||||
// it starts with, so it can never become a flag of its own.
|
||||
expect(claude({ model: 'claude-fable-5-1' })).toBe(
|
||||
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" --model "claude-fable-5-1"'
|
||||
);
|
||||
expect(claude({ model: '--dangerously-skip-permissions' })).toBe(
|
||||
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
|
||||
'--model "--dangerously-skip-permissions"'
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes through a shell fallback to a fresh session', () => {
|
||||
// The ` || ` is emitted by the ENGINE, not by config — no registry field can hold shell
|
||||
// text. This pin is what proves the fallback chain still renders as one command line.
|
||||
@@ -87,6 +102,30 @@ describe('claude', () => {
|
||||
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"'
|
||||
);
|
||||
});
|
||||
|
||||
it('folds the keep-warm idleCompaction opt-out LAST into the one --settings JSON, and only when asked', () => {
|
||||
const launch = {
|
||||
claudeCliVersion: '2.1.300',
|
||||
statusLineCommand: '/x/exporter.sh',
|
||||
advisorModel: 'opus',
|
||||
sessionName: 'w1 alpha',
|
||||
};
|
||||
// Keep-warm off: byte-identical to the launch from before keep-warm existed.
|
||||
expect(claude(launch)).toBe(
|
||||
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
|
||||
'--settings \'{"advisorModel":"opus","statusLine":{"type":"command","command":"/x/exporter.sh"}}\' --name "w1 alpha"'
|
||||
);
|
||||
expect(claude({ ...launch, effort: 'ultracode', idleCompactionOff: true })).toBe(
|
||||
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
|
||||
'--settings \'{"ultracode":true,"advisorModel":"opus","statusLine":{"type":"command","command":"/x/exporter.sh"},"idleCompaction":false}\' --name "w1 alpha"'
|
||||
);
|
||||
// The builder folds the key whenever asked, even with nothing else in the JSON (the
|
||||
// launch only asks alongside the exporter, but the builder does not rely on that).
|
||||
expect(claude({ idleCompactionOff: true })).toBe(
|
||||
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
|
||||
'--settings \'{"idleCompaction":false}\''
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('opencode', () => {
|
||||
@@ -131,6 +170,27 @@ describe('codex', () => {
|
||||
it('resumes with a POSITIONAL subcommand, not a flag', () => {
|
||||
expect(cx({ model: 'gpt-5', resumeSessionId: 'roll_42' })).toBe('codex --model gpt-5 resume roll_42');
|
||||
});
|
||||
|
||||
it('carries an Ollama-style `qwen3:32b` model id, and still drops a leading-flag shape', () => {
|
||||
// `model-plain-colon`: the custom-model feature forces the selected id onto this param,
|
||||
// and ids like `qwen3:32b` carry a colon. The leading-alphanumeric rule is what keeps a
|
||||
// flag from riding the flag, so `:x` and `-x` must still drop out.
|
||||
expect(cx({ model: 'qwen3:32b' })).toBe('codex --model qwen3:32b');
|
||||
expect(cx({ model: ':leading' })).toBe('codex');
|
||||
expect(cx({ model: '-yolo' })).toBe('codex');
|
||||
});
|
||||
|
||||
it('sends reasoning effort as one model_reasoning_effort config value, for every level', () => {
|
||||
for (const level of CODEX_REASONING_EFFORTS) {
|
||||
expect(cx({ reasoningEffort: level })).toBe(`codex --config model_reasoning_effort=${level}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps reasoning effort ahead of the resume subcommand', () => {
|
||||
expect(cx({ model: 'gpt-5', reasoningEffort: 'high', resumeSessionId: 'roll_42' })).toBe(
|
||||
'codex --model gpt-5 --config model_reasoning_effort=high resume roll_42'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini', () => {
|
||||
@@ -293,3 +353,72 @@ describe('unsafe values are DROPPED, never escaped into the command', () => {
|
||||
expect(cmd).not.toContain('..');
|
||||
});
|
||||
});
|
||||
|
||||
describe('copilot', () => {
|
||||
const cp = (copilotConfig?: SpawnBridgeOptions['copilotConfig']) =>
|
||||
render({ mode: 'copilot' as never, sessionId: SID, copilotConfig });
|
||||
|
||||
it("spawns bare by default, in Copilot's own Manual Approval mode", () => {
|
||||
expect(cp()).toBe('copilot');
|
||||
});
|
||||
|
||||
it('emits --yolo only when asked, with the model', () => {
|
||||
expect(cp({ allowAll: true, model: 'claude-sonnet-5.5' })).toBe('copilot --yolo --model claude-sonnet-5.5');
|
||||
expect(cp({ allowAll: false })).toBe('copilot');
|
||||
});
|
||||
|
||||
it("pins the tab's name as --name", () => {
|
||||
expect(render({ mode: 'copilot' as never, sessionId: SID, sessionName: 'w4-Codeman' } as never)).toBe(
|
||||
'copilot --name "w4-Codeman"'
|
||||
);
|
||||
});
|
||||
|
||||
it('drops --name next to --resume and --continue, which copilot refuses to combine', () => {
|
||||
const named = (copilotConfig: SpawnBridgeOptions['copilotConfig']) =>
|
||||
render({ mode: 'copilot' as never, sessionId: SID, sessionName: 'w4-Codeman', copilotConfig } as never);
|
||||
expect(named({ resumeSessionId: '0c02c30f' })).toBe('copilot --resume 0c02c30f');
|
||||
expect(named({ continueSession: true })).toBe('copilot --continue');
|
||||
expect(named({ allowAll: true, model: 'auto' })).toBe('copilot --yolo --name "w4-Codeman" --model auto');
|
||||
});
|
||||
|
||||
it('prefers an explicit resume id over --continue', () => {
|
||||
expect(cp({ resumeSessionId: '0c02c30f-3383-4139-8b52-b28abad2ad43' })).toBe(
|
||||
'copilot --resume 0c02c30f-3383-4139-8b52-b28abad2ad43'
|
||||
);
|
||||
expect(cp({ continueSession: true })).toBe('copilot --continue');
|
||||
expect(cp({ continueSession: true, resumeSessionId: '0c02c30f' })).toBe('copilot --resume 0c02c30f');
|
||||
});
|
||||
|
||||
it('drops unsafe values rather than escaping them', () => {
|
||||
expect(cp({ model: 'a`b' })).toBe('copilot');
|
||||
expect(cp({ resumeSessionId: 'my session; rm -rf /' })).toBe('copilot');
|
||||
});
|
||||
|
||||
// `--resume`'s value is optional, so `--resume --yolo` would make `--yolo` its own flag and get
|
||||
// around the allowAll clamp. A resume value must start with an alphanumeric.
|
||||
it('drops a resume value that would be read as a flag', () => {
|
||||
for (const resumeSessionId of ['--yolo', '-n', '--allow-all-tools', '.', '..', '.hidden']) {
|
||||
expect(cp({ resumeSessionId })).toBe('copilot');
|
||||
}
|
||||
expect(cp({ allowAll: false, resumeSessionId: '--yolo' })).toBe('copilot');
|
||||
// A plain word is still passed through (a session NAME also resumes).
|
||||
expect(cp({ resumeSessionId: 'my-feature' })).toBe('copilot --resume my-feature');
|
||||
});
|
||||
|
||||
// `--model --yolo` would make `--yolo` its own flag and get around the allowAll clamp, so a model id
|
||||
// starts with an alphanumeric too. A provider/id value (the `/`) is still accepted.
|
||||
it('drops a model value that would be read as a flag', () => {
|
||||
for (const model of ['--yolo', '-n', '--allow-all-tools', '.', '/etc/passwd']) {
|
||||
expect(cp({ model })).toBe('copilot');
|
||||
expect(cp({ allowAll: false, model })).toBe('copilot');
|
||||
}
|
||||
expect(cp({ model: 'claude-sonnet-5.5' })).toBe('copilot --model claude-sonnet-5.5');
|
||||
expect(cp({ model: 'openai/gpt-5' })).toBe('copilot --model openai/gpt-5');
|
||||
});
|
||||
|
||||
it('never puts a credential on the command line', () => {
|
||||
const cmd = cp({ allowAll: true, model: 'auto' }) ?? '';
|
||||
expect(cmd).not.toContain('token');
|
||||
expect(cmd).not.toContain('key');
|
||||
});
|
||||
});
|
||||
@@ -132,20 +132,22 @@ describe('linked-cases.json tolerance', () => {
|
||||
});
|
||||
|
||||
describe('skill command wiring', () => {
|
||||
it('registers install and uninstall, both accepting --case and --global', () => {
|
||||
it('registers install and uninstall, both accepting --case and --global, install also --mode', () => {
|
||||
const skill = program.commands.find((cmd) => cmd.name() === 'skill');
|
||||
expect(skill).toBeDefined();
|
||||
|
||||
const subcommands = skill!.commands.map((cmd) => cmd.name());
|
||||
expect(subcommands).toEqual(expect.arrayContaining(['install', 'uninstall']));
|
||||
|
||||
for (const name of ['install', 'uninstall']) {
|
||||
const flags = skill!.commands
|
||||
const flagsOf = (name: string) =>
|
||||
skill!.commands
|
||||
.find((cmd) => cmd.name() === name)!
|
||||
.options.map((opt) => opt.long)
|
||||
.sort();
|
||||
expect(flags).toEqual(['--case', '--global']);
|
||||
}
|
||||
// --mode picks the skill and directory that run mode reads (capabilities.agentSkill);
|
||||
// uninstall needs none, since --case removes every Codeman-managed copy.
|
||||
expect(flagsOf('install')).toEqual(['--case', '--global', '--mode']);
|
||||
expect(flagsOf('uninstall')).toEqual(['--case', '--global']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @fileoverview Issue #706 tunnel flag plumbing through the real command parser into daemon, foreground and service launches. Launch boundaries are mocked, so the CLI's valid nonzero port never binds a socket or creates a service unit.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const launches = vi.hoisted(() => ({
|
||||
startDaemon: vi.fn(),
|
||||
startWebServer: vi.fn(),
|
||||
installService: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../src/daemon-control.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../src/daemon-control.js')>()),
|
||||
startDaemon: launches.startDaemon,
|
||||
}));
|
||||
vi.mock('../src/service-installer.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../src/service-installer.js')>()),
|
||||
installService: launches.installService,
|
||||
}));
|
||||
vi.mock('../src/web/server.js', () => ({ startWebServer: launches.startWebServer }));
|
||||
|
||||
const SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const;
|
||||
let originalListeners: Map<NodeJS.Signals, ReturnType<typeof process.listeners>>;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn<typeof process, 'exit'>>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Commander retains parsed option values; use a fresh real program for each invocation.
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
originalListeners = new Map(SIGNALS.map((signal) => [signal, process.listeners(signal)]));
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`Unexpected process.exit(${code})`);
|
||||
});
|
||||
launches.startDaemon.mockResolvedValue({ ok: true, url: 'http://127.0.0.1:8080', pid: 123, logPath: 'web.log' });
|
||||
launches.stop.mockResolvedValue(undefined);
|
||||
launches.startWebServer.mockResolvedValue({ stop: launches.stop });
|
||||
launches.installService.mockResolvedValue({ ok: true, message: 'Installed', unitPath: 'codeman-web.service' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const signal of SIGNALS) {
|
||||
for (const listener of process.listeners(signal)) {
|
||||
if (!originalListeners.get(signal)!.includes(listener)) process.removeListener(signal, listener);
|
||||
}
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const expectedOptions = (allowUnauthenticatedTunnel: boolean) => ({
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
https: false,
|
||||
basePath: '',
|
||||
titleHostname: undefined,
|
||||
allowUnauthenticatedNetwork: false,
|
||||
allowUnauthenticatedTunnel,
|
||||
multiuser: false,
|
||||
});
|
||||
|
||||
async function parse(args: string[], enabled: boolean) {
|
||||
const { program } = await import('../src/cli.js');
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'codeman',
|
||||
...args,
|
||||
'--port',
|
||||
'8080',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--base-url',
|
||||
'/',
|
||||
...(enabled ? ['--allow-unauthenticated-tunnel'] : []),
|
||||
]);
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe('tunnel flag CLI plumbing', () => {
|
||||
it.each([true, false])('web -d passes allowUnauthenticatedTunnel=%s to startDaemon', async (enabled) => {
|
||||
await parse(['web', '-d'], enabled);
|
||||
expect(launches.startDaemon.mock.calls).toEqual([[expectedOptions(enabled)]]);
|
||||
expect(launches.startWebServer.mock.calls).toEqual([]);
|
||||
expect(launches.installService.mock.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([true, false])('foreground web passes allowUnauthenticatedTunnel=%s as argument eight', async (enabled) => {
|
||||
await parse(['web'], enabled);
|
||||
expect(launches.startWebServer.mock.calls).toEqual([
|
||||
[8080, false, false, '127.0.0.1', undefined, false, '', enabled],
|
||||
]);
|
||||
expect(launches.startDaemon.mock.calls).toEqual([]);
|
||||
expect(launches.installService.mock.calls).toEqual([]);
|
||||
expect(launches.stop.mock.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([true, false])('service install passes allowUnauthenticatedTunnel=%s to installService', async (enabled) => {
|
||||
await parse(['service', 'install'], enabled);
|
||||
expect(launches.installService.mock.calls).toEqual([[expectedOptions(enabled)]]);
|
||||
expect(launches.startDaemon.mock.calls).toEqual([]);
|
||||
expect(launches.startWebServer.mock.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@
|
||||
* - tunnel NOT running + good secret → not 401 (allowed)
|
||||
* - rate limiting: rapid unauthorized hook POSTs eventually 429
|
||||
*
|
||||
* Port: 3230 (tunnel-running), 3231 (tunnel-down), 3232 (rate-limit)
|
||||
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
@@ -28,9 +28,6 @@ import { TunnelManager } from '../src/tunnel-manager.js';
|
||||
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
|
||||
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
|
||||
|
||||
const TUNNEL_UP_PORT = 3230;
|
||||
const TUNNEL_DOWN_PORT = 3231;
|
||||
const RATE_LIMIT_PORT = 3232;
|
||||
const TEST_USER = 'admin';
|
||||
const TEST_PASS = 'cod54-test-password';
|
||||
|
||||
@@ -58,9 +55,9 @@ describe('COD-54 hook-event auth — tunnel running requires secret', () => {
|
||||
process.env.CODEMAN_USERNAME = TEST_USER;
|
||||
// Force the middleware's tunnel check to report "running".
|
||||
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
|
||||
server = new WebServer(TUNNEL_UP_PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TUNNEL_UP_PORT}`;
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -97,9 +94,9 @@ describe('COD-91 hook-event auth — tunnel down ALSO requires the secret', () =
|
||||
process.env.CODEMAN_USERNAME = TEST_USER;
|
||||
// Tunnel NOT running — loopback-only normal prod case.
|
||||
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(false);
|
||||
server = new WebServer(TUNNEL_DOWN_PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TUNNEL_DOWN_PORT}`;
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -130,9 +127,9 @@ describe('COD-54 hook-event auth — rate limiting', () => {
|
||||
process.env.CODEMAN_USERNAME = TEST_USER;
|
||||
// Tunnel running so unauthorized (no-secret) hook POSTs are rejected and counted.
|
||||
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
|
||||
server = new WebServer(RATE_LIMIT_PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${RATE_LIMIT_PORT}`;
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @fileoverview The one credential reader `codeman attach`, `codeman tui` and
|
||||
* `codeman agent` share: env first, the data dir's `.env` as the fallback.
|
||||
*/
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { basicAuthHeader, readCodemanCredentials, readCodemanEnvFile } from '../src/codeman-credentials.js';
|
||||
|
||||
describe('readCodemanCredentials', () => {
|
||||
let dir: string;
|
||||
const saved = { user: process.env.CODEMAN_USERNAME, pass: process.env.CODEMAN_PASSWORD };
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codeman-cred-'));
|
||||
delete process.env.CODEMAN_USERNAME;
|
||||
delete process.env.CODEMAN_PASSWORD;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
if (saved.user === undefined) delete process.env.CODEMAN_USERNAME;
|
||||
else process.env.CODEMAN_USERNAME = saved.user;
|
||||
if (saved.pass === undefined) delete process.env.CODEMAN_PASSWORD;
|
||||
else process.env.CODEMAN_PASSWORD = saved.pass;
|
||||
});
|
||||
|
||||
it('falls back to the .env file, quotes and an export prefix stripped, default user admin', () => {
|
||||
const env = join(dir, '.env');
|
||||
writeFileSync(env, '# a comment\nnot an assignment\nexport CODEMAN_PASSWORD="hunter2"\n');
|
||||
expect(readCodemanCredentials(env)).toEqual({ username: 'admin', password: 'hunter2' });
|
||||
});
|
||||
|
||||
it('prefers the environment over the file', () => {
|
||||
const env = join(dir, '.env');
|
||||
writeFileSync(env, 'CODEMAN_USERNAME=file\nCODEMAN_PASSWORD=file-pass\n');
|
||||
process.env.CODEMAN_USERNAME = 'envuser';
|
||||
process.env.CODEMAN_PASSWORD = 'env-pass';
|
||||
expect(readCodemanCredentials(env)).toEqual({ username: 'envuser', password: 'env-pass' });
|
||||
});
|
||||
|
||||
it('an absent file means no password, and no header to send', () => {
|
||||
const creds = readCodemanCredentials(join(dir, 'missing'));
|
||||
expect(creds).toEqual({ username: 'admin' });
|
||||
expect(basicAuthHeader(creds)).toBeUndefined();
|
||||
expect(readCodemanEnvFile(join(dir, 'missing'))).toEqual({});
|
||||
});
|
||||
|
||||
it('takes an explicit environment, field by field', () => {
|
||||
const env = join(dir, '.env');
|
||||
writeFileSync(env, 'CODEMAN_USERNAME=joe\n');
|
||||
expect(readCodemanCredentials(env, { CODEMAN_PASSWORD: 'pw' })).toEqual({ username: 'joe', password: 'pw' });
|
||||
});
|
||||
|
||||
it('builds a Basic header from a password', () => {
|
||||
expect(basicAuthHeader({ username: 'joe', password: 'pw' })).toBe(
|
||||
`Basic ${Buffer.from('joe:pw').toString('base64')}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* A Codex session waiting on one of its own dialogs is BLOCKED, read off the screen.
|
||||
*
|
||||
* Codex is the one CLI Codeman launches with its approvals on by default, and it has no
|
||||
* hook to say it stopped for one (`hooks: 'none'`), so a session parked on "Would you like
|
||||
* to run the following command?" used to read as plainly idle, and one parked on a
|
||||
* question (whose key hints carry `esc to interrupt`, codex's working line) as WORKING.
|
||||
* The registry's `capabilities.workDetect.blockedLine` now names the dialog's chrome, the
|
||||
* session reads it at every settle and on a timer while it is up, and the server raises
|
||||
* exactly what a Claude `permission_prompt` hook raises, alert-only.
|
||||
*
|
||||
* The fixtures in `test/fixtures/codex-dialogs/` are live `tmux capture-pane -p` frames
|
||||
* (no -J, what `capturePaneText()` reads) of codex-cli 0.162.0 on 2026-10-14, run with
|
||||
* `-a on-request -s read-only` at 200, 80, 60, 44 and 36 columns; only trailing
|
||||
* whitespace was trimmed and the scratch path replaced by `/tmp/codex-scratch/repo`.
|
||||
*/
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Session } from '../src/session.js';
|
||||
import { getCli } from '../src/config/cli-registry/index.js';
|
||||
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
|
||||
import {
|
||||
isBlockedScreen,
|
||||
BLOCKED_TAIL_LINES,
|
||||
BLOCKED_MAX_TAIL_LINES,
|
||||
BLOCKED_RECHECK_MS,
|
||||
BLOCKED_CLEAR_READS,
|
||||
BLOCKED_QUIET_RECHECK_MS,
|
||||
IDLE_SILENCE_MS,
|
||||
PANE_PROBE_MIN_INTERVAL_MS,
|
||||
} from '../src/session-activity.js';
|
||||
import type { SessionMode } from '../src/types.js';
|
||||
|
||||
const DETECT = getCli('codex')!.capabilities.workDetect!;
|
||||
const CODEX_BLOCKED = compileVersionRegex(DETECT.blockedLine!)!;
|
||||
const CODEX_WINDOW = DETECT.blockedLines!;
|
||||
const CODEX_WORKING = compileVersionRegex(DETECT.workingLine)!;
|
||||
|
||||
const FIXTURES = new URL('./fixtures/codex-dialogs/', import.meta.url);
|
||||
const fixture = (name: string): string => readFileSync(new URL(name, FIXTURES), 'utf8');
|
||||
const blocked = (screen: string): boolean => isBlockedScreen(screen, CODEX_BLOCKED, CODEX_WINDOW);
|
||||
|
||||
/** Every dialog codex was measured blocking on. */
|
||||
const DIALOGS = [
|
||||
'exec-approval.txt',
|
||||
'exec-approval-w36.txt', // the key hint wraps: `Press enter to confirm or esc to` / `cancel`
|
||||
'exec-approval-w44.txt',
|
||||
'exec-approval-long-command.txt', // a 14-row heredoc preview between the header and the options
|
||||
'exec-approval-60x14.txt', // a 14-row pane: codex folds the preview to `[… 21 lines]`
|
||||
'edit-approval.txt',
|
||||
'network-escalation-approval.txt',
|
||||
'question.txt',
|
||||
'question-option2-selected.txt',
|
||||
'question-1of2.txt', // `| ←/→ to navigate questions |` in the hint
|
||||
'question-2of2-submit-all.txt', // `enter to submit all` on the last question
|
||||
'question-w36.txt', // the hint one segment per row, the `›` row 16 rows up
|
||||
'question-w60.txt',
|
||||
'question-notes.txt', // the notes field open: `tab or esc to clear notes | enter to submit answer`
|
||||
'question-notes-typed.txt',
|
||||
'trust-dialog.txt', // drawn at the TOP of an otherwise blank screen at startup
|
||||
'trust-dialog-w40.txt',
|
||||
// The same prompt, reached through the update prompt (`2. Skip`) on 0.162.0 with 0.163.0
|
||||
// out: `2. Back to Agent Command Center` over `enter continue · esc back`. Captured
|
||||
// 2026-10-16 from Codeman's own launch flags in a fresh git repo, at 120 columns.
|
||||
'trust-dialog-back.txt',
|
||||
];
|
||||
|
||||
/** Screens with no dialog codex blocks on, several of them quoting the dialogs' words. */
|
||||
const NOT_DIALOGS = [
|
||||
'idle-start.txt',
|
||||
'composer-typed.txt',
|
||||
'working.txt',
|
||||
'after-approve.txt',
|
||||
'after-deny.txt',
|
||||
'after-question.txt', // the transcript keeps `• Questions 1/1 answered` and the question
|
||||
'slash-popup.txt', // `› /plan switch to Plan mode`: a `›` row with nothing after it
|
||||
'transcript-quotes-dialog.txt', // an answer that printed the dialog's lines verbatim
|
||||
'composer-typed-dialog-text.txt', // the user TYPED them into the composer
|
||||
'composer-typed-dialog-text-w60.txt', // ... and at 60 columns, where the text wraps
|
||||
'model-popup.txt', // menus the user opened at the keyboard: `enter select · esc back`
|
||||
'permissions-popup.txt', // (with the quoted dialog from the earlier answer still above it)
|
||||
'review-popup.txt',
|
||||
'implement-plan.txt', // deliberately not blocked: a plan turn that ended, same footer as /model
|
||||
];
|
||||
|
||||
describe('codex blockedLine on live captures', () => {
|
||||
it('is a pattern the registry guard accepts, with a window inside the cap', () => {
|
||||
expect(DETECT.blockedLine!.length).toBeLessThanOrEqual(200);
|
||||
expect(CODEX_BLOCKED).not.toBeNull();
|
||||
expect(CODEX_WINDOW).toBeGreaterThan(BLOCKED_TAIL_LINES);
|
||||
expect(CODEX_WINDOW).toBeLessThanOrEqual(BLOCKED_MAX_TAIL_LINES);
|
||||
});
|
||||
|
||||
it.each(DIALOGS)('reads %s as blocked', (name) => {
|
||||
expect(blocked(fixture(name))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(NOT_DIALOGS)('does not read %s as blocked', (name) => {
|
||||
expect(blocked(fixture(name))).toBe(false);
|
||||
});
|
||||
|
||||
it('needs the whole window for the narrowest question, and no more than it declares', () => {
|
||||
// At 36 columns the selected option sits 16 non-blank rows above the end; a window
|
||||
// that stops short of it reads nothing, which is the pre-field behaviour, never a
|
||||
// false alert.
|
||||
const narrow = fixture('question-w36.txt');
|
||||
expect(isBlockedScreen(narrow, CODEX_BLOCKED, 16)).toBe(true);
|
||||
expect(isBlockedScreen(narrow, CODEX_BLOCKED, 15)).toBe(false);
|
||||
});
|
||||
|
||||
it('sees the question dialogs that the working line used to claim', () => {
|
||||
// Why a blocked screen has to WIN over the working line in the probe: the question's
|
||||
// own key hints end in `esc to interrupt`.
|
||||
for (const name of ['question.txt', 'question-w36.txt', 'question-1of2.txt']) {
|
||||
expect(CODEX_WORKING.test(fixture(name)), name).toBe(true);
|
||||
expect(blocked(fixture(name)), name).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('codex blockedLine against a screen the agent writes', () => {
|
||||
// The foot of codex's screen while no dialog is up: composer, footer (whose model and
|
||||
// path vary), hint row.
|
||||
const composer = (typed = 'Ask Codex to do anything', footer = ' GPT-6-Luna default · ~/cases/x'): string[] => [
|
||||
`› ${typed}`,
|
||||
'',
|
||||
footer,
|
||||
' ? for shortcuts',
|
||||
];
|
||||
const forgedDialog = [
|
||||
'• Here is what the dialog looks like:',
|
||||
'› 1. Yes, proceed (y)',
|
||||
' 2. No, and tell Codex what to do differently (esc)',
|
||||
'',
|
||||
' Press enter to confirm or esc to cancel',
|
||||
];
|
||||
|
||||
it('refuses a forged dialog in the transcript, whatever follows it', () => {
|
||||
expect(blocked([...forgedDialog, '', ...composer()].join('\n'))).toBe(false);
|
||||
// While a prompt is typed the hint row goes away, so the forged hint sits only two
|
||||
// rows above the end: the composer row (column 0, `›`) still breaks the run.
|
||||
expect(blocked([...forgedDialog, ...composer('next thing').slice(0, 3)].join('\n'))).toBe(false);
|
||||
// Even with a lowercase model and a short path, the footer row would have to end in
|
||||
// one of the hint's own closing words.
|
||||
expect(blocked([...forgedDialog, ...composer('x', ' gpt-5.6 default · ~/x').slice(0, 3)].join('\n'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses the working view with the dialog quoted right above the status line', () => {
|
||||
const screen = [...forgedDialog, '', '• Working (3s • esc to interrupt)', '', ...composer()].join('\n');
|
||||
expect(blocked(screen)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a hint row without the option row above it, and an option row without the hint', () => {
|
||||
expect(
|
||||
blocked(
|
||||
[' Would you like to run the following command?', ' Press enter to confirm or esc to cancel'].join('\n')
|
||||
)
|
||||
).toBe(false);
|
||||
expect(blocked(['› 1. Yes, proceed (y)', ' 2. No (esc)'].join('\n'))).toBe(false);
|
||||
});
|
||||
|
||||
it('stays linear on a hostile window', () => {
|
||||
// Many option rows and long rows, ending in something that is almost the hint: the
|
||||
// pattern must give up quickly, since it runs on a timer while a dialog is up.
|
||||
const row = `› 1. ${'x'.repeat(300)}`;
|
||||
const screen = [
|
||||
...Array(BLOCKED_MAX_TAIL_LINES - 1).fill(row),
|
||||
` Press enter to confirm ${'or '.repeat(60)}`,
|
||||
].join('\n');
|
||||
const started = performance.now();
|
||||
for (let i = 0; i < 50; i++) blocked(screen);
|
||||
expect(performance.now() - started).toBeLessThan(250);
|
||||
expect(blocked(screen)).toBe(false);
|
||||
});
|
||||
|
||||
it('caps the window at BLOCKED_MAX_TAIL_LINES whatever the caller asks', () => {
|
||||
const dialog = fixture('exec-approval.txt');
|
||||
const padded = `${dialog}\n${Array(BLOCKED_MAX_TAIL_LINES).fill(' filler').join('\n')}`;
|
||||
expect(isBlockedScreen(padded, CODEX_BLOCKED, 10_000)).toBe(false);
|
||||
});
|
||||
|
||||
it('answers false for an empty or unreadable capture', () => {
|
||||
expect(isBlockedScreen('', CODEX_BLOCKED, CODEX_WINDOW)).toBe(false);
|
||||
expect(isBlockedScreen(null, CODEX_BLOCKED, CODEX_WINDOW)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── The session's blocked state ────────────────────────────────────────────
|
||||
|
||||
/** Codex's own composer repaint: carries the glyph that arms the idle confirmation. */
|
||||
const CODEX_REPAINT = '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m';
|
||||
|
||||
type SessionInternals = {
|
||||
_handleTerminalOutput(data: string): void;
|
||||
_detectInteractiveActivity(data: string): void;
|
||||
};
|
||||
|
||||
function feed(session: Session, data: string): void {
|
||||
const internals = session as unknown as SessionInternals;
|
||||
internals._handleTerminalOutput(data);
|
||||
internals._detectInteractiveActivity(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the pane shows, and how often the session has read it: every read is a synchronous
|
||||
* `tmux capture-pane` on the live server.
|
||||
*/
|
||||
type FakeScreen = { text: string | null; captures?: number };
|
||||
|
||||
/** A session whose mux answers captures from `screen.text` (null = unreadable). */
|
||||
function withFakePane(screen: FakeScreen, mode: SessionMode = 'codex'): Session {
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
capturePaneText: () => {
|
||||
screen.captures = (screen.captures ?? 0) + 1;
|
||||
return screen.text;
|
||||
},
|
||||
} as unknown as NonNullable<ConstructorParameters<typeof Session>[0]>['mux'];
|
||||
return new Session({
|
||||
workingDir: '/tmp',
|
||||
mode,
|
||||
mux,
|
||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
|
||||
} as ConstructorParameters<typeof Session>[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pane changes the only way a real one can: tmux redraws it to the attached client,
|
||||
* which reaches the session as output. A cursor move and an erase, with no composer glyph
|
||||
* in it, so it marks activity without arming the idle confirmation.
|
||||
*/
|
||||
function redraw(session: Session, screen: FakeScreen, text: string | null): void {
|
||||
screen.text = text;
|
||||
feed(session, '\x1b[2;1H\x1b[K');
|
||||
}
|
||||
|
||||
/** Output, then quiet: the moment the idle confirmation reads the screen. */
|
||||
function settle(session: Session): void {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
feed(session, CODEX_REPAINT);
|
||||
vi.advanceTimersByTime(1000);
|
||||
}
|
||||
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
|
||||
}
|
||||
|
||||
function recordChanges(session: Session): Array<{ blocked: boolean; frame: string | null }> {
|
||||
const changes: Array<{ blocked: boolean; frame: string | null }> = [];
|
||||
session.on('blockedChanged', (b: boolean, frame: string | null) => changes.push({ blocked: b, frame }));
|
||||
return changes;
|
||||
}
|
||||
|
||||
describe('Session.screenBlocked', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('raises once when the pane settles on an approval, and reads the session idle', () => {
|
||||
vi.useFakeTimers();
|
||||
const screen = { text: fixture('exec-approval.txt') };
|
||||
const session = withFakePane(screen);
|
||||
const changes = recordChanges(session);
|
||||
|
||||
settle(session);
|
||||
expect(session.screenBlocked).toBe(true);
|
||||
expect(session.status).toBe('idle');
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].blocked).toBe(true);
|
||||
// The frame it was read from rides along, as the approval card's context.
|
||||
expect(changes[0].frame).toContain('Would you like to run the following command?');
|
||||
|
||||
// The dialog staying up is one dialog: no second raise from the timer or a repaint.
|
||||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 5);
|
||||
settle(session);
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('calls a question dialog blocked and idle, not working', () => {
|
||||
// Its key hints carry `esc to interrupt`, which is codex's working line: before the
|
||||
// field the probe called this pane busy for as long as the question stayed up.
|
||||
vi.useFakeTimers();
|
||||
const session = withFakePane({ text: fixture('question.txt') });
|
||||
settle(session);
|
||||
expect(session.screenBlocked).toBe(true);
|
||||
expect(session.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('clears after the dialog has been gone for BLOCKED_CLEAR_READS reads, without waiting for the turn', () => {
|
||||
vi.useFakeTimers();
|
||||
const screen = { text: fixture('exec-approval.txt') as string | null };
|
||||
const session = withFakePane(screen);
|
||||
const changes = recordChanges(session);
|
||||
settle(session);
|
||||
expect(session.screenBlocked).toBe(true);
|
||||
|
||||
// Answered: codex resumes the turn, and nothing settles while it runs. The timer is
|
||||
// what notices, and one read alone is not enough.
|
||||
redraw(session, screen, fixture('working.txt'));
|
||||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
|
||||
expect(session.screenBlocked).toBe(true);
|
||||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * (BLOCKED_CLEAR_READS - 1));
|
||||
expect(session.screenBlocked).toBe(false);
|
||||
expect(changes.map((c) => c.blocked)).toEqual([true, false]);
|
||||
|
||||
// Nothing keeps reading a screen with no dialog on it.
|
||||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 10);
|
||||
expect(changes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not flap on one read that misses a dialog still on screen', () => {
|
||||
// A capture inside a full-frame repaint can come back without the footer. Clearing on
|
||||
// it would raise again a moment later: a second alert, push and webhook for one dialog.
|
||||
vi.useFakeTimers();
|
||||
const dialog = fixture('exec-approval.txt');
|
||||
const screen = { text: dialog as string | null };
|
||||
const session = withFakePane(screen);
|
||||
const changes = recordChanges(session);
|
||||
settle(session);
|
||||
|
||||
redraw(session, screen, fixture('working.txt'));
|
||||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
|
||||
redraw(session, screen, dialog);
|
||||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 3);
|
||||
expect(session.screenBlocked).toBe(true);
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps its state through an unreadable capture, in either direction', () => {
|
||||
vi.useFakeTimers();
|
||||
const screen = { text: fixture('exec-approval.txt') as string | null };
|
||||
const session = withFakePane(screen);
|
||||
settle(session);
|
||||
// The pane moved, so the session looks, and tmux answers nothing it can read.
|
||||
redraw(session, screen, null);
|
||||
const before = screen.captures!;
|
||||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 5);
|
||||
expect(screen.captures! - before).toBeGreaterThanOrEqual(5);
|
||||
expect(session.screenBlocked).toBe(true);
|
||||
|
||||
// ...and it is still watching: once the screen reads again without the dialog, it clears.
|
||||
redraw(session, screen, fixture('after-approve.txt'));
|
||||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
|
||||
expect(session.screenBlocked).toBe(false);
|
||||
});
|
||||
|
||||
it('does not re-read a quiet pane every interval while a dialog sits unanswered', () => {
|
||||
// Each read is a synchronous `tmux capture-pane` (about 8 ms of blocked event loop,
|
||||
// measured 2026-10-14), and a dialog can sit unanswered overnight. It leaves the
|
||||
// screen only through a repaint, which reaches the session as output, so a pane that
|
||||
// drew nothing since the last reading that saw the dialog is still showing it.
|
||||
vi.useFakeTimers();
|
||||
const screen: FakeScreen = { text: fixture('exec-approval.txt') };
|
||||
const session = withFakePane(screen);
|
||||
settle(session);
|
||||
expect(session.screenBlocked).toBe(true);
|
||||
|
||||
const quietMs = 10 * 60_000;
|
||||
const before = screen.captures!;
|
||||
vi.advanceTimersByTime(quietMs);
|
||||
// The safety-net read only, never one per BLOCKED_RECHECK_MS (that would be 300 here).
|
||||
expect(screen.captures! - before).toBeLessThanOrEqual(quietMs / BLOCKED_QUIET_RECHECK_MS);
|
||||
expect(session.screenBlocked).toBe(true);
|
||||
|
||||
// The answer repaints the pane, and the clear comes as fast as before.
|
||||
redraw(session, screen, fixture('after-approve.txt'));
|
||||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
|
||||
expect(session.screenBlocked).toBe(false);
|
||||
});
|
||||
|
||||
it('takes the confirming read even when no output follows the reading that missed the dialog', () => {
|
||||
// tmux updates the pane before it sends this client the repaint, so a capture can see
|
||||
// the dialog gone while the session has been sent nothing yet. Skipping a quiet pane
|
||||
// is right only while the last reading SAW the dialog: once one reading missed it,
|
||||
// waiting for output could leave the alert up for the whole safety-net interval.
|
||||
vi.useFakeTimers();
|
||||
const screen: FakeScreen = { text: fixture('exec-approval.txt') };
|
||||
const session = withFakePane(screen);
|
||||
settle(session);
|
||||
vi.advanceTimersByTime(PANE_PROBE_MIN_INTERVAL_MS);
|
||||
screen.text = fixture('after-approve.txt'); // and not one byte of output, from here on
|
||||
(session as unknown as { _probePaneWorking(): boolean | null })._probePaneWorking();
|
||||
expect(session.screenBlocked).toBe(true);
|
||||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
|
||||
expect(session.screenBlocked).toBe(false);
|
||||
});
|
||||
|
||||
it('raises again for the next dialog after a clear', () => {
|
||||
vi.useFakeTimers();
|
||||
const screen = { text: fixture('exec-approval.txt') as string | null };
|
||||
const session = withFakePane(screen);
|
||||
const changes = recordChanges(session);
|
||||
settle(session);
|
||||
redraw(session, screen, fixture('after-approve.txt'));
|
||||
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
|
||||
screen.text = fixture('edit-approval.txt');
|
||||
settle(session);
|
||||
expect(changes.map((c) => c.blocked)).toEqual([true, false, true]);
|
||||
});
|
||||
|
||||
it('never reads a dialog off the screen of a CLI that declares no blockedLine', () => {
|
||||
// Claude's dialogs arrive as hooks; gemini declares nothing. Same screen, no state.
|
||||
vi.useFakeTimers();
|
||||
for (const mode of ['claude', 'gemini'] as const) {
|
||||
const session = withFakePane({ text: fixture('exec-approval.txt') }, mode);
|
||||
const changes = recordChanges(session);
|
||||
settle(session);
|
||||
expect(session.screenBlocked, mode).toBe(false);
|
||||
expect(changes, mode).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('drops the state when a new pane starts, announcing it', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = withFakePane({ text: fixture('trust-dialog.txt') });
|
||||
const changes = recordChanges(session);
|
||||
settle(session);
|
||||
expect(session.screenBlocked).toBe(true);
|
||||
(session as unknown as { _resetBuffers(): void })._resetBuffers();
|
||||
expect(session.screenBlocked).toBe(false);
|
||||
expect(changes.map((c) => c.blocked)).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Codex's selection menus (the "Implement this plan?" prompt, the /model, /permissions and /review pickers,
|
||||
* approvals, questions, the trust prompt) take PageUp and PageDown as "move the selection". Codeman turns
|
||||
* the wheel into those keys for a Codex pane that does not track the mouse (codex-cli 0.162 and 0.164 never
|
||||
* do), so a wheel notch walked the cursor through the options: one notch down landed on "No, stay in Plan
|
||||
* mode", one up on "Yes, implement this plan". The pane must consume the wheel there and send nothing.
|
||||
*
|
||||
* The screens are the live `tmux capture-pane` frames in test/fixtures/codex-dialogs/.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
|
||||
const PAGE_KEY_MODES = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id);
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const fixture = (name: string) => readFileSync(resolve(import.meta.dirname, 'fixtures/codex-dialogs', name), 'utf8');
|
||||
|
||||
/** A terminal whose screen shows `text`, padded to `rows` rows (what xterm holds for a hollow pane). */
|
||||
function terminalShowing(text: string, rows = 40) {
|
||||
const lines = text.replace(/\n+$/, '').split('\n');
|
||||
// A capture taller than the pane keeps its bottom rows (the menu), a shorter one sits at the top.
|
||||
const shown = lines.length > rows ? lines.slice(-rows) : lines;
|
||||
const screen = Array.from({ length: rows }, (_, i) => shown[i] ?? '');
|
||||
return {
|
||||
cols: 100,
|
||||
rows,
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
buffer: {
|
||||
active: {
|
||||
type: 'normal',
|
||||
baseY: 0,
|
||||
viewportY: 0,
|
||||
length: rows,
|
||||
getLine: (y: number) => (screen[y] === undefined ? undefined : { translateToString: () => screen[y] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function load() {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const windowRef: Record<string, any> = { __codemanTranscriptPageKeys: PAGE_KEY_MODES };
|
||||
const context = vm.createContext({
|
||||
window: windowRef,
|
||||
CodemanApp,
|
||||
console: { warn: vi.fn(), log: vi.fn() },
|
||||
_crashDiag: { log: vi.fn() },
|
||||
performance: { now: () => 1_000 },
|
||||
requestAnimationFrame: () => 1,
|
||||
setTimeout: () => 1,
|
||||
clearTimeout: () => undefined,
|
||||
Blob: function Blob() {},
|
||||
URL: { createObjectURL: () => 'blob:x', revokeObjectURL: () => {} },
|
||||
Worker: function Worker(this: any) {
|
||||
this.postMessage = () => {};
|
||||
},
|
||||
MobileDetection: { isTouchDevice: () => true },
|
||||
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
|
||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||
});
|
||||
vm.runInContext(readFileSync(resolve(publicDir, 'terminal-ui.js'), 'utf8'), context, { filename: 'terminal-ui.js' });
|
||||
return { app: new (CodemanApp as any)(), windowRef };
|
||||
}
|
||||
|
||||
/** Every dialog or menu a Codex pane can be sitting in, and the screens where PageUp must still page. */
|
||||
const MENUS = [
|
||||
'implement-plan.txt',
|
||||
'model-popup.txt',
|
||||
'permissions-popup.txt',
|
||||
'review-popup.txt',
|
||||
'exec-approval.txt',
|
||||
'exec-approval-w36.txt',
|
||||
'edit-approval.txt',
|
||||
'question.txt',
|
||||
'question-1of2.txt',
|
||||
'question-notes.txt',
|
||||
'trust-dialog.txt',
|
||||
'trust-dialog-back.txt',
|
||||
'trust-dialog-w40.txt',
|
||||
'exec-approval-w44.txt',
|
||||
'exec-approval-60x14.txt',
|
||||
'exec-approval-long-command.txt',
|
||||
'network-escalation-approval.txt',
|
||||
'question-option2-selected.txt',
|
||||
'question-2of2-submit-all.txt',
|
||||
'question-notes-typed.txt',
|
||||
'question-w36.txt', // the key hint one segment per row
|
||||
'question-w60.txt',
|
||||
];
|
||||
const NOT_MENUS = [
|
||||
'idle-start.txt',
|
||||
'composer-typed.txt',
|
||||
'working.txt',
|
||||
'after-approve.txt',
|
||||
'after-deny.txt',
|
||||
'after-question.txt',
|
||||
'slash-popup.txt',
|
||||
'transcript-quotes-dialog.txt',
|
||||
'composer-typed-dialog-text.txt',
|
||||
'composer-typed-dialog-text-w60.txt',
|
||||
];
|
||||
|
||||
describe('isCodexSelectionMenu on live captures', () => {
|
||||
const { windowRef } = load();
|
||||
const isMenu = (name: string) => windowRef.CodemanTerminalInput.isCodexSelectionMenu(terminalShowing(fixture(name)));
|
||||
|
||||
it.each(MENUS)('reads %s as a menu', (name) => {
|
||||
expect(isMenu(name), name).toBe(true);
|
||||
});
|
||||
|
||||
it.each(NOT_MENUS)('does not read %s as a menu', (name) => {
|
||||
expect(isMenu(name), name).toBe(false);
|
||||
});
|
||||
|
||||
it('still reads a narrow question whose option descriptions wrap over many rows', () => {
|
||||
// The selected option sat 16 rows up from the bottom at 36 columns; sentence-length descriptions on
|
||||
// a phone-width pane push it further, and an earlier row cap lost it (the menu was missed and the
|
||||
// wheel moved the selection again).
|
||||
const rows = fixture('question-w36.txt').split('\n');
|
||||
const at = rows.findIndex((row) => /^\s{0,2}› 1\./.test(row));
|
||||
expect(at).toBeGreaterThan(-1);
|
||||
rows.splice(at + 1, 0, ...Array.from({ length: 12 }, (_, i) => ` wrapped description row ${i + 1}`));
|
||||
const terminal = terminalShowing(rows.join('\n'), 60);
|
||||
expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu(terminal)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a terminal it cannot read', () => {
|
||||
expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu(undefined)).toBe(false);
|
||||
expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu({ rows: 10, buffer: { active: { baseY: 0 } } })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the wheel over a Codex pane', () => {
|
||||
function codexPane(screen: string) {
|
||||
const { app } = load();
|
||||
const sent: string[] = [];
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: false }]]);
|
||||
app._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
|
||||
app.terminal = terminalShowing(screen);
|
||||
return { app, sent };
|
||||
}
|
||||
|
||||
it('sends no page key while the "Implement this plan?" menu is open, and consumes the wheel', () => {
|
||||
const { app, sent } = codexPane(fixture('implement-plan.txt'));
|
||||
for (const lines of [-30, 30, -30, 30]) expect(app._maybePageCliTranscript({ shiftKey: false }, lines)).toBe(true);
|
||||
app._flushWheelSgrQueue();
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('still pages the transcript from the composer', () => {
|
||||
const { app, sent } = codexPane(fixture('composer-typed.txt'));
|
||||
expect(app._maybePageCliTranscript({ shiftKey: false }, -30)).toBe(true);
|
||||
app._flushWheelSgrQueue();
|
||||
expect(sent).toEqual(['\x1b[5~']);
|
||||
});
|
||||
|
||||
it('only looks at Codex: another page-key CLI showing the same text is paged as before', () => {
|
||||
const { app, sent } = codexPane(fixture('implement-plan.txt'));
|
||||
app.sessions.set('sess-1', { mode: 'opencode' });
|
||||
app._maybePageCliTranscript({ shiftKey: false }, -30);
|
||||
app._flushWheelSgrQueue();
|
||||
expect(sent).toEqual(['\x1b[5~']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a tile asks the same question', () => {
|
||||
it('consumes the wheel for an open Codex menu in its own terminal and session', () => {
|
||||
const tile = readFileSync(resolve(publicDir, 'terminal-tile.js'), 'utf8');
|
||||
expect(tile).toContain('app._codexMenuOpen?.({ terminal: this.terminal, sessionId: this.sessionId })');
|
||||
const { app } = load();
|
||||
app.sessions = new Map([
|
||||
['active', { mode: 'claude' }],
|
||||
['tile', { mode: 'codex' }],
|
||||
]);
|
||||
app.terminal = terminalShowing(fixture('composer-typed.txt'));
|
||||
const tileTerminal = terminalShowing(fixture('implement-plan.txt'));
|
||||
// the tile's own terminal and session decide, not the active pane's
|
||||
expect(app._codexMenuOpen({ terminal: tileTerminal, sessionId: 'tile' })).toBe(true);
|
||||
expect(app._codexMenuOpen({ terminal: tileTerminal, sessionId: 'active' })).toBe(false);
|
||||
expect(app._codexMenuOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @fileoverview `codexConfig.reasoningEffort` on the create routes.
|
||||
*
|
||||
* The level becomes part of a `--config model_reasoning_effort=<level>` launch token, so the
|
||||
* schema admits only the words codex knows; anything else fails the request rather than
|
||||
* reaching the argv.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
|
||||
|
||||
describe('codexConfig.reasoningEffort', () => {
|
||||
it('accepts every level codex knows on both create routes', () => {
|
||||
for (const level of CODEX_REASONING_EFFORTS) {
|
||||
const created = CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
codexConfig: { reasoningEffort: level },
|
||||
});
|
||||
expect(created.codexConfig?.reasoningEffort).toBe(level);
|
||||
const quick = QuickStartSchema.parse({
|
||||
caseName: 'work',
|
||||
mode: 'codex',
|
||||
codexConfig: { reasoningEffort: level },
|
||||
});
|
||||
expect(quick.codexConfig?.reasoningEffort).toBe(level);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a level codex does not know, and anything shaped like shell, on both create routes', () => {
|
||||
for (const reasoningEffort of ['bogus', 'HIGH', 'high; rm -rf /', '']) {
|
||||
expect(() =>
|
||||
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'codex', codexConfig: { reasoningEffort } })
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
QuickStartSchema.parse({ caseName: 'work', mode: 'codex', codexConfig: { reasoningEffort } })
|
||||
).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @fileoverview The Codex rollout shape helpers shared by the Past Sessions
|
||||
* scanner (codex-transcript.ts) and the offline history reader
|
||||
* (local-history.ts): store location, message shapes and the injected-context
|
||||
* rule. Both readers' own tests cover their use of these; this pins the shared
|
||||
* contract itself.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
codexContentText,
|
||||
codexHomeDir,
|
||||
codexMessageFromLine,
|
||||
codexSessionsRoot,
|
||||
isCodexInjectedContext,
|
||||
} from '../src/codex-rollout-format.js';
|
||||
|
||||
const savedHome = process.env.CODEX_HOME;
|
||||
afterEach(() => {
|
||||
if (savedHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = savedHome;
|
||||
});
|
||||
|
||||
describe('codex rollout format', () => {
|
||||
it('resolves the store from CODEX_HOME, else ~/.codex', () => {
|
||||
process.env.CODEX_HOME = '/custom/codex';
|
||||
expect(codexHomeDir()).toBe('/custom/codex');
|
||||
expect(codexSessionsRoot()).toBe(join('/custom/codex', 'sessions'));
|
||||
process.env.CODEX_HOME = '';
|
||||
expect(codexSessionsRoot()).toBe(join(process.env.HOME!, '.codex', 'sessions'));
|
||||
});
|
||||
|
||||
it('reads all three message shapes with their role, shape and phase', () => {
|
||||
expect(codexMessageFromLine({ type: 'event_msg', payload: { type: 'user_message', message: 'hi' } })).toEqual({
|
||||
role: 'user',
|
||||
text: 'hi',
|
||||
shape: 'event',
|
||||
phase: undefined,
|
||||
injectionProne: false,
|
||||
});
|
||||
expect(
|
||||
codexMessageFromLine({
|
||||
type: 'event_msg',
|
||||
payload: { type: 'agent_message', message: 'ok', phase: 'commentary' },
|
||||
})
|
||||
).toMatchObject({ role: 'assistant', shape: 'event', phase: 'commentary', injectionProne: false });
|
||||
expect(
|
||||
codexMessageFromLine({
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'item_completed',
|
||||
item: { type: 'AgentMessage', phase: 'final_answer', content: [{ type: 'Text', text: 'done' }] },
|
||||
},
|
||||
})
|
||||
).toMatchObject({ role: 'assistant', text: 'done', shape: 'item', phase: 'final_answer' });
|
||||
expect(
|
||||
codexMessageFromLine({
|
||||
type: 'response_item',
|
||||
payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'typed' }] },
|
||||
})
|
||||
).toMatchObject({ role: 'user', text: 'typed', shape: 'response', injectionProne: true });
|
||||
expect(
|
||||
codexMessageFromLine({
|
||||
type: 'response_item',
|
||||
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'a' }] },
|
||||
})
|
||||
).toMatchObject({ role: 'assistant', shape: 'response', injectionProne: false });
|
||||
});
|
||||
|
||||
it('ignores every other line, empty text and malformed input', () => {
|
||||
for (const value of [
|
||||
null,
|
||||
42,
|
||||
'text',
|
||||
[],
|
||||
{ type: 'session_meta', payload: { id: 'x' } },
|
||||
{ type: 'response_item', payload: { type: 'message', role: 'developer', content: 'policy' } },
|
||||
{ type: 'response_item', payload: { type: 'reasoning', summary: [] } },
|
||||
{ type: 'event_msg', payload: { type: 'user_message', message: '' } },
|
||||
{ type: 'event_msg', payload: { type: 'item_completed', item: { type: 'CommandExecution' } } },
|
||||
])
|
||||
expect(codexMessageFromLine(value)).toBeNull();
|
||||
});
|
||||
|
||||
it('flattens only blocks that carry text', () => {
|
||||
expect(codexContentText('plain')).toBe('plain');
|
||||
expect(
|
||||
codexContentText([
|
||||
{ type: 'input_text', text: 'a' },
|
||||
{ type: 'input_image', image_url: 'x' },
|
||||
{ text: 'b' },
|
||||
null,
|
||||
])
|
||||
).toBe('a\nb');
|
||||
expect(codexContentText(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('treats a heading or a tag as injected context, wherever the wrapper name is new', () => {
|
||||
for (const text of [
|
||||
'# AGENTS.md instructions for /repo',
|
||||
'<environment_context>',
|
||||
'<recommended_plugins>',
|
||||
'<subagent_notification>',
|
||||
'<codex_internal_context source="goal">',
|
||||
'<image name=[Image #1]>',
|
||||
' <turn_aborted>',
|
||||
])
|
||||
expect(isCodexInjectedContext(text)).toBe(true);
|
||||
for (const text of ['fix the bug', 'You are a reviewer', '$skill run', 'a <b> tag later'])
|
||||
expect(isCodexInjectedContext(text)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
// Adversarial browser layout fixture, not evidence of native Codex history:
|
||||
// authentic CLI behavior is measured separately in an isolated live pane.
|
||||
describe('tracked Codex wheel routing in the primary pane', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 60000);
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.stop();
|
||||
});
|
||||
|
||||
it('sends plain wheel to the CLI rather than scrolling stale repaint frames', async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
|
||||
try {
|
||||
await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal);
|
||||
await page.waitForTimeout(3500);
|
||||
await page.evaluate(async () => {
|
||||
const app = (window as any).app;
|
||||
app.activeSessionId = 'codex-wheel-probe';
|
||||
app.sessions.set(app.activeSessionId, { id: app.activeSessionId, mode: 'codex', cliMouseTracking: true });
|
||||
(window as any).scrollInputs = [];
|
||||
// Cell hit testing is irrelevant to routing; the empty dashboard covers the test terminal.
|
||||
app._clientPointToCell = () => ({ col: 1, row: 1 });
|
||||
app._sendInputEphemeral = (id: string, data: string) => (window as any).scrollInputs.push({ id, data });
|
||||
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
|
||||
await new Promise<void>((resolve) => app.terminal.write('\r\nold repaint row'.repeat(100), resolve));
|
||||
app.terminal.scrollToTop();
|
||||
});
|
||||
const fire = (shiftKey = false) =>
|
||||
page.evaluate((shiftKey) => {
|
||||
const t = (window as any).app.terminal;
|
||||
t.element.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, shiftKey, bubbles: true, cancelable: true }));
|
||||
}, shiftKey);
|
||||
await fire();
|
||||
await page.waitForTimeout(150);
|
||||
const forwarded = await page.evaluate(() => {
|
||||
const app = (window as any).app;
|
||||
return {
|
||||
inputs: (window as any).scrollInputs,
|
||||
baseY: app.terminal.buffer.active.baseY,
|
||||
viewportY: app.terminal.buffer.active.viewportY,
|
||||
};
|
||||
});
|
||||
expect(forwarded.inputs.length).toBeGreaterThan(0);
|
||||
expect(forwarded.inputs.every((i: { id: string }) => i.id === 'codex-wheel-probe')).toBe(true);
|
||||
expect(
|
||||
forwarded.inputs
|
||||
.map((i: { data: string }) => i.data)
|
||||
.join('')
|
||||
.match(/\x1b\[<64;/g)
|
||||
).toHaveLength(4);
|
||||
expect(forwarded.viewportY).toBe(forwarded.baseY);
|
||||
for (const shift of [false, true]) {
|
||||
await page.evaluate((tracked) => {
|
||||
const app = (window as any).app;
|
||||
app.sessions.get(app.activeSessionId).cliMouseTracking = tracked;
|
||||
app.terminal.scrollToBottom();
|
||||
(window as any).scrollInputs = [];
|
||||
}, shift);
|
||||
await fire(shift);
|
||||
await page.waitForTimeout(150);
|
||||
const local = await page.evaluate(() => {
|
||||
const b = (window as any).app.terminal.buffer.active;
|
||||
return { inputs: (window as any).scrollInputs, baseY: b.baseY, viewportY: b.viewportY };
|
||||
});
|
||||
expect(local.inputs).toEqual([]);
|
||||
expect(local.viewportY).toBeLessThan(local.baseY);
|
||||
}
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
describe('Command-K agent arrow keys in Chromium', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 60000);
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.stop();
|
||||
});
|
||||
|
||||
it('cycles both directions at query boundaries while preserving text editing and selection', async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
try {
|
||||
await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.openCommandPalette);
|
||||
await page.waitForTimeout(3500);
|
||||
await page.evaluate(() => {
|
||||
const a = (window as any).app;
|
||||
a.cases = [{ name: 'buildmesh', path: '/fixture/buildmesh' }];
|
||||
a.sessions = new Map();
|
||||
a.sessionOrder = [];
|
||||
a._runMode = 'codex';
|
||||
(window as any).__codemanCliCatalog = [
|
||||
{ id: 'claude', label: 'Claude', enabled: true, kind: 'agent' },
|
||||
{ id: 'codex', label: 'Codex', enabled: true, kind: 'agent' },
|
||||
{ id: 'shell', label: 'Shell', enabled: true, kind: 'shell' },
|
||||
];
|
||||
a._runModeAvailableForCase = () => true;
|
||||
a._sendInputEphemeral = () => {
|
||||
throw new Error('Palette leaked terminal input');
|
||||
};
|
||||
a.run = () => {
|
||||
throw new Error('Arrow key launched a session');
|
||||
};
|
||||
});
|
||||
await page.keyboard.press('Control+k');
|
||||
await page.locator('#commandPaletteModal.active').waitFor();
|
||||
const input = page.locator('#commandPaletteSearch');
|
||||
await input.fill('buildmesh');
|
||||
const label = page.locator('.command-palette-agent-label');
|
||||
expect(await label.innerText()).toBe('Codex');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
expect(await label.innerText()).toBe('Claude');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
expect(await label.innerText()).toBe('Codex');
|
||||
expect(await input.inputValue()).toBe('buildmesh');
|
||||
expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(9);
|
||||
await page.keyboard.press('Home');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
expect(await label.innerText()).toBe('Shell');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
expect(await label.innerText()).toBe('Codex');
|
||||
await input.evaluate((e: HTMLInputElement) => e.setSelectionRange(4, 4));
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(3);
|
||||
await page.keyboard.press('ArrowRight');
|
||||
expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(4);
|
||||
await page.keyboard.press('Shift+End');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
expect(await label.innerText()).toBe('Codex');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => (window as any).app.commandPaletteItems[(window as any).app.commandPaletteActiveIndex].type
|
||||
)
|
||||
).toBe('browse-sessions');
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
expect(await label.innerText()).toBe('Codex');
|
||||
await page.keyboard.press('Escape');
|
||||
expect(await page.locator('#commandPaletteModal').evaluate((e) => e.classList.contains('active'))).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function loadPaletteHarness(overrides: Record<string, any> = {}) {
|
||||
function loadPaletteHarness(overrides: Record<string, any> = {}, { sessionUi = false } = {}) {
|
||||
const elements: Record<string, any> = {};
|
||||
const listeners: Record<string, (event: any) => void> = {};
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
@@ -70,6 +70,12 @@ function loadPaletteHarness(overrides: Record<string, any> = {}) {
|
||||
|
||||
const panelsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
|
||||
vm.runInContext(panelsUi, context, { filename: 'panels-ui.js' });
|
||||
// session-ui.js loads after panels-ui.js in index.html. Opt-in: it also brings
|
||||
// the real selectQuickStartCase, which the case-pick tests above stub around.
|
||||
if (sessionUi) {
|
||||
const sessionUiSrc = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUiSrc, context, { filename: 'session-ui.js' });
|
||||
}
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.sessions = new Map([
|
||||
@@ -111,7 +117,7 @@ function loadPaletteHarness(overrides: Record<string, any> = {}) {
|
||||
app.getSessionName = (session: any) =>
|
||||
session.name || session.workingDir?.split('/').pop() || app.getShortId(session.id);
|
||||
|
||||
return { app, elements, listeners };
|
||||
return { app, elements, listeners, makeClassList };
|
||||
}
|
||||
|
||||
describe('Command-K session palette', () => {
|
||||
@@ -341,6 +347,358 @@ describe('Command-K session palette', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const PALETTE_CATALOG = [
|
||||
{ id: 'claude', label: 'Claude Code', kind: 'agent', enabled: true },
|
||||
{ id: 'shell', label: 'Shell', kind: 'shell', enabled: true },
|
||||
{ id: 'codex', label: 'Codex', kind: 'agent', enabled: true },
|
||||
{ id: 'opencode', label: 'OpenCode', kind: 'agent', enabled: true },
|
||||
{ id: 'gemini', label: 'Gemini', kind: 'agent', enabled: false },
|
||||
];
|
||||
|
||||
/** A palette whose agent list comes from the injected CLI catalog, codex not installed. */
|
||||
function loadAgentPalette(catalog: any[] = PALETTE_CATALOG, options: { sessionUi?: boolean } = {}) {
|
||||
const available: Record<string, boolean> = { claude: true, shell: true, codex: false, opencode: true, gemini: true };
|
||||
const harness = loadPaletteHarness(
|
||||
{ window: { __codemanCliCatalog: catalog, __codemanCliAvailable: available } },
|
||||
options
|
||||
);
|
||||
// Mirrors settings-ui.js isCliAvailable(): unknown reads as available.
|
||||
harness.app.isCliAvailable = (id: string) => available[id] !== false;
|
||||
harness.app._runMode = 'claude';
|
||||
return harness;
|
||||
}
|
||||
|
||||
function arrow(key: 'ArrowLeft' | 'ArrowRight', caret: number, value = '') {
|
||||
return {
|
||||
key,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
target: { value, selectionStart: caret, selectionEnd: caret },
|
||||
};
|
||||
}
|
||||
|
||||
describe('Command-K new-session agent picker', () => {
|
||||
it('offers the enabled, installed CLIs from the registry catalog, in catalog order', () => {
|
||||
const { app } = loadAgentPalette();
|
||||
|
||||
expect(app._commandPaletteAgentChoices().map((c: any) => c.id)).toEqual(['claude', 'shell', 'opencode']);
|
||||
});
|
||||
|
||||
it('starts on the Run button mode and labels the row from the catalog', () => {
|
||||
const { app, elements } = loadAgentPalette();
|
||||
app._runMode = 'opencode';
|
||||
|
||||
app.openCommandPalette();
|
||||
const item = app.buildCommandPaletteItems('flux').find((i: any) => i.type === 'new-session');
|
||||
|
||||
expect(item).toMatchObject({ mode: 'opencode', subtitle: 'Run OpenCode in flux-player' });
|
||||
expect(elements.commandPaletteList.innerHTML).toContain('data-palette-agent-cycle="next"');
|
||||
expect(elements.commandPaletteList.innerHTML).toContain('run-mode-dot opencode');
|
||||
});
|
||||
|
||||
it('keeps the switch out of the row button accessible name and gives its tooltip a zh-CN entry', () => {
|
||||
const { app, elements } = loadAgentPalette();
|
||||
app.openCommandPalette();
|
||||
const html = elements.commandPaletteList.innerHTML;
|
||||
const title = 'Agent for the new session (Left/Right to change)';
|
||||
|
||||
// No nested buttons inside the row's <button>: the subtitle names the agent.
|
||||
expect(html).toContain(`class="command-palette-agent" aria-hidden="true" title="${title}"`);
|
||||
expect(html).not.toContain('role="button"');
|
||||
expect(html).not.toContain('aria-label=');
|
||||
const i18n = readFileSync(resolve(import.meta.dirname, '../src/web/public/i18n.js'), 'utf8');
|
||||
expect(i18n.split(`'${title}':`).length - 1).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to the first offered agent when the Run button mode is not offered', () => {
|
||||
const { app } = loadAgentPalette();
|
||||
app._runMode = 'codex'; // not installed
|
||||
|
||||
app.openCommandPalette();
|
||||
|
||||
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('claude');
|
||||
});
|
||||
|
||||
it('cycles the agent with Right/Left on the new-session row, wrapping', async () => {
|
||||
const { app, listeners, elements } = loadAgentPalette();
|
||||
app.openCommandPalette();
|
||||
elements.commandPaletteSearch.value = 'nomatch';
|
||||
app.renderCommandPalette();
|
||||
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].type).toBe('new-session');
|
||||
|
||||
const right = arrow('ArrowRight', 7, 'nomatch');
|
||||
await listeners['search:keydown'](right);
|
||||
expect(right.preventDefault).toHaveBeenCalled();
|
||||
expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'shell' });
|
||||
expect(elements.commandPaletteList.innerHTML).toContain('Run Shell in');
|
||||
|
||||
await listeners['search:keydown'](arrow('ArrowLeft', 0, 'nomatch'));
|
||||
await listeners['search:keydown'](arrow('ArrowLeft', 0, 'nomatch'));
|
||||
expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'opencode' });
|
||||
});
|
||||
|
||||
it('cycles both ways at the end of a typed case query without moving its caret', async () => {
|
||||
const { app, listeners, elements } = loadAgentPalette();
|
||||
app.openCommandPalette();
|
||||
elements.commandPaletteSearch.value = 'nomatch';
|
||||
app.renderCommandPalette();
|
||||
const left = arrow('ArrowLeft', 7, 'nomatch');
|
||||
await listeners['search:keydown'](left);
|
||||
expect(left.preventDefault).toHaveBeenCalled();
|
||||
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('opencode');
|
||||
const right = arrow('ArrowRight', 7, 'nomatch');
|
||||
await listeners['search:keydown'](right);
|
||||
expect(right.preventDefault).toHaveBeenCalled();
|
||||
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
|
||||
});
|
||||
|
||||
it('leaves Left/Right to the caret inside the query', async () => {
|
||||
const { app, listeners, elements } = loadAgentPalette();
|
||||
app.openCommandPalette();
|
||||
elements.commandPaletteSearch.value = 'nomatch';
|
||||
app.renderCommandPalette();
|
||||
|
||||
const left = arrow('ArrowLeft', 3, 'nomatch');
|
||||
const right = arrow('ArrowRight', 3, 'nomatch');
|
||||
await listeners['search:keydown'](left);
|
||||
await listeners['search:keydown'](right);
|
||||
|
||||
expect(left.preventDefault).not.toHaveBeenCalled();
|
||||
expect(right.preventDefault).not.toHaveBeenCalled();
|
||||
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
|
||||
});
|
||||
|
||||
it.each(['altKey', 'ctrlKey', 'metaKey', 'shiftKey'])(
|
||||
'preserves %s arrow editing at query boundaries',
|
||||
async (modifier) => {
|
||||
const { app, listeners, elements } = loadAgentPalette();
|
||||
app.openCommandPalette();
|
||||
elements.commandPaletteSearch.value = 'nomatch';
|
||||
app.renderCommandPalette();
|
||||
const event = { ...arrow('ArrowLeft', 7, 'nomatch'), [modifier]: true };
|
||||
await listeners['search:keydown'](event);
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
|
||||
}
|
||||
);
|
||||
|
||||
it('does not cycle while a session row is highlighted', async () => {
|
||||
const { app, listeners } = loadAgentPalette();
|
||||
app.openCommandPalette();
|
||||
expect(app.commandPaletteItems[0].type).toBe('session');
|
||||
|
||||
const right = arrow('ArrowRight', 0);
|
||||
await listeners['search:keydown'](right);
|
||||
|
||||
expect(right.preventDefault).not.toHaveBeenCalled();
|
||||
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('claude');
|
||||
});
|
||||
|
||||
it('cycles from the row arrows without launching', async () => {
|
||||
const { app, listeners } = loadAgentPalette();
|
||||
app.openCommandPalette();
|
||||
const cycle = { dataset: { paletteAgentCycle: 'next' } };
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
target: { closest: (sel: string) => (sel === '[data-palette-agent-cycle]' ? cycle : null) },
|
||||
};
|
||||
|
||||
listeners['list:click'](event);
|
||||
|
||||
expect(app.run).not.toHaveBeenCalled();
|
||||
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('shell');
|
||||
});
|
||||
|
||||
it('launches the chosen agent once, without changing the Run button mode', async () => {
|
||||
const { app } = loadAgentPalette();
|
||||
app.setRunMode = vi.fn();
|
||||
let modeDuringRun: string | undefined;
|
||||
app.run = vi.fn(async () => {
|
||||
modeDuringRun = app._runMode;
|
||||
});
|
||||
app.openCommandPalette();
|
||||
app._cycleCommandPaletteAgent(2); // claude -> shell -> opencode
|
||||
|
||||
app.commandPaletteItems = app.buildCommandPaletteItems('nomatch');
|
||||
app.commandPaletteActiveIndex = 0;
|
||||
await app.activateCommandPaletteItem();
|
||||
|
||||
expect(app.run).toHaveBeenCalledTimes(1);
|
||||
expect(modeDuringRun).toBe('opencode');
|
||||
expect(app._runMode).toBe('claude');
|
||||
expect(app.setRunMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the Run button mode even when the launch throws', async () => {
|
||||
const { app } = loadAgentPalette();
|
||||
app.run = vi.fn(async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
app.openCommandPalette();
|
||||
app._cycleCommandPaletteAgent(1);
|
||||
app.commandPaletteItems = app.buildCommandPaletteItems('nomatch');
|
||||
app.commandPaletteActiveIndex = 0;
|
||||
|
||||
await expect(app.activateCommandPaletteItem()).rejects.toThrow('boom');
|
||||
expect(app._runMode).toBe('claude');
|
||||
});
|
||||
|
||||
it('draws no picker when only one agent is offered', () => {
|
||||
const { app, elements } = loadAgentPalette([{ id: 'claude', label: 'Claude Code', kind: 'agent', enabled: true }]);
|
||||
|
||||
app.openCommandPalette();
|
||||
|
||||
expect(elements.commandPaletteList.innerHTML).not.toContain('data-palette-agent-cycle');
|
||||
});
|
||||
|
||||
it('keeps the Run button mode when a single other agent is offered', async () => {
|
||||
// Only Shell is offered while the Run button names claude: with nothing to
|
||||
// pick between, the row keeps the Run button's mode, as before the picker
|
||||
// existed, so run() answers a missing claude with its install hint instead
|
||||
// of the palette silently starting Shell.
|
||||
const { app, elements } = loadAgentPalette(PALETTE_CATALOG.slice(0, 2));
|
||||
app.isCliAvailable = (id: string) => id !== 'claude';
|
||||
let modeDuringRun: string | undefined;
|
||||
app.run = vi.fn(async () => {
|
||||
modeDuringRun = app._runMode;
|
||||
});
|
||||
|
||||
app.openCommandPalette();
|
||||
elements.commandPaletteSearch.value = 'nomatch';
|
||||
app.renderCommandPalette();
|
||||
const index = app.commandPaletteItems.findIndex((i: any) => i.type === 'new-session');
|
||||
|
||||
expect(app.commandPaletteItems[index]).toMatchObject({ mode: 'claude', subtitle: 'Run Claude in plex-previews' });
|
||||
expect(elements.commandPaletteList.innerHTML).not.toContain('data-palette-agent-cycle');
|
||||
await app.activateCommandPaletteItem(index);
|
||||
expect(modeDuringRun).toBe('claude');
|
||||
expect(app._runMode).toBe('claude');
|
||||
});
|
||||
});
|
||||
|
||||
/** The toolbar Run menu's entries, as renderRegistryRunOptions() and index.html draw them. */
|
||||
function fakeRunMenu(modes: string[]) {
|
||||
const options = modes.map((mode) => ({ dataset: { mode }, style: { display: 'PRISTINE' } }));
|
||||
return {
|
||||
options,
|
||||
querySelectorAll: (selector: string) => (selector === '.run-mode-option[data-mode]' ? options : []),
|
||||
querySelector: () => null,
|
||||
prepend: vi.fn(),
|
||||
};
|
||||
}
|
||||
const shownByRunMenu = (menu: ReturnType<typeof fakeRunMenu>) =>
|
||||
menu.options.filter((option) => option.style.display === 'flex').map((option) => option.dataset.mode);
|
||||
|
||||
/**
|
||||
* A palette with session-ui.js loaded, so the agent list goes through the Run
|
||||
* menu's real availability rule. Host flags as in loadAgentPalette (no codex
|
||||
* here); the active case is a Docker case with the given `docker` record.
|
||||
*/
|
||||
function loadDockerPalette(docker: Record<string, any>) {
|
||||
const harness = loadAgentPalette(PALETTE_CATALOG, { sessionUi: true });
|
||||
harness.app.cases = [
|
||||
{ name: 'plex-previews' },
|
||||
{ name: 'far', location: 'remote' },
|
||||
{ name: 'dock', location: 'docker', docker },
|
||||
];
|
||||
harness.elements.quickStartCase.value = 'dock';
|
||||
harness.app.selectQuickStartCase = vi.fn();
|
||||
// The Run menu's live container probe is a docker exec round trip; here it
|
||||
// never lands, which is also what a probe still in flight looks like.
|
||||
harness.app._probeDockerCaseModes = vi.fn();
|
||||
return harness;
|
||||
}
|
||||
|
||||
const ADOPTED = { owned: false, hostId: 'local', container: 'dev-box', availableModes: ['shell', 'claude', 'codex'] };
|
||||
|
||||
describe('Command-K new-session agents follow the Run menu for the row case', () => {
|
||||
it('offers a Docker case its container agents and starts on a container-only Run mode', async () => {
|
||||
// codex is missing on the host but ships in the container, and the Run
|
||||
// button is on it (picked from the Run menu, which gates on the container).
|
||||
const { app, elements } = loadDockerPalette(ADOPTED);
|
||||
app._runMode = 'codex';
|
||||
let modeDuringRun: string | undefined;
|
||||
app.run = vi.fn(async () => {
|
||||
modeDuringRun = app._runMode;
|
||||
});
|
||||
|
||||
app.openCommandPalette();
|
||||
elements.commandPaletteSearch.value = 'nomatch';
|
||||
app.renderCommandPalette();
|
||||
const index = app.commandPaletteItems.findIndex((i: any) => i.type === 'new-session');
|
||||
|
||||
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual(['claude', 'shell', 'codex']);
|
||||
expect(app.commandPaletteItems[index]).toMatchObject({
|
||||
caseName: 'dock',
|
||||
mode: 'codex',
|
||||
subtitle: 'Run Codex in dock',
|
||||
});
|
||||
expect(elements.commandPaletteList.innerHTML).toContain('run-mode-dot codex');
|
||||
|
||||
// The same agents the Run menu shows for this case.
|
||||
const menu = fakeRunMenu(['claude', 'codex', 'opencode', 'shell']);
|
||||
app._refreshRunModeAvailability(menu);
|
||||
expect(shownByRunMenu(menu)).toEqual(['claude', 'codex']);
|
||||
|
||||
await app.activateCommandPaletteItem(index);
|
||||
expect(modeDuringRun).toBe('codex');
|
||||
expect(app._runMode).toBe('codex');
|
||||
});
|
||||
|
||||
it('offers every enabled agent for an owned container nothing has probed yet, and then follows the probe', () => {
|
||||
// An owned container runs the base image, which ships every CLI, and before
|
||||
// its first session there is nothing to probe: like the Run menu, an unknown
|
||||
// list does not gate, so a probe still in flight cannot empty the row.
|
||||
const { app } = loadDockerPalette({ owned: true, hostId: 'local', container: 'codeman-dock' });
|
||||
app._runMode = 'codex';
|
||||
|
||||
app.openCommandPalette();
|
||||
|
||||
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual([
|
||||
'claude',
|
||||
'shell',
|
||||
'codex',
|
||||
'opencode',
|
||||
]);
|
||||
expect(app.commandPaletteItems.find((i: any) => i.type === 'new-session').mode).toBe('codex');
|
||||
// The palette reads what the Run menu learned; it never starts a docker exec itself.
|
||||
expect(app._probeDockerCaseModes).not.toHaveBeenCalled();
|
||||
const menu = fakeRunMenu(['claude', 'codex', 'opencode', 'shell']);
|
||||
app._refreshRunModeAvailability(menu);
|
||||
expect(shownByRunMenu(menu)).toEqual(['claude', 'codex', 'opencode']);
|
||||
|
||||
// Once the live probe has landed, both follow it.
|
||||
app._dockerCaseModes = { dock: ['shell', 'opencode'] };
|
||||
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual(['shell', 'opencode']);
|
||||
app._refreshRunModeAvailability(menu);
|
||||
expect(shownByRunMenu(menu)).toEqual(['opencode']);
|
||||
});
|
||||
|
||||
it('keeps the host rule for local and remote cases, and follows the case the query names', () => {
|
||||
const { app, elements } = loadDockerPalette(ADOPTED);
|
||||
elements.quickStartCase.value = 'plex-previews';
|
||||
app._runMode = 'codex'; // missing on the host
|
||||
const row = () => app.commandPaletteItems.find((i: any) => i.type === 'new-session');
|
||||
|
||||
app.openCommandPalette();
|
||||
|
||||
expect(app._commandPaletteAgentChoices('plex-previews').map((c: any) => c.id)).toEqual([
|
||||
'claude',
|
||||
'shell',
|
||||
'opencode',
|
||||
]);
|
||||
expect(app._commandPaletteAgentChoices('far').map((c: any) => c.id)).toEqual(['claude', 'shell', 'opencode']);
|
||||
expect(row()).toMatchObject({ caseName: 'plex-previews', mode: 'claude' });
|
||||
|
||||
// Typing the Docker case's name moves the row there, where the container
|
||||
// has the Run button's codex, so the row starts on it.
|
||||
elements.commandPaletteSearch.value = 'dock';
|
||||
app.renderCommandPalette();
|
||||
expect(row()).toMatchObject({ caseName: 'dock', mode: 'codex' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Manager unified list', () => {
|
||||
it('maps UnifiedSessionItem fields to the history-record shape and routes clicks by liveness', async () => {
|
||||
const { app, elements } = loadPaletteHarness({
|
||||
@@ -480,6 +838,150 @@ describe('Session Manager unified list', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('overlay focus restoration (Escape must not strand the keyboard)', () => {
|
||||
/**
|
||||
* Both overlays focus their search box on open. Closing them used to leave
|
||||
* focus on <body>, so after Escape every keystroke went nowhere until the
|
||||
* user clicked the terminal — measured in a real browser against a shell
|
||||
* session: activeElement BODY, zero onData for anything typed afterwards.
|
||||
*/
|
||||
function focusHarness() {
|
||||
const terminalTextarea = { focus: vi.fn(), isConnected: true };
|
||||
const priorElement = { focus: vi.fn(), isConnected: true, tagName: 'TEXTAREA' };
|
||||
const body = { tagName: 'BODY' };
|
||||
let active: any = priorElement;
|
||||
// The harness builds its element map internally, so getElementById reads it
|
||||
// through this binding, filled in once the harness returns.
|
||||
let els: Record<string, any> = {};
|
||||
const { app, elements, makeClassList } = loadPaletteHarness({
|
||||
document: {
|
||||
getElementById: (id: string) => els[id] ?? null,
|
||||
get activeElement() {
|
||||
return active;
|
||||
},
|
||||
body,
|
||||
},
|
||||
});
|
||||
els = elements;
|
||||
app.terminal = { focus: terminalTextarea.focus };
|
||||
app.activeSessionId = 'sess-beta';
|
||||
// The overlay's own focus() is what moves focus in a real browser; the
|
||||
// fake document needs the same transition or the test proves nothing.
|
||||
elements.commandPaletteSearch.focus = vi.fn(() => {
|
||||
active = elements.commandPaletteSearch;
|
||||
});
|
||||
// The restore keeps a focus that already left the overlay, so the modal has
|
||||
// to know its own search box is inside it, as the real DOM does.
|
||||
elements.commandPaletteModal.contains = (el: any) => el === elements.commandPaletteSearch;
|
||||
// Same wiring for the Session Manager. A real classList: the close guard
|
||||
// reads `contains('active')`, and a stub without it reports "not open" and
|
||||
// skips the restore.
|
||||
const installSessionManager = () => {
|
||||
const search: any = { value: '', addEventListener: vi.fn() };
|
||||
search.focus = vi.fn(() => {
|
||||
active = search;
|
||||
});
|
||||
elements.sessionManagerSearch = search;
|
||||
elements.sessionManagerModal = {
|
||||
classList: makeClassList(),
|
||||
addEventListener: vi.fn(),
|
||||
contains: (el: any) => el === search,
|
||||
};
|
||||
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: vi.fn() };
|
||||
app._loadSessionManagerList = vi.fn();
|
||||
};
|
||||
return {
|
||||
app,
|
||||
elements,
|
||||
priorElement,
|
||||
terminalTextarea,
|
||||
body,
|
||||
makeClassList,
|
||||
installSessionManager,
|
||||
setActive: (v: any) => (active = v),
|
||||
};
|
||||
}
|
||||
|
||||
it('returns focus to whatever had it when the command palette closes', () => {
|
||||
const { app, priorElement } = focusHarness();
|
||||
app.openCommandPalette();
|
||||
expect(priorElement.focus).not.toHaveBeenCalled();
|
||||
app.closeCommandPalette();
|
||||
expect(priorElement.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to the terminal when the prior element is gone, but only with a live session', () => {
|
||||
const { app, priorElement, terminalTextarea } = focusHarness();
|
||||
app.openCommandPalette();
|
||||
priorElement.isConnected = false;
|
||||
app.closeCommandPalette();
|
||||
expect(priorElement.focus).not.toHaveBeenCalled();
|
||||
expect(terminalTextarea.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never focuses the terminal from the welcome screen (a phone would pop the keyboard)', () => {
|
||||
const { app, priorElement, terminalTextarea } = focusHarness();
|
||||
app.activeSessionId = null;
|
||||
app.openCommandPalette();
|
||||
priorElement.isConnected = false;
|
||||
app.closeCommandPalette();
|
||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not restore focus to <body>, which is the bug itself', () => {
|
||||
const { app, terminalTextarea, body, setActive } = focusHarness();
|
||||
setActive(body);
|
||||
app.openCommandPalette();
|
||||
app.closeCommandPalette();
|
||||
expect(terminalTextarea.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves focus alone when neither overlay was open — the path every Escape takes', () => {
|
||||
// app.js's global Escape handler calls both close methods on EVERY Escape,
|
||||
// in the capture phase. Nothing was saved, so an unguarded restore would fall
|
||||
// through to the terminal and steal focus from split Pane B, from any text
|
||||
// field, and turn the inline rename's Escape into a commit.
|
||||
const { app, elements, terminalTextarea, priorElement, makeClassList } = focusHarness();
|
||||
elements.sessionManagerModal = { classList: makeClassList(), addEventListener: vi.fn() };
|
||||
app.closeCommandPalette();
|
||||
app.closeSessionManager();
|
||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
||||
expect(priorElement.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not focus the terminal on touch while the keyboard is down', () => {
|
||||
const { app, terminalTextarea, body, setActive } = focusHarness();
|
||||
app._shouldFocusTerminalForTabSwitch = () => false;
|
||||
setActive(body);
|
||||
app.openCommandPalette();
|
||||
app.closeCommandPalette();
|
||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores focus on the session manager too, not just the palette', async () => {
|
||||
const { app, priorElement, installSessionManager } = focusHarness();
|
||||
installSessionManager();
|
||||
await app.openSessionManager();
|
||||
app.closeSessionManager();
|
||||
expect(priorElement.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the terminal focus the row menu gave it when the session manager closes', async () => {
|
||||
// "Switch to session" and "Open folder" (terminal-ui.js) call selectSession(),
|
||||
// which focuses the terminal on desktop, and only THEN closeSessionManager().
|
||||
// Opened from its header button, the saved focus is that button, so an
|
||||
// unconditional restore pulled focus off the session the user just picked.
|
||||
const { app, priorElement, terminalTextarea, installSessionManager, setActive } = focusHarness();
|
||||
installSessionManager();
|
||||
await app.openSessionManager();
|
||||
app.selectSession = vi.fn(() => setActive(terminalTextarea));
|
||||
app.selectSession('sess-alpha');
|
||||
app.closeSessionManager();
|
||||
expect(priorElement.focus).not.toHaveBeenCalled();
|
||||
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('panel close helpers', () => {
|
||||
it('closes panels when the mobile header helper is unavailable', () => {
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @fileoverview The snapshot origin must survive a hostname change (a Docker container gets a new
|
||||
* hostname on every recreate) and still differ between data dirs.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const host = vi.hoisted(() => ({ name: 'container-a' }));
|
||||
vi.mock('node:os', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('node:os')>()),
|
||||
hostname: () => host.name,
|
||||
}));
|
||||
|
||||
import {
|
||||
configBackupOrigin,
|
||||
createConfigBackup,
|
||||
listConfigBackups,
|
||||
pruneConfigBackups,
|
||||
restoreConfigBackup,
|
||||
} from '../src/config-backup.js';
|
||||
|
||||
let root: string;
|
||||
let data: string;
|
||||
let backups: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'cfg-origin-'));
|
||||
data = join(root, 'data');
|
||||
backups = join(root, 'backups');
|
||||
mkdirSync(data, { recursive: true });
|
||||
writeFileSync(join(data, 'settings.json'), '{"a":1}');
|
||||
host.name = 'container-a';
|
||||
});
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('configBackupOrigin', () => {
|
||||
it('is made once, kept in the data dir, and differs between data dirs', () => {
|
||||
const a = configBackupOrigin(data);
|
||||
expect(configBackupOrigin(data)).toBe(a);
|
||||
expect(readFileSync(join(data, 'config-backup-id'), 'utf8').trim()).toBe(a);
|
||||
const other = join(root, 'other');
|
||||
expect(configBackupOrigin(other)).not.toBe(a);
|
||||
});
|
||||
|
||||
it('keeps snapshots listed, restorable and prunable after the hostname changes', () => {
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: 1_000_000 }) as {
|
||||
id: string;
|
||||
};
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'manual', force: true, now: 2_000_000 });
|
||||
host.name = 'container-b'; // the container was recreated
|
||||
const origin = configBackupOrigin(data);
|
||||
expect(listConfigBackups(backups, origin)).toHaveLength(2);
|
||||
writeFileSync(join(data, 'settings.json'), '{"a":2}'); // a change, so the restore takes a pre-restore copy
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id })).not.toThrow();
|
||||
const removed = pruneConfigBackups(backups, { keepCount: 1, keepDays: 0, origin });
|
||||
expect(removed).toHaveLength(2); // 3 now: the restore took a pre-restore copy
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @fileoverview Restoring settings.json from inside App Settings must refill the form, or Save
|
||||
* would PUT the pre-restore values back over the restored file. Evaluates the real settings-ui.js
|
||||
* against a stub app.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const SRC = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf-8');
|
||||
|
||||
function makeApp(
|
||||
restored: string[],
|
||||
confirmed = true,
|
||||
reply: { ok: boolean; body: unknown } = { ok: true, body: { success: true, data: { restored, restartRequired: [] } } }
|
||||
) {
|
||||
const calls: string[] = [];
|
||||
const confirms: string[] = [];
|
||||
const toasts: Array<[string, string]> = [];
|
||||
function CodemanApp(this: unknown) {}
|
||||
const ctx = vm.createContext({
|
||||
CodemanApp,
|
||||
window: {},
|
||||
document: {},
|
||||
confirm: (m: string) => (confirms.push(m), confirmed),
|
||||
escapeHtml: (s: string) => s,
|
||||
});
|
||||
vm.runInContext(SRC, ctx);
|
||||
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>;
|
||||
Object.assign(app, {
|
||||
_configBackups: [
|
||||
{
|
||||
id: 'cfg-1',
|
||||
files: [{ name: 'settings.json' }, { name: 'users.json' }, { name: '.env' }, { name: 'clis.json' }],
|
||||
},
|
||||
],
|
||||
_apiPost: async () => ({ ok: reply.ok, json: async () => reply.body }),
|
||||
loadAppSettingsFromServer: async () => void calls.push('reload-settings'),
|
||||
openAppSettings: () => void calls.push('refill-form'),
|
||||
loadConfigBackups: () => void calls.push('reload-list'),
|
||||
showToast: (m: string, type: string) => void toasts.push([m, type]),
|
||||
});
|
||||
return { app, calls, confirms, toasts };
|
||||
}
|
||||
|
||||
describe('restoreConfigBackup (Settings UI)', () => {
|
||||
it('re-reads settings into the form when settings.json was restored', async () => {
|
||||
const { app, calls } = makeApp(['settings.json', 'clis.json']);
|
||||
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
|
||||
expect(calls).toEqual(['reload-settings', 'refill-form', 'reload-list']);
|
||||
});
|
||||
|
||||
it('leaves the form alone when settings.json was not part of the restore', async () => {
|
||||
const { app, calls } = makeApp(['clis.json']);
|
||||
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
|
||||
expect(calls).toEqual(['reload-list']);
|
||||
});
|
||||
|
||||
it('a restore that failed partway still refills the form when settings.json was among the files written', async () => {
|
||||
const { app, calls, toasts } = makeApp([], true, {
|
||||
ok: false,
|
||||
body: {
|
||||
success: false,
|
||||
errorCode: 'OPERATION_FAILED',
|
||||
error: 'Restore incomplete: could not write clis.json (EISDIR); settings.json restored',
|
||||
data: {
|
||||
restored: ['settings.json'],
|
||||
failed: [{ name: 'clis.json', error: 'EISDIR' }],
|
||||
safetySnapshot: 'cfg-2',
|
||||
restartRequired: ['settings.json'],
|
||||
},
|
||||
},
|
||||
});
|
||||
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
|
||||
expect(calls).toEqual(['reload-settings', 'refill-form', 'reload-list']);
|
||||
expect(toasts).toHaveLength(1);
|
||||
expect(toasts[0][1]).toBe('error');
|
||||
expect(toasts[0][0]).toContain('clis.json');
|
||||
});
|
||||
|
||||
it('a refused restore (nothing written) only reports the error', async () => {
|
||||
const { app, calls, toasts } = makeApp([], true, {
|
||||
ok: false,
|
||||
body: { success: false, errorCode: 'CONFLICT', error: 'settings.json does not match its checksum' },
|
||||
});
|
||||
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
|
||||
expect(calls).toEqual([]);
|
||||
expect(toasts).toEqual([['settings.json does not match its checksum', 'error']]);
|
||||
});
|
||||
|
||||
it('names the files being replaced and calls out the credential ones', async () => {
|
||||
const { app, confirms } = makeApp(['settings.json'], false);
|
||||
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
|
||||
expect(confirms[0]).toContain('settings.json, users.json, .env, clis.json');
|
||||
expect(confirms[0]).toContain('credentials');
|
||||
expect(confirms[0]).toMatch(/users\.json, \.env\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfigBackups (retention fields)', () => {
|
||||
function load(saved: Record<string, unknown>) {
|
||||
const inputs: Record<string, { value: string; placeholder: string }> = {
|
||||
appSettingsConfigBackupKeepCount: { value: '20', placeholder: '' },
|
||||
appSettingsConfigBackupKeepDays: { value: '30', placeholder: '' },
|
||||
appSettingsConfigBackupDir: { value: '', placeholder: '' },
|
||||
};
|
||||
function CodemanApp(this: unknown) {}
|
||||
const ctx = vm.createContext({
|
||||
CodemanApp,
|
||||
window: {},
|
||||
document: { getElementById: (id: string) => inputs[id] ?? null },
|
||||
escapeHtml: (s: string) => s,
|
||||
});
|
||||
vm.runInContext(SRC, ctx);
|
||||
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>;
|
||||
Object.assign(app, {
|
||||
$: () => ({ textContent: '', innerHTML: '' }),
|
||||
loadAppSettingsFromStorage: () => saved,
|
||||
_api: async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: { settings: { keepCount: 100, keepDays: 0 }, dir: '/d', defaultDir: '/d', backups: [] },
|
||||
}),
|
||||
}),
|
||||
});
|
||||
return { app, inputs };
|
||||
}
|
||||
|
||||
it('shows the retention the passes use when settings.json has none of the keys (a reset file)', async () => {
|
||||
const { app, inputs } = load({});
|
||||
await (app.loadConfigBackups as () => Promise<void>)();
|
||||
expect(inputs.appSettingsConfigBackupKeepCount.value).toBe(100);
|
||||
expect(inputs.appSettingsConfigBackupKeepDays.value).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves the form alone when the saved settings state the retention', async () => {
|
||||
const { app, inputs } = load({ configBackupKeepCount: 7, configBackupKeepDays: 9 });
|
||||
await (app.loadConfigBackups as () => Promise<void>)();
|
||||
expect(inputs.appSettingsConfigBackupKeepCount.value).toBe('20'); // the open-settings code owns it
|
||||
});
|
||||
});
|
||||
|
||||
describe('Save and the displayed retention', () => {
|
||||
function payload(
|
||||
prev: Record<string, unknown>,
|
||||
touched: { count?: boolean; days?: boolean },
|
||||
count = '100',
|
||||
days = '0'
|
||||
) {
|
||||
const inputs: Record<string, { value: string; dataset: Record<string, string> }> = {
|
||||
appSettingsConfigBackupKeepCount: { value: count, dataset: touched.count ? { touched: '1' } : {} },
|
||||
appSettingsConfigBackupKeepDays: { value: days, dataset: touched.days ? { touched: '1' } : {} },
|
||||
};
|
||||
function CodemanApp(this: unknown) {}
|
||||
const ctx = vm.createContext({
|
||||
CodemanApp,
|
||||
window: {},
|
||||
document: { getElementById: (id: string) => inputs[id] },
|
||||
});
|
||||
vm.runInContext(SRC, ctx);
|
||||
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>;
|
||||
return (app._configBackupRetentionPayload as (p: Record<string, unknown>) => Record<string, unknown>).call(
|
||||
app,
|
||||
prev
|
||||
);
|
||||
}
|
||||
|
||||
it('leaves out a retention value that was only displayed (a reset settings.json)', () => {
|
||||
expect(payload({}, {})).toEqual({});
|
||||
});
|
||||
|
||||
it('sends a value that was saved before, or edited here, and only that one', () => {
|
||||
expect(payload({ configBackupKeepCount: 100 }, {})).toEqual({ configBackupKeepCount: 100 });
|
||||
expect(payload({}, { days: true })).toEqual({ configBackupKeepDays: 0 });
|
||||
expect(payload({}, { count: true, days: true }, '5', '7')).toEqual({
|
||||
configBackupKeepCount: 5,
|
||||
configBackupKeepDays: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps like the form always did', () => {
|
||||
expect(payload({}, { count: true, days: true }, '1', '99999')).toEqual({
|
||||
configBackupKeepCount: 2,
|
||||
configBackupKeepDays: 3650,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,686 @@
|
||||
/**
|
||||
* @fileoverview Config backups (src/config-backup.ts): what is tracked, when a snapshot is made,
|
||||
* how retention prunes, and that restore verifies before it writes. Pure fs over temp dirs;
|
||||
* port: N/A.
|
||||
*/
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CONFIG_BACKUP_FILES,
|
||||
ConfigBackupCorruptError,
|
||||
ConfigBackupNotFoundError,
|
||||
ConfigBackupPartialRestoreError,
|
||||
createConfigBackup,
|
||||
defaultConfigBackupDir,
|
||||
diffConfigBackups,
|
||||
listConfigBackups,
|
||||
normalizeBackupDir,
|
||||
pruneConfigBackups,
|
||||
readConfigBackupSettings,
|
||||
resolveConfigBackupDir,
|
||||
restoreConfigBackup,
|
||||
configBackupOrigin,
|
||||
latestBackupRetention,
|
||||
sweepConfigBackupTemps,
|
||||
} from '../src/config-backup.js';
|
||||
|
||||
let root: string;
|
||||
let data: string;
|
||||
let backups: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'cfg-backup-'));
|
||||
data = join(root, 'data');
|
||||
backups = join(root, 'backups');
|
||||
mkdirSync(data);
|
||||
});
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const put = (name: string, text: string) => writeFileSync(join(data, name), text);
|
||||
const DAY = 86_400_000;
|
||||
const T0 = Date.UTC(2026, 9, 1, 12, 0, 0);
|
||||
|
||||
describe('settings', () => {
|
||||
it('defaults to ON, 20 copies, 30 days, default folder', () => {
|
||||
expect(readConfigBackupSettings({})).toEqual({ enabled: true, dir: null, keepCount: 20, keepDays: 30 });
|
||||
expect(readConfigBackupSettings(null).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('only an explicit false turns it off, and numbers are clamped to their ranges', () => {
|
||||
expect(readConfigBackupSettings({ configBackupEnabled: false }).enabled).toBe(false);
|
||||
const s = readConfigBackupSettings({ configBackupKeepCount: 9999, configBackupKeepDays: -3 });
|
||||
expect(s.keepCount).toBe(500);
|
||||
expect(s.keepDays).toBe(0);
|
||||
expect(readConfigBackupSettings({ configBackupKeepCount: 0 }).keepCount).toBe(2);
|
||||
expect(readConfigBackupSettings({ configBackupKeepCount: 'abc' }).keepCount).toBe(20);
|
||||
expect(readConfigBackupSettings({ configBackupKeepCount: 7.9 }).keepCount).toBe(7);
|
||||
});
|
||||
|
||||
it('expands ~ and refuses anything that is not an absolute path', () => {
|
||||
expect(normalizeBackupDir('~/cfg', '/home/u')).toBe('/home/u/cfg');
|
||||
expect(normalizeBackupDir('/mnt/backups/codeman', '/home/u')).toBe('/mnt/backups/codeman');
|
||||
for (const bad of ['', ' ', 'relative/dir', './here', undefined, 42, '/x\0y']) {
|
||||
expect(normalizeBackupDir(bad as never, '/home/u'), String(bad)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses <data dir>/backups/config by default and never the data dir itself', () => {
|
||||
expect(resolveConfigBackupDir(readConfigBackupSettings({}), '/d')).toBe(defaultConfigBackupDir('/d'));
|
||||
expect(resolveConfigBackupDir(readConfigBackupSettings({ configBackupDir: '/elsewhere' }), '/d')).toBe(
|
||||
'/elsewhere'
|
||||
);
|
||||
expect(resolveConfigBackupDir(readConfigBackupSettings({ configBackupDir: '/d' }), '/d')).toBe(
|
||||
defaultConfigBackupDir('/d')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createConfigBackup', () => {
|
||||
it('snapshots only the tracked files that exist, with a manifest', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
put('clis.json', '{"clis":{}}');
|
||||
put('state.json', '{"runtime":true}'); // not config: must not be copied
|
||||
put('hook-secret', 'x'); // regenerated: must not be copied
|
||||
const r = createConfigBackup({
|
||||
dataDir: data,
|
||||
backupDir: backups,
|
||||
reason: 'startup',
|
||||
appVersion: '1.2.3',
|
||||
now: T0,
|
||||
});
|
||||
expect(r.status).toBe('created');
|
||||
const [b] = listConfigBackups(backups);
|
||||
expect(b.files.map((f) => f.name).sort()).toEqual(['clis.json', 'settings.json']);
|
||||
expect(b.reason).toBe('startup');
|
||||
expect(b.appVersion).toBe('1.2.3');
|
||||
expect(b.createdAt).toBe(T0);
|
||||
expect(existsSync(join(backups, b.id, 'state.json'))).toBe(false);
|
||||
expect(readFileSync(join(backups, b.id, 'settings.json'), 'utf8')).toBe('{"a":1}');
|
||||
});
|
||||
|
||||
it('tracks a fixed allowlist', () => {
|
||||
expect([...CONFIG_BACKUP_FILES]).toContain('settings.json');
|
||||
expect([...CONFIG_BACKUP_FILES]).not.toContain('state.json');
|
||||
expect([...CONFIG_BACKUP_FILES]).not.toContain('hook-secret');
|
||||
});
|
||||
|
||||
it('makes no second snapshot while nothing changed, and one when something does', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
const again = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 });
|
||||
expect(again).toEqual({ status: 'unchanged', id: (first as { id: string }).id });
|
||||
expect(listConfigBackups(backups)).toHaveLength(1);
|
||||
|
||||
put('settings.json', '{"a":2}');
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
|
||||
'created'
|
||||
);
|
||||
expect(listConfigBackups(backups)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not count a self-refreshed discovery timestamp as a change', () => {
|
||||
const hosts = (stamp: string, model = 'm1') =>
|
||||
JSON.stringify({ hosts: [{ id: 'h', models: [{ id: model }], lastDiscoveredAt: stamp }] });
|
||||
put('custom-model-hosts.json', hosts('2026-10-12T08:44:22.026Z'));
|
||||
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
|
||||
// The 5-minute discovery poll rewrites only the timestamp: still the same state.
|
||||
put('custom-model-hosts.json', hosts('2026-10-12T08:49:22.025Z'));
|
||||
const again = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 });
|
||||
expect(again).toEqual({ status: 'unchanged', id: (first as { id: string }).id });
|
||||
expect(listConfigBackups(backups)).toHaveLength(1);
|
||||
|
||||
// A real edit next to a refreshed timestamp is still a change, stored exactly as written.
|
||||
const edited = hosts('2026-10-12T08:54:22.025Z', 'm2');
|
||||
put('custom-model-hosts.json', edited);
|
||||
const third = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 });
|
||||
expect(third.status).toBe('created');
|
||||
expect(readFileSync(join(backups, (third as { id: string }).id, 'custom-model-hosts.json'), 'utf8')).toBe(edited);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'webviews.json',
|
||||
(n: number) =>
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'w1',
|
||||
name: 'Grafana',
|
||||
url: 'http://localhost:3001',
|
||||
embedMode: 'proxy',
|
||||
trusted: false,
|
||||
createdAt: 1,
|
||||
lastOpenedAt: n,
|
||||
},
|
||||
]),
|
||||
'name',
|
||||
],
|
||||
[
|
||||
'docker-cases.json',
|
||||
(n: number) => JSON.stringify([{ name: 'app', hostId: 'h', lastClaudeSessionId: `c${n}` }]),
|
||||
'hostId',
|
||||
],
|
||||
['users.json', (n: number) => JSON.stringify({ users: [{ name: 'a', role: 'admin', lastLoginAt: n }] }), 'role'],
|
||||
['settings.json', (n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}` }), 'displayName'],
|
||||
[
|
||||
'intents.json',
|
||||
(n: number) => JSON.stringify({ profiles: [{ key: 'k', goals: 'g', updatedAt: n, recentPrompts: [`p${n}`] }] }),
|
||||
'goals',
|
||||
],
|
||||
])('does not count activity in %s as a change, but does count an edit next to it', (name, make, editKey) => {
|
||||
put(name, make(1));
|
||||
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
put(name, make(2));
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 })).toEqual({
|
||||
status: 'unchanged',
|
||||
id: (first as { id: string }).id,
|
||||
});
|
||||
put(name, make(3).replace(new RegExp(`"${editKey}":"[^"]*"`), `"${editKey}":"edited"`));
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
|
||||
'created'
|
||||
);
|
||||
});
|
||||
|
||||
it('compares a tracked file that is not valid JSON by its bytes', () => {
|
||||
put('custom-model-hosts.json', 'not json lastDiscoveredAt 1');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
put('custom-model-hosts.json', 'not json lastDiscoveredAt 2');
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }).status).toBe(
|
||||
'created'
|
||||
);
|
||||
});
|
||||
|
||||
it('force makes a snapshot even when unchanged, and two in one second get distinct ids', () => {
|
||||
put('settings.json', '{}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'a', now: T0 });
|
||||
const forced = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'manual', force: true, now: T0 });
|
||||
expect(forced.status).toBe('created');
|
||||
const ids = listConfigBackups(backups).map((b) => b.id);
|
||||
expect(new Set(ids).size).toBe(2);
|
||||
});
|
||||
|
||||
it('reports empty, creating nothing, when no tracked file exists', () => {
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x' })).toEqual({ status: 'empty' });
|
||||
expect(existsSync(backups)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps snapshots private: 0700 folder and 0600 files', () => {
|
||||
put('settings.json', '{}');
|
||||
put('.env', 'CODEMAN_PASSWORD=secret');
|
||||
const r = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x', now: T0 });
|
||||
const id = (r as { id: string }).id;
|
||||
expect(statSync(join(backups, id)).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(join(backups, id, '.env')).mode & 0o777).toBe(0o600);
|
||||
expect(statSync(join(backups, id, 'manifest.json')).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('leaves no temp folder behind', () => {
|
||||
put('settings.json', '{}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x', now: T0 });
|
||||
const stray = readdirSync(backups).filter((n) => n.startsWith('.tmp-'));
|
||||
expect(stray).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retention', () => {
|
||||
const makeN = (n: number) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
put('settings.json', JSON.stringify({ i }));
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + i * DAY });
|
||||
}
|
||||
};
|
||||
|
||||
it('keeps the newest keepCount', () => {
|
||||
makeN(6);
|
||||
const removed = pruneConfigBackups(backups, { keepCount: 3, keepDays: 0, now: T0 + 6 * DAY });
|
||||
expect(removed).toHaveLength(3);
|
||||
const left = listConfigBackups(backups);
|
||||
expect(left).toHaveLength(3);
|
||||
expect(left.map((b) => b.createdAt)).toEqual([T0 + 5 * DAY, T0 + 4 * DAY, T0 + 3 * DAY]);
|
||||
});
|
||||
|
||||
it('drops anything older than keepDays, but never the newest', () => {
|
||||
makeN(4); // T0 .. T0+3d
|
||||
// At T0+100d everything is older than 30 days: the newest still survives.
|
||||
const removed = pruneConfigBackups(backups, { keepCount: 50, keepDays: 30, now: T0 + 100 * DAY });
|
||||
expect(removed).toHaveLength(3);
|
||||
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([T0 + 3 * DAY]);
|
||||
});
|
||||
|
||||
it('applies both limits together and keepDays 0 means no age limit', () => {
|
||||
makeN(5);
|
||||
expect(pruneConfigBackups(backups, { keepCount: 50, keepDays: 0, now: T0 + 999 * DAY })).toEqual([]);
|
||||
const removed = pruneConfigBackups(backups, { keepCount: 4, keepDays: 3, now: T0 + 5 * DAY });
|
||||
// Newest first: T0+4d, +3d, +2d, +1d, T0. A snapshot ages from its successor: T0+1d was live
|
||||
// until T0+2d, which sits exactly on the cutoff (kept); only T0 is over the count.
|
||||
expect(removed.length).toBe(1);
|
||||
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([
|
||||
T0 + 4 * DAY,
|
||||
T0 + 3 * DAY,
|
||||
T0 + 2 * DAY,
|
||||
T0 + DAY,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the last good copy when the first change comes after a quiet month', () => {
|
||||
put('settings.json', '{"theme":"good"}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'startup', now: T0 });
|
||||
put('settings.json', '{}'); // the bad write, 40 days later
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 40 * DAY });
|
||||
pruneConfigBackups(backups, { keepCount: 20, keepDays: 30, now: T0 + 40 * DAY });
|
||||
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([T0 + 40 * DAY, T0]);
|
||||
});
|
||||
|
||||
it('never deletes a folder it did not make', () => {
|
||||
makeN(3);
|
||||
mkdirSync(join(backups, 'photos'));
|
||||
writeFileSync(join(backups, 'photos', 'a.jpg'), 'x');
|
||||
mkdirSync(join(backups, 'cfg-20200101-000000')); // right name, no manifest: not ours
|
||||
writeFileSync(join(backups, 'notes.txt'), 'keep');
|
||||
pruneConfigBackups(backups, { keepCount: 1, keepDays: 1, now: T0 + 500 * DAY });
|
||||
expect(existsSync(join(backups, 'photos', 'a.jpg'))).toBe(true);
|
||||
expect(existsSync(join(backups, 'cfg-20200101-000000'))).toBe(true);
|
||||
expect(existsSync(join(backups, 'notes.txt'))).toBe(true);
|
||||
expect(listConfigBackups(backups)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does nothing for a folder that does not exist', () => {
|
||||
expect(pruneConfigBackups(join(root, 'nope'), { keepCount: 1, keepDays: 1 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreConfigBackup', () => {
|
||||
it('puts the files back, atomically, after taking a pre-restore snapshot', () => {
|
||||
put('settings.json', '{"good":true}');
|
||||
put('clis.json', '{"clis":{"grok":{"enabled":true}}}');
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
|
||||
put('settings.json', '{"polluted":true}');
|
||||
rmSync(join(data, 'clis.json'));
|
||||
|
||||
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id, now: T0 + 1000 });
|
||||
expect(r.restored.sort()).toEqual(['clis.json', 'settings.json']);
|
||||
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"good":true}');
|
||||
expect(readFileSync(join(data, 'clis.json'), 'utf8')).toBe('{"clis":{"grok":{"enabled":true}}}');
|
||||
expect(statSync(join(data, 'settings.json')).mode & 0o777).toBe(0o600);
|
||||
|
||||
// The state it replaced is recoverable.
|
||||
expect(r.safetySnapshot).toBeTruthy();
|
||||
const safety = listConfigBackups(backups).find((b) => b.id === r.safetySnapshot)!;
|
||||
expect(safety.reason).toBe('pre-restore');
|
||||
expect(readFileSync(join(backups, safety.id, 'settings.json'), 'utf8')).toBe('{"polluted":true}');
|
||||
});
|
||||
|
||||
it('refuses a snapshot whose file no longer matches its checksum, changing nothing', () => {
|
||||
put('settings.json', '{"good":true}');
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
chmodSync(join(backups, made.id, 'settings.json'), 0o600);
|
||||
writeFileSync(join(backups, made.id, 'settings.json'), '{"tampered":true}');
|
||||
put('settings.json', '{"live":true}');
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id })).toThrow(
|
||||
ConfigBackupCorruptError
|
||||
);
|
||||
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"live":true}');
|
||||
expect(listConfigBackups(backups)).toHaveLength(1); // no pre-restore snapshot for a refused restore
|
||||
});
|
||||
|
||||
it('rejects unknown ids and anything that is not a snapshot name (no path traversal)', () => {
|
||||
put('settings.json', '{}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
for (const id of ['cfg-19990101-000000', '../data', '..', 'photos', 'cfg-20261001-120000/../../x']) {
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id }), id).toThrow(
|
||||
ConfigBackupNotFoundError
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses the newest backup as the undo copy when the live files already equal it', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
put('settings.json', '{"a":2}');
|
||||
const newest = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + DAY }) as {
|
||||
id: string;
|
||||
};
|
||||
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id: first.id, now: T0 + 2 * DAY });
|
||||
expect(r.safetySnapshot).toBe(newest.id);
|
||||
expect(listConfigBackups(backups).map((b) => b.id)).toEqual([newest.id, first.id]); // no duplicate
|
||||
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"a":1}');
|
||||
});
|
||||
|
||||
it('a file it cannot write does not stop the others, and the error names both lists', () => {
|
||||
put('settings.json', '{"good":true}');
|
||||
put('clis.json', '{"clis":{}}');
|
||||
put('webhook.json', '{"enabled":false}');
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
put('settings.json', '{"polluted":true}');
|
||||
put('webhook.json', '{"enabled":true}');
|
||||
rmSync(join(data, 'clis.json'));
|
||||
mkdirSync(join(data, 'clis.json')); // between the other two in the tracked list
|
||||
let caught: unknown;
|
||||
try {
|
||||
restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id, now: T0 + 1000 });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(ConfigBackupPartialRestoreError);
|
||||
const err = caught as ConfigBackupPartialRestoreError;
|
||||
expect(err.restored).toEqual(['settings.json', 'webhook.json']);
|
||||
expect(err.failed.map((f) => f.name)).toEqual(['clis.json']);
|
||||
expect(err.safetySnapshot).toBeTruthy();
|
||||
expect(readFileSync(join(data, 'webhook.json'), 'utf8')).toBe('{"enabled":false}');
|
||||
expect(readdirSync(data).filter((n) => n.includes('.restore-'))).toEqual([]); // no temp left behind
|
||||
});
|
||||
|
||||
it('only ever writes allowlisted names, even if a manifest lists others', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
const mpath = join(backups, made.id, 'manifest.json');
|
||||
const m = JSON.parse(readFileSync(mpath, 'utf8'));
|
||||
m.files.push({ name: '../escape.json', bytes: 1, sha256: 'x' }, { name: 'state.json', bytes: 1, sha256: 'x' });
|
||||
writeFileSync(mpath, JSON.stringify(m));
|
||||
restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id });
|
||||
expect(existsSync(join(root, 'escape.json'))).toBe(false);
|
||||
expect(existsSync(join(data, 'state.json'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diffConfigBackups', () => {
|
||||
const snap = (now: number) => {
|
||||
const r = createConfigBackup({ dataDir: data, backupDir: backups, reason: 't', force: true, now });
|
||||
if (r.status !== 'created') throw new Error('expected a snapshot');
|
||||
return r.id;
|
||||
};
|
||||
const diff = (from: string, to: string) => diffConfigBackups({ dataDir: data, backupDir: backups, from, to });
|
||||
const file = (d: ReturnType<typeof diff>, name: string) => d.files.find((f) => f.name === name)!;
|
||||
|
||||
it('lists key-level changes between two backups', () => {
|
||||
put('settings.json', JSON.stringify({ displayName: 'a', theme: { mode: 'dark' }, tags: ['x'] }));
|
||||
const a = snap(T0);
|
||||
put('settings.json', JSON.stringify({ displayName: 'b', theme: { mode: 'dark' }, tags: ['x', 'y'], extra: 1 }));
|
||||
const b = snap(T0 + 1000);
|
||||
const f = file(diff(a, b), 'settings.json');
|
||||
expect(f.status).toBe('changed');
|
||||
expect(f.changes).toEqual([
|
||||
{ path: 'displayName', kind: 'changed', before: '"a"', after: '"b"' },
|
||||
{ path: 'extra', kind: 'added', after: '1' },
|
||||
{ path: 'tags[1]', kind: 'added', after: '"y"' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('compares a backup with the running config, and reports files that appeared or vanished', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
put('webhook.json', '{"url":"u"}');
|
||||
const a = snap(T0);
|
||||
put('settings.json', '{"a":2}');
|
||||
rmSync(join(data, 'webhook.json'));
|
||||
put('intents.json', '{"i":true}');
|
||||
const d = diff(a, 'current');
|
||||
expect(d.changed).toBe(3);
|
||||
expect(file(d, 'settings.json').changes).toEqual([{ path: 'a', kind: 'changed', before: '1', after: '2' }]);
|
||||
expect(file(d, 'webhook.json').status).toBe('removed');
|
||||
expect(file(d, 'intents.json').status).toBe('added');
|
||||
expect(diff(a, a).changed).toBe(0);
|
||||
});
|
||||
|
||||
it('never returns a webhook URL, captured prompts, or URL userinfo and query', () => {
|
||||
put('webhook.json', JSON.stringify({ url: 'https://hooks.slack.com/services/T0/B0/oldsecret' }));
|
||||
put('intents.json', JSON.stringify({ profiles: [{ key: 'k', prompts: ['my password is pw1'] }] }));
|
||||
put('settings.json', JSON.stringify({ baseUrl: 'https://user:pw@host/p?token=t1#f' }));
|
||||
const a = snap(T0);
|
||||
put('webhook.json', JSON.stringify({ url: 'https://hooks.slack.com/services/T0/B0/newsecret' }));
|
||||
put('intents.json', JSON.stringify({ profiles: [{ key: 'k', prompts: ['my password is pw2'] }] }));
|
||||
put('settings.json', JSON.stringify({ baseUrl: 'https://user:pw@host/p?token=t2#f' }));
|
||||
const d = diff(a, 'current');
|
||||
const json = JSON.stringify(d);
|
||||
for (const s of ['secret', 'pw1', 'pw2', 'user:pw', 'token=', 'hooks.slack']) expect(json).not.toContain(s);
|
||||
expect(file(d, 'webhook.json').changes.every((c) => c.redacted)).toBe(true);
|
||||
expect(file(d, 'intents.json').changes.every((c) => c.redacted)).toBe(true);
|
||||
});
|
||||
|
||||
it('never returns a credential value, only that it differs', () => {
|
||||
put('.env', 'SECRET_ONE=hunter2\nOTHER=abc\n');
|
||||
put('users.json', JSON.stringify({ admin: { name: 'root' } }));
|
||||
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', apiKey: 'sk-old', url: 'http://x' }]));
|
||||
const a = snap(T0);
|
||||
put('.env', 'SECRET_ONE=hunter3\nOTHER=abd\n');
|
||||
put('users.json', JSON.stringify({ admin: { name: 'rooted' } }));
|
||||
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', apiKey: 'sk-new', url: 'http://y' }]));
|
||||
const d = diff(a, 'current');
|
||||
const json = JSON.stringify(d);
|
||||
for (const secret of ['hunter', 'abc', 'abd', 'root', 'sk-old', 'sk-new']) expect(json).not.toContain(secret);
|
||||
expect(file(d, '.env').changes.every((c) => c.redacted)).toBe(true);
|
||||
expect(file(d, 'custom-model-hosts.json').changes).toEqual([
|
||||
{ path: '[0].apiKey', kind: 'changed', redacted: true },
|
||||
{ path: '[0].url', kind: 'changed', before: '"http://x"', after: '"http://y"' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not count a self-refreshed discovery timestamp as a difference', () => {
|
||||
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', lastDiscoveredAt: 1 }]));
|
||||
const a = snap(T0);
|
||||
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', lastDiscoveredAt: 2 }]));
|
||||
const d = diff(a, 'current');
|
||||
expect(file(d, 'custom-model-hosts.json').status).toBe('unchanged');
|
||||
expect(d.changed).toBe(0);
|
||||
});
|
||||
|
||||
it('reports a file that is not JSON as differing, without its content', () => {
|
||||
put('settings.json', 'not json at all');
|
||||
const a = snap(T0);
|
||||
put('settings.json', 'still not json');
|
||||
const f = file(diff(a, 'current'), 'settings.json');
|
||||
expect(f).toMatchObject({ status: 'changed', detail: 'bytes', changes: [] });
|
||||
});
|
||||
|
||||
it('caps the changes listed per file', () => {
|
||||
put('settings.json', '{}');
|
||||
const a = snap(T0);
|
||||
put('settings.json', JSON.stringify(Object.fromEntries(Array.from({ length: 250 }, (_, i) => [`k${i}`, i]))));
|
||||
const f = file(diff(a, 'current'), 'settings.json');
|
||||
expect(f.changes).toHaveLength(200);
|
||||
expect(f.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses unknown ids, path traversal and a tampered snapshot', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const a = snap(T0);
|
||||
expect(() => diff('cfg-20200101-000000', 'current')).toThrow(ConfigBackupNotFoundError);
|
||||
expect(() => diff('../etc', 'current')).toThrow(ConfigBackupNotFoundError);
|
||||
writeFileSync(join(backups, a, 'settings.json'), '{"a":99}');
|
||||
expect(() => diff(a, 'current')).toThrow(ConfigBackupCorruptError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createConfigBackup: a snapshot dated ahead of the clock', () => {
|
||||
it('is not written again on every pass once the clock is corrected', () => {
|
||||
put('settings.json', '{"v":0}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
put('settings.json', '{"v":1}');
|
||||
const ahead = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2 * 3_600_000 });
|
||||
expect(ahead.status).toBe('created');
|
||||
put('settings.json', '{"v":2}'); // the clock is back at T0 + 1 minute
|
||||
const corrected = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 60_000 });
|
||||
expect(corrected.status).toBe('created');
|
||||
const [newest, second] = listConfigBackups(backups);
|
||||
expect(newest.id).toBe((corrected as { id: string }).id); // dated no earlier than the one before it
|
||||
expect(newest.createdAt).toBeGreaterThan(second.createdAt);
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const r = createConfigBackup({
|
||||
dataDir: data,
|
||||
backupDir: backups,
|
||||
reason: 'auto',
|
||||
now: T0 + 120_000 + i * 300_000,
|
||||
});
|
||||
expect(r.status, `pass ${i}`).toBe('unchanged');
|
||||
}
|
||||
expect(listConfigBackups(backups)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('instances sharing one backup folder', () => {
|
||||
it('keeps to its own snapshots: no churn, no prune, no restore of the other', () => {
|
||||
const other = join(root, 'other-data');
|
||||
mkdirSync(other, { recursive: true });
|
||||
put('settings.json', '{"a":1}');
|
||||
writeFileSync(join(other, 'settings.json'), '{"b":2}');
|
||||
const mine = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
const theirs = createConfigBackup({ dataDir: other, backupDir: backups, reason: 'auto', now: T0 + 1000 });
|
||||
expect(theirs.status).toBe('created');
|
||||
// Each side's change check compares with its own newest snapshot, so a second tick is a no-op.
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
|
||||
'unchanged'
|
||||
);
|
||||
expect(createConfigBackup({ dataDir: other, backupDir: backups, reason: 'auto', now: T0 + 3000 }).status).toBe(
|
||||
'unchanged'
|
||||
);
|
||||
expect(listConfigBackups(backups, configBackupOrigin(data))).toHaveLength(1);
|
||||
expect(
|
||||
pruneConfigBackups(backups, { keepCount: 1, keepDays: 1, now: T0 + 999 * DAY, origin: configBackupOrigin(data) })
|
||||
).toEqual([]);
|
||||
expect(listConfigBackups(backups)).toHaveLength(2);
|
||||
const theirId = (theirs as { id: string }).id;
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: theirId })).toThrow(
|
||||
ConfigBackupNotFoundError
|
||||
);
|
||||
expect((mine as { id: string }).id).not.toBe(theirId);
|
||||
});
|
||||
|
||||
it('a restore reports tracked files it left alone, and sweeps only old temp folders', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
put('webhook.json', '{"url":"u"}');
|
||||
expect(restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id }).untouched).toEqual(['webhook.json']);
|
||||
const stale = join(backups, '.tmp-cfg-20200101-000000-99999');
|
||||
mkdirSync(stale);
|
||||
sweepConfigBackupTemps(backups, Date.now() + 2 * 3_600_000);
|
||||
expect(existsSync(stale)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore trusts only what this module wrote', () => {
|
||||
const made = () =>
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
|
||||
it('refuses a symlinked snapshot file and a snapshot file with group or world bits', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
put('clis.json', '{"clis":{}}');
|
||||
const { id } = made();
|
||||
const file = join(backups, id, 'clis.json');
|
||||
chmodSync(file, 0o644);
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(ConfigBackupCorruptError);
|
||||
|
||||
chmodSync(file, 0o600);
|
||||
const real = join(root, 'elsewhere.json');
|
||||
writeFileSync(real, '{"clis":{}}');
|
||||
rmSync(file);
|
||||
symlinkSync(real, file);
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(/symlink/);
|
||||
// nothing was written and no pre-restore copy was taken for a refused restore
|
||||
expect(listConfigBackups(backups)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips the group/world bit test on a filesystem that does not keep modes, but never the symlink test', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
put('clis.json', '{"clis":{}}');
|
||||
const { id } = made();
|
||||
const file = join(backups, id, 'clis.json');
|
||||
chmodSync(file, 0o644); // what vfat, CIFS and WSL /mnt/d report for every file
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id, keepsModes: false })).not.toThrow();
|
||||
|
||||
const real = join(root, 'elsewhere.json');
|
||||
writeFileSync(real, '{"clis":{}}');
|
||||
rmSync(file);
|
||||
symlinkSync(real, file);
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id, keepsModes: false })).toThrow(/symlink/);
|
||||
});
|
||||
|
||||
it('the mode probe never writes through a planted symlink or trips over a planted directory', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const { id } = made();
|
||||
const target = join(root, 'victim.txt');
|
||||
writeFileSync(target, 'precious', { mode: 0o644 });
|
||||
symlinkSync(target, join(backups, `.mode-probe-${process.pid}`));
|
||||
mkdirSync(join(backups, '.mode-probe-0'));
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).not.toThrow();
|
||||
expect(() => diffConfigBackups({ dataDir: data, backupDir: backups, from: id, to: 'current' })).not.toThrow();
|
||||
expect(readFileSync(target, 'utf8')).toBe('precious');
|
||||
expect(statSync(target).mode & 0o777).toBe(0o644);
|
||||
});
|
||||
|
||||
it('compare applies the same check to a snapshot file', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const { id } = made();
|
||||
chmodSync(join(backups, id, 'settings.json'), 0o644);
|
||||
expect(() => diffConfigBackups({ dataDir: data, backupDir: backups, from: id, to: 'current' })).toThrow(
|
||||
ConfigBackupCorruptError
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a snapshot folder that others can write to', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const { id } = made();
|
||||
chmodSync(join(backups, id), 0o777);
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(ConfigBackupCorruptError);
|
||||
chmodSync(join(backups, id), 0o700);
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps the backup retention when the live settings.json was reset and has none of the four keys', () => {
|
||||
put('settings.json', JSON.stringify({ displayName: 'old', configBackupKeepCount: 100, configBackupKeepDays: 0 }));
|
||||
const { id } = made();
|
||||
put('settings.json', '{}');
|
||||
restoreConfigBackup({ dataDir: data, backupDir: backups, id });
|
||||
expect(JSON.parse(readFileSync(join(data, 'settings.json'), 'utf8'))).toEqual({
|
||||
displayName: 'old',
|
||||
configBackupKeepCount: 100,
|
||||
configBackupKeepDays: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('latestBackupRetention skips a newer snapshot that recorded none, and a pre-restore copy records it', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0, keepCount: 100, keepDays: 0 });
|
||||
put('settings.json', '{"a":2}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }); // an older build
|
||||
const origin = configBackupOrigin(data);
|
||||
expect(latestBackupRetention(backups, origin)).toEqual({ keepCount: 100, keepDays: 0 });
|
||||
const id = listConfigBackups(backups, origin).at(-1)!.id;
|
||||
// A change since the newest backup, so the restore makes a pre-restore copy rather than reusing it.
|
||||
put('settings.json', '{"a":3}');
|
||||
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id, now: T0 + 2000 });
|
||||
expect(r.safetySnapshot).toBeTruthy();
|
||||
const pre = listConfigBackups(backups, origin).find((b) => b.id === r.safetySnapshot)!;
|
||||
expect(pre.reason).toBe('pre-restore');
|
||||
expect(JSON.parse(readFileSync(join(backups, pre.id, 'manifest.json'), 'utf8'))).toMatchObject({
|
||||
keepCount: 100,
|
||||
keepDays: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the current folder and retention when settings.json is restored', () => {
|
||||
put('settings.json', JSON.stringify({ displayName: 'old', configBackupDir: '/old', configBackupKeepCount: 5 }));
|
||||
const { id } = made();
|
||||
put('settings.json', JSON.stringify({ displayName: 'new', configBackupDir: '/new', configBackupKeepDays: 7 }));
|
||||
restoreConfigBackup({ dataDir: data, backupDir: backups, id });
|
||||
const after = JSON.parse(readFileSync(join(data, 'settings.json'), 'utf8'));
|
||||
// the folder and days follow the live file; the live file has no keep count (reset), so the backup's 5 stays
|
||||
expect(after).toEqual({
|
||||
displayName: 'old',
|
||||
configBackupDir: '/new',
|
||||
configBackupKeepCount: 5,
|
||||
configBackupKeepDays: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @fileoverview The global session cap can be set with `CODEMAN_MAX_SESSIONS`.
|
||||
*
|
||||
* Tested through `sessionCapacityState` / `sessionCapacityMessage`, the check every
|
||||
* session-creating route goes through, so a regression in the env parsing OR in
|
||||
* the wiring from `map-limits` to the routes (and to the derived per-user cap in
|
||||
* multi-user mode) fails here.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type Helpers = typeof import('../../src/web/route-helpers.js');
|
||||
|
||||
/** A sessions map of the given size, every session owned by `owner`. */
|
||||
function sessionsOf(n: number, owner?: string): ReadonlyMap<string, never> {
|
||||
return new Map(Array.from({ length: n }, (_, i) => [`s${i}`, { id: `s${i}`, owner } as never]));
|
||||
}
|
||||
|
||||
/** Fresh module graph, so map-limits re-reads the environment at import. */
|
||||
async function loadWithEnv(env: Record<string, string | undefined>): Promise<Helpers> {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('CODEMAN_MAX_SESSIONS', undefined);
|
||||
vi.stubEnv('CODEMAN_MAX_SESSIONS_PER_USER', undefined);
|
||||
vi.stubEnv('CODEMAN_MULTIUSER', undefined);
|
||||
for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value);
|
||||
return import('../../src/web/route-helpers.js');
|
||||
}
|
||||
|
||||
describe('global session cap (CODEMAN_MAX_SESSIONS)', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('stays at 50 when the variable is not set', async () => {
|
||||
const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({});
|
||||
expect(sessionCapacityState(sessionsOf(49)).atGlobalCap).toBe(false);
|
||||
expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(true);
|
||||
expect(sessionCapacityMessage(sessionsOf(50))).toContain('(50)');
|
||||
});
|
||||
|
||||
it('raises the cap', async () => {
|
||||
const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: '120' });
|
||||
expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(false);
|
||||
expect(sessionCapacityState(sessionsOf(119)).atGlobalCap).toBe(false);
|
||||
expect(sessionCapacityState(sessionsOf(120)).atGlobalCap).toBe(true);
|
||||
expect(sessionCapacityMessage(sessionsOf(120))).toContain('(120)');
|
||||
});
|
||||
|
||||
it('lowers the cap, ignoring surrounding whitespace', async () => {
|
||||
const { sessionCapacityState } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: ' 3 ' });
|
||||
expect(sessionCapacityState(sessionsOf(2)).atGlobalCap).toBe(false);
|
||||
expect(sessionCapacityState(sessionsOf(3)).atGlobalCap).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['1000', '500', '463', '99999999999999999999'])('clamps %j to the ceiling of 462', async (value) => {
|
||||
const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value });
|
||||
expect(sessionCapacityState(sessionsOf(461)).atGlobalCap).toBe(false);
|
||||
expect(sessionCapacityState(sessionsOf(462)).atGlobalCap).toBe(true);
|
||||
expect(sessionCapacityMessage(sessionsOf(462))).toContain('(462)');
|
||||
});
|
||||
|
||||
it('keeps the ceiling inside the tab-layout budget next to the saved web tabs', async () => {
|
||||
vi.resetModules();
|
||||
const { MAX_CONCURRENT_SESSIONS_CEILING } = await import('../../src/config/map-limits.js');
|
||||
const { MAX_WEBVIEWS } = await import('../../src/config/webview-limits.js');
|
||||
const { MAX_TAB_REFS } = await import('../../src/tab-layout.js');
|
||||
expect(MAX_CONCURRENT_SESSIONS_CEILING + MAX_WEBVIEWS).toBeLessThanOrEqual(MAX_TAB_REFS);
|
||||
});
|
||||
|
||||
it.each(['', '0', '-5', '2.5', 'abc', '1e3'])('falls back to 50 for an unusable value %j', async (value) => {
|
||||
const { sessionCapacityState } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value });
|
||||
expect(sessionCapacityState(sessionsOf(49)).atGlobalCap).toBe(false);
|
||||
expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(true);
|
||||
});
|
||||
|
||||
it('derives the multi-user per-user cap (half the global cap) from it', async () => {
|
||||
const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({
|
||||
CODEMAN_MAX_SESSIONS: '100',
|
||||
CODEMAN_MULTIUSER: '1',
|
||||
});
|
||||
expect(sessionCapacityState(sessionsOf(49, 'alice'), 'alice').atUserCap).toBe(false);
|
||||
expect(sessionCapacityState(sessionsOf(50, 'alice'), 'alice')).toEqual({ atGlobalCap: false, atUserCap: true });
|
||||
expect(sessionCapacityMessage(sessionsOf(50, 'alice'), 'alice')).toContain('(50)');
|
||||
});
|
||||
|
||||
it('leaves an explicit CODEMAN_MAX_SESSIONS_PER_USER in charge of the per-user cap', async () => {
|
||||
const { sessionCapacityState } = await loadWithEnv({
|
||||
CODEMAN_MAX_SESSIONS: '100',
|
||||
CODEMAN_MAX_SESSIONS_PER_USER: '10',
|
||||
CODEMAN_MULTIUSER: '1',
|
||||
});
|
||||
expect(sessionCapacityState(sessionsOf(9, 'alice'), 'alice').atUserCap).toBe(false);
|
||||
expect(sessionCapacityState(sessionsOf(10, 'alice'), 'alice').atUserCap).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -158,10 +158,73 @@ describe('connection indicator — keystroke backlog threshold', () => {
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
});
|
||||
|
||||
it('annotates a genuine backlog (>4B) with a queued byte count', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
|
||||
it('annotates a genuine backlog (>4B) with a queued byte count once it has persisted', () => {
|
||||
const { app, els } = makeApp({
|
||||
activeSessionId: 's1',
|
||||
_wsState: 'connected',
|
||||
queuedBytes: 40,
|
||||
// The backlog has been above the threshold for well over the hold.
|
||||
_backlogOverSince: Date.now() - 5000,
|
||||
} as never);
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toMatch(/^WS · 40B queued$/);
|
||||
app._pendingDeliveries.clear();
|
||||
app._updateConnectionIndicator(); // drains: clears the hold timer
|
||||
});
|
||||
});
|
||||
|
||||
// A paste, an IME commit or a fast word is a few dozen bytes for a few milliseconds until its ACK
|
||||
// lands. Showing " · NB queued" for that made the "WS" pill widen and shrink on every burst.
|
||||
describe('connection indicator — a brief burst does not widen the pill', () => {
|
||||
it('keeps the plain "WS" while a >4B burst is still inside the hold window', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
app._pendingDeliveries.clear();
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
});
|
||||
|
||||
it('forgets the burst when it drains, so a later one starts a fresh hold', () => {
|
||||
const { app } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
|
||||
app._updateConnectionIndicator();
|
||||
expect((app as unknown as { _backlogOverSince: number | null })._backlogOverSince).not.toBeNull();
|
||||
app._pendingDeliveries.clear();
|
||||
app._updateConnectionIndicator();
|
||||
expect((app as unknown as { _backlogOverSince: number | null })._backlogOverSince).toBeNull();
|
||||
expect((app as unknown as { _backlogHoldTimer: unknown })._backlogHoldTimer).toBeNull();
|
||||
});
|
||||
|
||||
it('schedules the re-render for when the hold elapses, with no keystroke or ACK needed', () => {
|
||||
const { app } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
|
||||
app._updateConnectionIndicator();
|
||||
expect((app as unknown as { _backlogHoldTimer: unknown })._backlogHoldTimer).not.toBeNull();
|
||||
app._pendingDeliveries.clear();
|
||||
app._updateConnectionIndicator(); // cancels it
|
||||
});
|
||||
|
||||
it('shows the count by itself once the hold elapses, and the timer is spent', async () => {
|
||||
// A real wait, on purpose: the vm context runs on its own Date and the real setTimeout, so this
|
||||
// exercises the shipped timer callback rather than a manual re-render.
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
await new Promise((r) => setTimeout(r, 1400));
|
||||
expect(els.connectionText.textContent).toBe('WS · 40B queued');
|
||||
expect((app as unknown as { _backlogHoldTimer: unknown })._backlogHoldTimer).toBeNull();
|
||||
app._pendingDeliveries.clear();
|
||||
app._updateConnectionIndicator();
|
||||
});
|
||||
|
||||
it('shows the count once the same backlog has stayed up past the hold', () => {
|
||||
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
(app as unknown as { _backlogOverSince: number })._backlogOverSince = Date.now() - 1500;
|
||||
app._updateConnectionIndicator();
|
||||
expect(els.connectionText.textContent).toBe('WS · 40B queued');
|
||||
app._pendingDeliveries.clear();
|
||||
app._updateConnectionIndicator();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -458,3 +521,4 @@ describe('_updateConnectionIndicator — COD-136 unchanged-skip', () => {
|
||||
expect(els.connectionText.textContent).toBe('WS');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @fileoverview Tests for the GitHub Copilot CLI resolver wrapper.
|
||||
*
|
||||
* Copilot is a resolver with a version probe: `copilot` is a generic binary name, so a
|
||||
* resolved path is only accepted once `copilot --version` prints a
|
||||
* `GitHub Copilot CLI <semver>` string (e.g. `GitHub Copilot CLI 1.0.94.`). The probe EXECUTES the candidate, which is
|
||||
* exactly why it must never run under vitest — the hermeticity test below pins
|
||||
* that gate with a real executable fixture that would make the test fail
|
||||
* loudly if the gate were deleted again.
|
||||
*/
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createCopilotResolverForTest } from '../src/utils/copilot-cli-resolver.js';
|
||||
import {
|
||||
cliResolveRetryDelayMs,
|
||||
createProductionCliResolverHost,
|
||||
type CliResolverHost,
|
||||
} from '../src/utils/cli-executable-resolver.js';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createHost(
|
||||
options: {
|
||||
processPathResult?: string | null;
|
||||
loginShellResults?: Array<string | null>;
|
||||
existingPaths?: string[];
|
||||
} = {}
|
||||
): CliResolverHost {
|
||||
const loginShellResults = [...(options.loginShellResults ?? [])];
|
||||
const existingPaths = new Set(options.existingPaths ?? []);
|
||||
return {
|
||||
processPath: '/service/bin',
|
||||
shellPath: '/bin/zsh',
|
||||
shellArgs: ['-l'],
|
||||
findOnProcessPath: () => options.processPathResult ?? null,
|
||||
findInLoginShell: () => loginShellResults.shift() ?? null,
|
||||
exists: (path) => existingPaths.has(path),
|
||||
};
|
||||
}
|
||||
|
||||
describe('GitHub Copilot CLI resolver', () => {
|
||||
it('accepts a candidate the version probe verifies and carries the version as metadata', () => {
|
||||
const binaryPath = '/service/bin/copilot';
|
||||
const probe = vi.fn(() => '1.0.94');
|
||||
const resolver = createCopilotResolverForTest(
|
||||
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
|
||||
probe
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({
|
||||
binaryPath,
|
||||
directory: '/service/bin',
|
||||
source: 'process-path',
|
||||
metadata: '1.0.94',
|
||||
});
|
||||
expect(probe).toHaveBeenCalledWith(binaryPath);
|
||||
});
|
||||
|
||||
it('rejects a candidate the probe refuses and falls through to a later one', () => {
|
||||
// An unrelated `copilot` on the service PATH (probe returns null) must not mask
|
||||
// the real coding agent found by the login shell.
|
||||
const impostor = '/service/bin/copilot';
|
||||
const genuine = '/login-shell/bin/copilot';
|
||||
const probe = vi.fn((binPath: string) => (binPath === genuine ? '1.0.94' : null));
|
||||
const resolver = createCopilotResolverForTest(
|
||||
createHost({
|
||||
processPathResult: impostor,
|
||||
loginShellResults: [genuine],
|
||||
existingPaths: [impostor, genuine],
|
||||
}),
|
||||
probe
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '1.0.94' });
|
||||
});
|
||||
|
||||
it('negative-caches a miss and retries only after the backoff elapses', () => {
|
||||
const binaryPath = '/late/bin/copilot';
|
||||
let now = 0;
|
||||
const probe = vi.fn(() => '1.0.94');
|
||||
const resolver = createCopilotResolverForTest(
|
||||
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
|
||||
probe,
|
||||
() => now
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
now = cliResolveRetryDelayMs(1);
|
||||
expect(resolver.resolve()?.metadata).toBe('1.0.94');
|
||||
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
||||
});
|
||||
|
||||
it('never executes a copilot candidate under vitest (the ambient probe is VITEST-gated)', () => {
|
||||
// A REAL executable fixture that prints a valid version. If the guard in
|
||||
// probeCopilotVersion is ever removed again, the probe runs this script, the
|
||||
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
|
||||
// behavior rather than by source text. (The suites must never execute
|
||||
// whatever `copilot` binary the machine running them happens to carry.)
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeman-copilot-vitest-gate-'));
|
||||
temporaryDirectories.push(root);
|
||||
const binaryPath = join(root, 'copilot');
|
||||
writeFileSync(binaryPath, '#!/bin/sh\necho GitHub Copilot CLI 0.99.0.\n');
|
||||
chmodSync(binaryPath, 0o755);
|
||||
const hostOptions = {
|
||||
processPath: root,
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'] as string[],
|
||||
runCommand: () => '',
|
||||
isExecutableFile: (path: string) => path === binaryPath,
|
||||
};
|
||||
|
||||
// Default (ambient) probe: the candidate is found but never executed, so
|
||||
// the VITEST gate reports it unusable and resolution misses.
|
||||
const gated = createCopilotResolverForTest(createProductionCliResolverHost(hostOptions));
|
||||
expect(gated.resolve()).toBeNull();
|
||||
|
||||
// Control: identical setup with an injected probe resolves, proving the
|
||||
// null above comes from the gate, not from the fixture or the host.
|
||||
const control = createCopilotResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0');
|
||||
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @fileoverview copilotConfig on the session create body: `model` and `resumeSessionId` follow a flag
|
||||
* (`--model`, `--resume`), so a value that starts with `-` would be read as its own flag and get
|
||||
* around the allowAll clamp. Both need a leading letter or digit.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CreateSessionSchema } from '../src/web/schemas.js';
|
||||
|
||||
const parse = (copilotConfig: unknown) =>
|
||||
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'copilot', copilotConfig });
|
||||
|
||||
describe('copilotConfig schema', () => {
|
||||
it.each(['--yolo', '-n', '--allow-all-tools', '.hidden', '/etc/passwd'])('rejects model %s', (model) => {
|
||||
expect(parse({ model }).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['--yolo', '-n', '--allow-all-tools'])('rejects resumeSessionId %s', (resumeSessionId) => {
|
||||
expect(parse({ resumeSessionId }).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['auto', 'claude-sonnet-5.5', 'openai/gpt-5'])('accepts model %s', (model) => {
|
||||
expect(parse({ model }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
import { _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
|
||||
import { CreateSessionSchema } from '../src/web/schemas.js';
|
||||
|
||||
const copilot = STOCK_CLIS.find((c) => (c.id as string) === 'copilot')!;
|
||||
|
||||
describe('GitHub Copilot custom endpoint (BYOK)', () => {
|
||||
it('injects its COPILOT_PROVIDER_* variables, with the /v1 suffix an OpenAI route needs', () => {
|
||||
expect(copilot.capabilities.customModelInjection).toEqual({
|
||||
kind: 'env',
|
||||
baseUrlVar: 'COPILOT_PROVIDER_BASE_URL',
|
||||
apiKeyVar: 'COPILOT_PROVIDER_API_KEY',
|
||||
modelVars: ['COPILOT_MODEL'],
|
||||
appendV1Suffix: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps every injected variable privileged, so a non-granted owner cannot set them by hand', () => {
|
||||
const injection = copilot.capabilities.customModelInjection;
|
||||
if (injection.kind !== 'env') throw new Error('expected env injection');
|
||||
for (const name of [injection.baseUrlVar, injection.apiKeyVar, ...injection.modelVars]) {
|
||||
expect(copilot.capabilities.privilegedEnvKeys).toContain(name);
|
||||
expect(name.startsWith('COPILOT_')).toBe(true); // reachable through the prefix allowlist
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub Copilot environment and sync declarations', () => {
|
||||
it('admits only its own COPILOT_ namespace, never the shared GitHub token names', () => {
|
||||
// The env allowlist is one global list with no mode context, so a foreign key here would be
|
||||
// settable on every session. COPILOT_GITHUB_TOKEN is already reachable through the prefix.
|
||||
expect(copilot.env.allowedPrefixes).toEqual(['COPILOT_']);
|
||||
expect(copilot.env.allowedKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it('declares its own MCP config so Settings does not list it as unsupported', () => {
|
||||
expect(copilot.capabilities.mcpConfig).toEqual({
|
||||
path: '.copilot/mcp-config.json',
|
||||
format: 'copilot-json',
|
||||
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds no credentials into a Docker case', () => {
|
||||
expect(copilot.overlays?.credStore).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub Copilot multi-user clamp: the env-var half', () => {
|
||||
// COPILOT_ is an allowlisted prefix, so every variable below would otherwise pass the schema.
|
||||
// Each one hands back what the allowAll clamp removed: an approval switch, code that runs with no
|
||||
// approval row (a CLI build, an editor, a key command, skills, hooks), or the sign-in token sent to
|
||||
// a host of the caller's choosing.
|
||||
const DANGEROUS: Record<string, string> = {
|
||||
COPILOT_ALLOW_ALL: 'true',
|
||||
COPILOT_PLAN_THEN_AUTOPILOT: '1',
|
||||
COPILOT_HOME: '/tmp/planted-copilot-home',
|
||||
COPILOT_CLI_DIST_DIR: '/tmp/planted-dist',
|
||||
COPILOT_PKG_CACHE_HOME: '/tmp/planted-cache',
|
||||
COPILOT_CACHE_HOME: '/tmp/planted-cache',
|
||||
COPILOT_EDITOR: 'sh -c planted',
|
||||
COPILOT_PROVIDER_API_KEY_COMMAND: 'sh -c planted',
|
||||
COPILOT_SKILLS_DIRS: '/tmp/planted-skills',
|
||||
COPILOT_PROVIDERS_CONFIG: '/tmp/planted-providers.json',
|
||||
COPILOT_API_URL: 'https://attacker.example',
|
||||
COPILOT_GH_HOST: 'attacker.example',
|
||||
COPILOT_PROVIDER_BASE_URL: 'https://attacker.example/v1',
|
||||
COPILOT_PROVIDER_HEADERS: 'X-Planted: 1',
|
||||
};
|
||||
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
|
||||
beforeEach(() => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
});
|
||||
afterEach(() => {
|
||||
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
|
||||
});
|
||||
|
||||
it('passes the env allowlist, so only the clamp stands between these and the pane', () => {
|
||||
expect(() =>
|
||||
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'copilot', envOverrides: DANGEROUS })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("strips every one of them from a non-granted owner's overrides and keeps the harmless knobs", async () => {
|
||||
const out = await _clampEnvOverridesForOwner('nobody', {
|
||||
...DANGEROUS,
|
||||
COPILOT_GITHUB_TOKEN: 'own-token',
|
||||
COPILOT_AUTO_UPDATE: 'false',
|
||||
});
|
||||
expect(out).toEqual({ COPILOT_GITHUB_TOKEN: 'own-token', COPILOT_AUTO_UPDATE: 'false' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* @fileoverview GitHub Copilot's monthly premium-request quota for the shared plan-usage chip:
|
||||
* the quota parser, the sign-in token lookup and the read-only HTTP reader.
|
||||
*
|
||||
* The fixture is the shape `GET /copilot_internal/user` returned for a Business seat (values rounded);
|
||||
* `chat` and `completions` are `unlimited`, only `premium_interactions` is metered.
|
||||
*/
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { parseCopilotQuotaResponse, telemetrySignature } from '../src/usage-telemetry.js';
|
||||
import {
|
||||
COPILOT_QUOTA_URL,
|
||||
readCopilotPlanUsage,
|
||||
readCopilotPlanUsageIfEnabled,
|
||||
resolveCopilotToken,
|
||||
} from '../src/utils/copilot-plan-usage.js';
|
||||
|
||||
const RESPONSE = {
|
||||
copilot_plan: 'business',
|
||||
quota_reset_date_utc: '2026-11-01T00:00:00.000Z',
|
||||
quota_reset_date: '2026-11-01',
|
||||
quota_snapshots: {
|
||||
chat: { entitlement: 0, remaining: 0, percent_remaining: 100, unlimited: true },
|
||||
completions: { entitlement: 0, remaining: 0, percent_remaining: 100, unlimited: true },
|
||||
premium_interactions: {
|
||||
entitlement: 5000,
|
||||
remaining: 3785,
|
||||
percent_remaining: 75.7,
|
||||
unlimited: false,
|
||||
overage_permitted: true,
|
||||
overage_count: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('parseCopilotQuotaResponse', () => {
|
||||
it('maps premium_interactions to a monthly window with counts and the reset instant', () => {
|
||||
expect(parseCopilotQuotaResponse(RESPONSE)).toEqual({
|
||||
monthly: {
|
||||
usedPercentage: expect.closeTo(24.3, 5),
|
||||
resetAt: Date.parse('2026-11-01T00:00:00.000Z'),
|
||||
used: 1215,
|
||||
limit: 5000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores the unlimited chat and completions buckets entirely', () => {
|
||||
const only = { quota_snapshots: { chat: RESPONSE.quota_snapshots.chat } };
|
||||
expect(parseCopilotQuotaResponse(only)).toBeNull();
|
||||
});
|
||||
|
||||
it('has nothing to meter for an unlimited premium quota', () => {
|
||||
const unlimited = {
|
||||
quota_snapshots: {
|
||||
premium_interactions: { entitlement: 0, remaining: 0, percent_remaining: 100, unlimited: true },
|
||||
},
|
||||
};
|
||||
expect(parseCopilotQuotaResponse(unlimited)).toBeNull();
|
||||
});
|
||||
|
||||
it('derives the percent from the counts when percent_remaining is missing', () => {
|
||||
const result = parseCopilotQuotaResponse({
|
||||
quota_snapshots: { premium_interactions: { entitlement: 300, remaining: 75 } },
|
||||
quota_reset_date: '2026-11-01',
|
||||
});
|
||||
expect(result?.monthly?.usedPercentage).toBe(75);
|
||||
expect(result?.monthly?.used).toBe(225);
|
||||
});
|
||||
|
||||
it('clamps overage to 100% and keeps the real count', () => {
|
||||
const result = parseCopilotQuotaResponse({
|
||||
quota_snapshots: { premium_interactions: { entitlement: 300, remaining: -40, percent_remaining: -13.3 } },
|
||||
});
|
||||
expect(result?.monthly?.usedPercentage).toBe(100);
|
||||
expect(result?.monthly?.used).toBe(340);
|
||||
});
|
||||
|
||||
it('rejects anything it cannot read as a quota', () => {
|
||||
for (const bad of [
|
||||
null,
|
||||
undefined,
|
||||
'x',
|
||||
3,
|
||||
{},
|
||||
{ quota_snapshots: {} },
|
||||
{ quota_snapshots: { premium_interactions: 'x' } },
|
||||
]) {
|
||||
expect(parseCopilotQuotaResponse(bad)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns nothing for a zero quota that is not unlimited', () => {
|
||||
expect(
|
||||
parseCopilotQuotaResponse({
|
||||
quota_snapshots: { premium_interactions: { entitlement: 0, remaining: 0, percent_remaining: 0 } },
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('changes the signature when only the limit changes (a plan change mid-month)', () => {
|
||||
// 100 requests used and the same reported percentage under both plans: only the entitlement differs.
|
||||
const quota = (entitlement: number) =>
|
||||
parseCopilotQuotaResponse({
|
||||
quota_snapshots: {
|
||||
premium_interactions: { entitlement, remaining: entitlement - 100, percent_remaining: 66.7 },
|
||||
},
|
||||
quota_reset_date_utc: '2026-11-01T00:00:00.000Z',
|
||||
})!;
|
||||
expect(telemetrySignature(quota(300))).not.toBe(telemetrySignature(quota(1500)));
|
||||
});
|
||||
|
||||
it('feeds the change-detection signature without disturbing the Claude/Codex one', () => {
|
||||
const copilot = parseCopilotQuotaResponse(RESPONSE)!;
|
||||
expect(telemetrySignature(copilot)).not.toBe(telemetrySignature({}));
|
||||
// A Claude-only payload has exactly the four-element signature it always had.
|
||||
expect(JSON.parse(telemetrySignature({ fiveHour: { usedPercentage: 10, resetAt: 1 } }))).toEqual([
|
||||
10,
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCopilotToken', () => {
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
const home = (config?: string) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'copilot-token-'));
|
||||
dirs.push(dir);
|
||||
if (config !== undefined) {
|
||||
mkdirSync(join(dir, '.copilot'));
|
||||
writeFileSync(join(dir, '.copilot', 'config.json'), config);
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
const CONFIG = [
|
||||
'// User settings belong in settings.json.',
|
||||
JSON.stringify({
|
||||
lastLoggedInUser: { host: 'https://github.com', login: 'octo' },
|
||||
authTokens: {
|
||||
'https://github.com:octo': { token: 'file-token' },
|
||||
'https://github.com:other': { token: 'other-token' },
|
||||
},
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
it('prefers the environment, in the order Copilot itself reads it', async () => {
|
||||
const h = home(CONFIG);
|
||||
await expect(resolveCopilotToken({ GITHUB_TOKEN: 'c', GH_TOKEN: 'b', COPILOT_GITHUB_TOKEN: 'a' }, h)).resolves.toBe(
|
||||
'a'
|
||||
);
|
||||
await expect(resolveCopilotToken({ GITHUB_TOKEN: 'c', GH_TOKEN: 'b' }, h)).resolves.toBe('b');
|
||||
await expect(resolveCopilotToken({ GITHUB_TOKEN: 'c' }, h)).resolves.toBe('c');
|
||||
});
|
||||
|
||||
it("falls back to the signed-in user's token in the commented config.json", async () => {
|
||||
await expect(resolveCopilotToken({}, home(CONFIG))).resolves.toBe('file-token');
|
||||
});
|
||||
|
||||
it('honours COPILOT_HOME', async () => {
|
||||
const elsewhere = home();
|
||||
mkdirSync(join(elsewhere, 'cp'));
|
||||
writeFileSync(join(elsewhere, 'cp', 'config.json'), CONFIG.replace('file-token', 'relocated'));
|
||||
await expect(resolveCopilotToken({ COPILOT_HOME: join(elsewhere, 'cp') }, home())).resolves.toBe('relocated');
|
||||
});
|
||||
|
||||
it('reads no file token for another host, a missing file or a broken one', async () => {
|
||||
const ghe = CONFIG.replace('"host":"https://github.com"', '"host":"https://ghe.example.com"');
|
||||
await expect(resolveCopilotToken({}, home(ghe))).resolves.toBeNull();
|
||||
await expect(resolveCopilotToken({}, home())).resolves.toBeNull();
|
||||
await expect(resolveCopilotToken({}, home('{ not json'))).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readCopilotPlanUsage', () => {
|
||||
const ok = (body: unknown, status = 200) =>
|
||||
vi.fn(async () => new Response(JSON.stringify(body), { status })) as unknown as typeof fetch;
|
||||
|
||||
it('asks api.github.com with the bearer token, no redirects, and normalizes the answer', async () => {
|
||||
const fetchFn = ok(RESPONSE);
|
||||
const usage = await readCopilotPlanUsage({ token: 'tok', fetchFn });
|
||||
expect(usage?.monthly?.limit).toBe(5000);
|
||||
const [url, init] = (fetchFn as unknown as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(COPILOT_QUOTA_URL);
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer tok');
|
||||
expect(init.redirect).toBe('error');
|
||||
});
|
||||
|
||||
it('resolves the token itself when none is passed, and sends it in the header', async () => {
|
||||
vi.stubEnv('COPILOT_GITHUB_TOKEN', 'env-token');
|
||||
try {
|
||||
const fetchFn = ok(RESPONSE);
|
||||
expect(await readCopilotPlanUsage({ fetchFn })).not.toBeNull();
|
||||
const init = (fetchFn as unknown as ReturnType<typeof vi.fn>).mock.calls[0][1] as {
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
expect(init.headers.Authorization).toBe('Bearer env-token');
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends nothing when there is no token', async () => {
|
||||
const fetchFn = ok(RESPONSE);
|
||||
expect(await readCopilotPlanUsage({ token: null, fetchFn })).toBeNull();
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers null, never throws, on an HTTP error, a network failure or a changed shape', async () => {
|
||||
expect(await readCopilotPlanUsage({ token: 't', fetchFn: ok({ message: 'Bad credentials' }, 401) })).toBeNull();
|
||||
expect(await readCopilotPlanUsage({ token: 't', fetchFn: ok({ unexpected: true }) })).toBeNull();
|
||||
const down = vi.fn(async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
}) as unknown as typeof fetch;
|
||||
expect(await readCopilotPlanUsage({ token: 't', fetchFn: down })).toBeNull();
|
||||
});
|
||||
|
||||
it('never puts the token in anything it returns', async () => {
|
||||
const usage = await readCopilotPlanUsage({ token: 'super-secret-token', fetchFn: ok(RESPONSE) });
|
||||
expect(JSON.stringify(usage)).not.toContain('super-secret-token');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The server's 10-minute poll goes through these gates in this order: Plan Usage Limits on, then a
|
||||
* sign-in token, then the CLI installed, so a host with no Copilot sign-in never runs the CLI
|
||||
* resolver (whose last step is a login-shell probe) on every tick.
|
||||
*/
|
||||
describe('readCopilotPlanUsageIfEnabled', () => {
|
||||
const gates = (over: Partial<Parameters<typeof readCopilotPlanUsageIfEnabled>[0]> = {}) => {
|
||||
const fetchFn = vi.fn(
|
||||
async () => new Response(JSON.stringify(RESPONSE), { status: 200 })
|
||||
) as unknown as typeof fetch;
|
||||
const deps = {
|
||||
planUsageEnabled: vi.fn(async () => true),
|
||||
resolveToken: vi.fn(async (): Promise<string | null> => 'tok'),
|
||||
copilotInstalled: vi.fn(() => true),
|
||||
read: vi.fn((token: string) => readCopilotPlanUsage({ token, fetchFn })),
|
||||
...over,
|
||||
};
|
||||
return { deps, fetchFn };
|
||||
};
|
||||
|
||||
it('sends no request, and looks for no token or CLI, while Plan Usage Limits is off', async () => {
|
||||
const { deps, fetchFn } = gates({ planUsageEnabled: vi.fn(async () => false) });
|
||||
expect(await readCopilotPlanUsageIfEnabled(deps)).toBeNull();
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
expect(deps.read).not.toHaveBeenCalled();
|
||||
expect(deps.resolveToken).not.toHaveBeenCalled();
|
||||
expect(deps.copilotInstalled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never probes for the CLI on a host with no Copilot sign-in', async () => {
|
||||
const { deps, fetchFn } = gates({ resolveToken: vi.fn(async () => null) });
|
||||
expect(await readCopilotPlanUsageIfEnabled(deps)).toBeNull();
|
||||
expect(deps.copilotInstalled).not.toHaveBeenCalled();
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends no request when the CLI is not installed, whatever token the environment holds', async () => {
|
||||
const { deps, fetchFn } = gates({ copilotInstalled: vi.fn(() => false) });
|
||||
expect(await readCopilotPlanUsageIfEnabled(deps)).toBeNull();
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the quota with the resolved token once every gate passes', async () => {
|
||||
const { deps, fetchFn } = gates();
|
||||
expect((await readCopilotPlanUsageIfEnabled(deps))?.monthly?.limit).toBe(5000);
|
||||
expect(deps.read).toHaveBeenCalledWith('tok');
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/** @fileoverview Guards cron docs against drift from routes, schema, statuses, and UI entry points. */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CronJobSchema } from '../src/web/schemas.js';
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
const reference = read('docs/api-reference.md');
|
||||
const wiki = read('docs/wiki/HTTP-API.md');
|
||||
const guide = read('docs/wiki/Cron-Jobs.md');
|
||||
|
||||
const cronSection = (text: string) => text.split('## Cron jobs\n')[1]?.split('\n## ')[0] ?? '';
|
||||
|
||||
describe('cron documentation', () => {
|
||||
it('documents every cron route in both API references', () => {
|
||||
const routes = [...read('src/web/routes/cron-routes.ts').matchAll(/app\.(get|post|put|delete)\('([^']+)'/g)];
|
||||
expect(routes).toHaveLength(9);
|
||||
for (const [, method, path] of routes) {
|
||||
const row = `| ${method.toUpperCase()} | \`${path.replace('/api/', '/api/v1/')}\` |`;
|
||||
expect(cronSection(reference)).toContain(row);
|
||||
expect(cronSection(wiki)).toContain(row);
|
||||
}
|
||||
});
|
||||
|
||||
it('documents every accepted job field and validates the guide example', () => {
|
||||
for (const field of Object.keys(CronJobSchema.shape)) {
|
||||
expect(cronSection(reference)).toContain(`| \`${field}\` |`);
|
||||
}
|
||||
const example = guide.match(/-d '(\{[\s\S]*?\})'/)?.[1];
|
||||
expect(example).toBeDefined();
|
||||
expect(CronJobSchema.safeParse(JSON.parse(example!)).success).toBe(true);
|
||||
});
|
||||
|
||||
it('documents all run statuses without treating prompt delivery as task success', () => {
|
||||
const statusType = read('src/types/cron.ts').match(/export type CronJobRunStatus = ([^;]+);/)?.[1];
|
||||
expect(statusType).toBeDefined();
|
||||
for (const [, status] of statusType!.matchAll(/'([^']+)'/g)) {
|
||||
expect(guide).toContain(`| \`${status}\``);
|
||||
expect(cronSection(reference)).toContain(`\`${status}\``);
|
||||
}
|
||||
expect(guide.replace(/\s+/g, ' ')).toContain("not whether the agent's task succeeded");
|
||||
expect(guide).toContain('bottom toolbar');
|
||||
expect(guide).toContain('App Settings → Header & Panels → Scheduling');
|
||||
});
|
||||
|
||||
it('keeps linked pages consistent and explains non-terminal launch history', () => {
|
||||
expect(read('docs/wiki/The-Dashboard.md')).toMatch(/\| Cron\s*\| Bottom toolbar/);
|
||||
expect(read('docs/cron-guide.md')).not.toMatch(/Cron\*\* (?:button )?in the header/);
|
||||
for (const text of [reference, guide]) {
|
||||
const normalized = text.replace(/\s+/g, ' ');
|
||||
expect(normalized).toContain('session is closed during the readiness wait');
|
||||
expect(normalized).toContain('server restarts before delivery');
|
||||
expect(normalized).toContain('`session_started` indefinitely with `finishedAt: null`');
|
||||
}
|
||||
expect(reference).toContain("also closes the previous run's session before launching");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @fileoverview Ctrl+W never closes a session; it reaches the terminal.
|
||||
*
|
||||
* "Close Session" used to be bound to Ctrl+W by default. Ctrl+W is "delete the
|
||||
* previous word" in every shell, readline prompt and agent CLI, so muscle memory
|
||||
* killed the session (its tmux pane and CLI, with no confirm) in the middle of a
|
||||
* sentence; with the split pane open it was not even the pane being typed in.
|
||||
* The action now has NO default key: the capture-phase shortcut handler lets
|
||||
* Ctrl+W through, and xterm sends ^W to the CLI like any other key. The action
|
||||
* stays in the registry so a user can still bind a key to it in App Settings →
|
||||
* Shortcuts.
|
||||
*
|
||||
* Real code under test: constants.js + app.js (DEFAULT_SHORTCUTS,
|
||||
* getShortcutRegistry, matchesShortcutEvent) in a `vm` context.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||
|
||||
type Shortcut = { id: string; action?: string; bindings?: unknown[]; disabled?: boolean };
|
||||
type App = {
|
||||
getShortcutRegistry(): Shortcut[];
|
||||
matchesShortcutEvent(e: Record<string, unknown>, s: Shortcut): boolean;
|
||||
loadAppSettingsFromStorage: () => Record<string, unknown>;
|
||||
};
|
||||
|
||||
function makeApp(settings: Record<string, unknown> = {}): App {
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: { OPEN: 1 },
|
||||
fetch: vi.fn(),
|
||||
document: { addEventListener: vi.fn() },
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: {},
|
||||
});
|
||||
vm.runInContext(`${read('constants.js')}\n${read('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
||||
const app = Object.create(CodemanApp.prototype) as App;
|
||||
app.loadAppSettingsFromStorage = () => settings;
|
||||
return app;
|
||||
}
|
||||
|
||||
const keydown = (overrides: Record<string, unknown>) => ({
|
||||
type: 'keydown',
|
||||
key: 'w',
|
||||
code: 'KeyW',
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Ctrl+W is left to the terminal', () => {
|
||||
it('Close Session is still in the registry, with no default key', () => {
|
||||
const close = makeApp()
|
||||
.getShortcutRegistry()
|
||||
.find((s) => s.id === 'close-session');
|
||||
|
||||
expect(close?.action).toBe('killActiveSession');
|
||||
expect(close?.bindings).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Ctrl+W', { ctrlKey: true }],
|
||||
['Cmd+W', { metaKey: true }],
|
||||
])('no default shortcut answers %s, so the capture handler lets it reach xterm', (_name, mods) => {
|
||||
const app = makeApp();
|
||||
const event = keydown(mods);
|
||||
|
||||
const matched = app.getShortcutRegistry().filter((s) => !s.disabled && s.action && app.matchesShortcutEvent(event, s));
|
||||
|
||||
expect(matched.map((s) => s.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('a user can still bind a key to Close Session', () => {
|
||||
const app = makeApp({
|
||||
shortcutOverrides: { 'close-session': { bindings: [{ modifiers: ['ctrl', 'shift'], key: 'w' }] } },
|
||||
});
|
||||
const close = app.getShortcutRegistry().find((s) => s.id === 'close-session')!;
|
||||
|
||||
expect(app.matchesShortcutEvent(keydown({ ctrlKey: true, shiftKey: true, key: 'W' }), close)).toBe(true);
|
||||
expect(app.matchesShortcutEvent(keydown({ ctrlKey: true }), close)).toBe(false);
|
||||
});
|
||||
|
||||
it('the shortcut overlay says an unbound action is not bound', () => {
|
||||
const appSource = read('app.js');
|
||||
const overlay = appSource.slice(appSource.indexOf('renderShortcutOverlay() {'), appSource.indexOf('closeShortcutOverlay() {'));
|
||||
|
||||
expect(overlay).toContain("if (s.bindings.length === 0) return '<span class=\"shortcut-overlay-unbound\">not bound</span>';");
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getDataDir } from '../src/config/instance.js';
|
||||
import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
|
||||
import { refreshAllCustomModelHosts } from '../src/web/routes/custom-model-routes.js';
|
||||
import { clearPropsProbeCache, refreshAllCustomModelHosts } from '../src/web/routes/custom-model-routes.js';
|
||||
import { webviewFetch } from '../src/web/webview-egress.js';
|
||||
|
||||
vi.mock('../src/web/webview-egress.js', async () => {
|
||||
@@ -31,6 +31,7 @@ function host(overrides: Partial<CustomModelHost> & Pick<CustomModelHost, 'id' |
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
clearPropsProbeCache();
|
||||
});
|
||||
|
||||
describe('refreshAllCustomModelHosts (the periodic re-discovery sweep)', () => {
|
||||
@@ -211,8 +212,9 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama
|
||||
it('prefers the REAL configured context size parsed from /running’s launch command over /props’s unreliable n_ctx', async () => {
|
||||
// Confirmed live: llama-swap launched a model with --fit-ctx 16384 (the real, working
|
||||
// limit — the actual server then refused a request over it), but /props reported
|
||||
// n_ctx: 154112 for the same model, well over what it would really accept. /props must
|
||||
// never be reached at all once the /running command parse already answered it.
|
||||
// n_ctx: 154112 for the same model, well over what it would really accept. The context
|
||||
// must therefore keep coming from the /running command; /props is now also probed, but
|
||||
// only for `total_slots`, which the command does not state.
|
||||
const dir = getDataDir();
|
||||
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
|
||||
fetchMock.mockImplementation(async (url: URL) => {
|
||||
@@ -235,7 +237,9 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.pathname === '/props') throw new Error('must never be reached — the cmd parse already answered it');
|
||||
if (url.pathname === '/props') {
|
||||
return new Response(JSON.stringify({ n_ctx: 154112, total_slots: 4 }), { status: 200 });
|
||||
}
|
||||
throw new Error(`unexpected request: ${url.href}`);
|
||||
});
|
||||
|
||||
@@ -243,6 +247,8 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama
|
||||
|
||||
const [updated] = await readCustomModelHosts(dir);
|
||||
expect(updated.modelContextLengths).toEqual({ 'qwen3.8-27b': 16384 });
|
||||
expect(updated.modelSlotCounts).toEqual({ 'qwen3.8-27b': 4 });
|
||||
// /props's 154112 never becomes the context, and nothing else stores it
|
||||
});
|
||||
|
||||
it('falls back to /props when /running has no cmd, or the cmd states no recognizable context flag', async () => {
|
||||
@@ -283,13 +289,243 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected request: ${url.href}`); // /props must never be reached
|
||||
if (url.pathname === '/props') return new Response('not found', { status: 404 }); // slot probe only
|
||||
throw new Error(`unexpected request: ${url.href}`);
|
||||
});
|
||||
|
||||
await refreshAllCustomModelHosts();
|
||||
|
||||
const [updated] = await readCustomModelHosts(dir);
|
||||
expect(updated.modelContextLengths).toEqual({ a: 8192 });
|
||||
expect(updated.modelSlotCounts).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshAllCustomModelHosts: parallel-slot enrichment (llama.cpp splits its context across --parallel slots)', () => {
|
||||
/** One loaded model whose launch command is `cmd`; `/props` behaviour is supplied by the test. */
|
||||
function serve(cmd: string, props: () => Response) {
|
||||
fetchMock.mockImplementation(async (url: URL) => {
|
||||
if (url.pathname === '/v1/models') {
|
||||
return new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'loaded' } }] }), { status: 200 });
|
||||
}
|
||||
if (url.pathname === '/running') {
|
||||
return new Response(JSON.stringify({ running: [{ model: 'm', state: 'ready', cmd }] }), { status: 200 });
|
||||
}
|
||||
if (url.pathname === '/props') return props();
|
||||
throw new Error(`unexpected request: ${url.href}`);
|
||||
});
|
||||
}
|
||||
const propsMustNotBeReached = () => {
|
||||
throw new Error('/props must not be probed: the launch command already answered both questions');
|
||||
};
|
||||
|
||||
it.each([
|
||||
['--parallel 1', 1],
|
||||
['-np 2', 2],
|
||||
['--parallel=3', 3],
|
||||
['--fit-ctx 65536 --parallel 1 --host 0.0.0.0', 1],
|
||||
])('reads an explicit %s from the launch command without probing /props', async (flags, slots) => {
|
||||
const dir = getDataDir();
|
||||
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
|
||||
serve(`llama-server -m /models/m.gguf --ctx-size 65536 ${flags}`, propsMustNotBeReached);
|
||||
|
||||
await refreshAllCustomModelHosts();
|
||||
|
||||
const [updated] = await readCustomModelHosts(dir);
|
||||
expect(updated.modelSlotCounts).toEqual({ m: slots });
|
||||
expect(updated.modelContextLengths).toEqual({ m: 65536 });
|
||||
});
|
||||
|
||||
it('asks /props for total_slots when the command states no --parallel (llama-server picks the count itself)', async () => {
|
||||
const dir = getDataDir();
|
||||
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
|
||||
serve(
|
||||
'llama-server -m /models/m.gguf --fit-ctx 65536',
|
||||
() => new Response(JSON.stringify({ n_ctx: 133888, total_slots: 4 }), { status: 200 })
|
||||
);
|
||||
|
||||
await refreshAllCustomModelHosts();
|
||||
|
||||
const [updated] = await readCustomModelHosts(dir);
|
||||
expect(updated.modelSlotCounts).toEqual({ m: 4 });
|
||||
// the context still comes from the command's floor, never from /props's n_ctx
|
||||
expect(updated.modelContextLengths).toEqual({ m: 65536 });
|
||||
});
|
||||
|
||||
it('probes /props once per launch command: a repeat sweep reuses the answer, a changed command probes again', async () => {
|
||||
const dir = getDataDir();
|
||||
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
|
||||
let probes = 0;
|
||||
const answer = () => (probes++, new Response(JSON.stringify({ n_ctx: 133888, total_slots: 4 }), { status: 200 }));
|
||||
serve('llama-server -m /models/m.gguf --fit-ctx 65536', answer);
|
||||
await refreshAllCustomModelHosts();
|
||||
await refreshAllCustomModelHosts();
|
||||
expect(probes).toBe(1); // a /props call refreshes the model's last-use time in llama-swap, so not every sweep
|
||||
expect((await readCustomModelHosts(dir))[0].modelSlotCounts).toEqual({ m: 4 });
|
||||
|
||||
serve('llama-server -m /models/m.gguf --fit-ctx 65536 --flash-attn on', answer);
|
||||
await refreshAllCustomModelHosts();
|
||||
expect(probes).toBe(2);
|
||||
});
|
||||
|
||||
it('does not divide a /props context a second time when --parallel is explicit and the command states no context', async () => {
|
||||
const dir = getDataDir();
|
||||
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
|
||||
// with an explicit count llama.cpp reports n_ctx per slot: 65,536 each is already what one request gets
|
||||
serve(
|
||||
'llama-server -m /models/m.gguf --parallel 4',
|
||||
() => new Response(JSON.stringify({ n_ctx: 65536, total_slots: 4 }), { status: 200 })
|
||||
);
|
||||
|
||||
await refreshAllCustomModelHosts();
|
||||
|
||||
const [updated] = await readCustomModelHosts(dir);
|
||||
expect(updated.modelContextLengths).toEqual({ m: 65536 });
|
||||
expect(updated.modelSlotCounts).toEqual({ m: 1 }); // already per slot, so one request gets all of it
|
||||
});
|
||||
|
||||
it('a later round with an explicit --parallel replaces the slot count an earlier round learned', async () => {
|
||||
const dir = getDataDir();
|
||||
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
|
||||
// round 1: auto slots, the server chose 4 sharing 131,072 tokens
|
||||
serve(
|
||||
'llama-server -m /models/m.gguf',
|
||||
() => new Response(JSON.stringify({ n_ctx: 131072, total_slots: 4 }), { status: 200 })
|
||||
);
|
||||
await refreshAllCustomModelHosts();
|
||||
expect((await readCustomModelHosts(dir))[0].modelSlotCounts).toEqual({ m: 4 });
|
||||
|
||||
// round 2: the user followed the dialog and added --parallel 1
|
||||
serve(
|
||||
'llama-server -m /models/m.gguf --parallel 1',
|
||||
() => new Response(JSON.stringify({ n_ctx: 131072, total_slots: 1 }), { status: 200 })
|
||||
);
|
||||
await refreshAllCustomModelHosts();
|
||||
expect((await readCustomModelHosts(dir))[0].modelSlotCounts).toEqual({ m: 1 });
|
||||
|
||||
// round 3: --parallel 2 with a per-slot /props context must not bring a pool back
|
||||
serve(
|
||||
'llama-server -m /models/m.gguf --parallel 2',
|
||||
() => new Response(JSON.stringify({ n_ctx: 65536, total_slots: 2 }), { status: 200 })
|
||||
);
|
||||
await refreshAllCustomModelHosts();
|
||||
const [updated] = await readCustomModelHosts(dir);
|
||||
expect(updated.modelSlotCounts).toEqual({ m: 1 });
|
||||
expect(updated.modelContextLengths).toEqual({ m: 65536 });
|
||||
});
|
||||
|
||||
it('remembers a /props that answers non-2xx per launch command, but retries a network failure', async () => {
|
||||
const dir = getDataDir();
|
||||
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
|
||||
let probes = 0;
|
||||
serve('llama-server -m /models/m.gguf --fit-ctx 65536', () => (probes++, new Response('nope', { status: 404 })));
|
||||
await refreshAllCustomModelHosts();
|
||||
await refreshAllCustomModelHosts();
|
||||
expect(probes).toBe(1); // no probe every sweep: a /props call keeps a model with a ttl loaded
|
||||
|
||||
clearPropsProbeCache();
|
||||
probes = 0;
|
||||
serve('llama-server -m /models/m.gguf --fit-ctx 65536', () => {
|
||||
probes++;
|
||||
throw new TypeError('fetch failed');
|
||||
});
|
||||
await refreshAllCustomModelHosts();
|
||||
await refreshAllCustomModelHosts();
|
||||
expect(probes).toBe(2); // a network error says nothing about the server, so it is tried again
|
||||
});
|
||||
|
||||
it('treats -np -1 (llama.cpp auto) as unstated and falls back to /props', async () => {
|
||||
const dir = getDataDir();
|
||||
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
|
||||
serve(
|
||||
'llama-server -m /models/m.gguf --fit-ctx 65536 -np -1',
|
||||
() => new Response(JSON.stringify({ total_slots: 4 }), { status: 200 })
|
||||
);
|
||||
|
||||
await refreshAllCustomModelHosts();
|
||||
|
||||
const [updated] = await readCustomModelHosts(dir);
|
||||
expect(updated.modelSlotCounts).toEqual({ m: 4 });
|
||||
});
|
||||
|
||||
it('a failed or slot-less /props leaves no slot count rather than failing the sweep or guessing one', async () => {
|
||||
const dir = getDataDir();
|
||||
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
|
||||
serve(
|
||||
'llama-server -m /models/m.gguf --fit-ctx 65536',
|
||||
() => new Response(JSON.stringify({ n_ctx: 8192 }), { status: 200 })
|
||||
);
|
||||
|
||||
await expect(refreshAllCustomModelHosts()).resolves.toBeUndefined();
|
||||
|
||||
const [updated] = await readCustomModelHosts(dir);
|
||||
expect(updated.modelSlotCounts).toBeUndefined();
|
||||
expect(updated.modelContextLengths).toEqual({ m: 65536 });
|
||||
});
|
||||
|
||||
it('keeps a previously-learned slot count for a model that is no longer loaded, and drops it once the model disappears', async () => {
|
||||
const dir = getDataDir();
|
||||
await writeCustomModelHosts(dir, [
|
||||
host({
|
||||
id: 'ep',
|
||||
baseUrl: 'http://localhost:8080',
|
||||
models: ['a', 'b'],
|
||||
modelContextLengths: { a: 65536, b: 65536 },
|
||||
modelSlotCounts: { a: 4, b: 2 },
|
||||
}),
|
||||
]);
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({ data: [{ id: 'a' }] }), { status: 200 }));
|
||||
|
||||
await refreshAllCustomModelHosts();
|
||||
|
||||
const [updated] = await readCustomModelHosts(dir);
|
||||
expect(updated.modelSlotCounts).toEqual({ a: 4 });
|
||||
});
|
||||
|
||||
it('a round where only the listed figure answers resets the slot count a loaded round learned', async () => {
|
||||
// The false-warning scenario, measured on a real llama-swap: round 1 has the model
|
||||
// loaded under `--ctx-size 262144 --parallel 4`: a TOTAL window shared across slots,
|
||||
// correctly stored with its count. Round 2 finds it unloaded and takes the LISTED
|
||||
// `context_length`, which llama-swap documents as the PER-SLOT window. If the stored 4
|
||||
// survived, the floor check would divide the per-slot figure a second time
|
||||
// (65536/4 = 16384) and warn about a model that serves 65536 to every request.
|
||||
const dir = getDataDir();
|
||||
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
|
||||
fetchMock.mockImplementation(async (url: URL) => {
|
||||
if (url.pathname === '/v1/models') {
|
||||
return new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'loaded' } }] }), { status: 200 });
|
||||
}
|
||||
if (url.pathname === '/running') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
running: [{ model: 'm', state: 'ready', cmd: 'llama-server -m /models/m.gguf --ctx-size 262144 --parallel 4' }],
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected request: ${url.href}`); // the command answers both questions
|
||||
});
|
||||
|
||||
await refreshAllCustomModelHosts();
|
||||
|
||||
let [round1] = await readCustomModelHosts(dir);
|
||||
expect(round1.modelContextLengths).toEqual({ m: 262144 });
|
||||
expect(round1.modelSlotCounts).toEqual({ m: 4 });
|
||||
|
||||
// Round 2: unloaded, no /running or /props probe in order, listing its per-slot figure.
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'unloaded' }, context_length: 65536 }] }), {
|
||||
status: 200,
|
||||
})
|
||||
);
|
||||
|
||||
await refreshAllCustomModelHosts();
|
||||
|
||||
const [round2] = await readCustomModelHosts(dir);
|
||||
expect(round2.modelContextLengths).toEqual({ m: 65536 });
|
||||
// 1 slot, not the stale 4: 65536 per slot with 1 recorded slot is the honest pair, so
|
||||
// the floor check reads 65536/request: exactly what the server gives a single request.
|
||||
expect(round2.modelSlotCounts).toEqual({ m: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -53,9 +53,12 @@ describe('applyCustomModelInjection: context length', () => {
|
||||
expect(applied?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deepseek: has no contextLengthVar declared, so a passed-in length is a no-op', () => {
|
||||
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', 'sess-ctx-3', 16384);
|
||||
expect(Object.keys(applied?.envOverrides ?? {}).sort()).toEqual(['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL']);
|
||||
it('deepseek: passes the known context through its isolated model catalogue', () => {
|
||||
const sessionId = 'sess-deepseek-context';
|
||||
sessionsToClean.push(sessionId);
|
||||
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId, 32768);
|
||||
expect(Object.keys(applied?.envOverrides ?? {}).sort()).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY', 'DSH_HOME']);
|
||||
expect(readFileSync(join(applied!.configDir!, 'cordis.patch.yml'), 'utf8')).toContain('"contextWindow": 32768');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,11 +106,12 @@ describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => {
|
||||
expect(applied?.envOverrides.HOME).toBe(customModelConfigDir(sessionId));
|
||||
});
|
||||
|
||||
it('deepseek: no configDirVar declared, so no config dir is created at all', () => {
|
||||
it('deepseek: creates an isolated profile patch', () => {
|
||||
const sessionId = 'sess-cfgdir-deepseek';
|
||||
sessionsToClean.push(sessionId);
|
||||
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId);
|
||||
expect(applied?.configDir).toBeUndefined();
|
||||
expect(existsSync(customModelConfigDir(sessionId))).toBe(false);
|
||||
expect(applied?.configDir).toBe(customModelConfigDir(sessionId));
|
||||
expect(existsSync(join(applied!.configDir!, 'cordis.patch.yml'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -188,18 +188,18 @@ describe('custom-model-injection contract (mock server)', () => {
|
||||
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
|
||||
});
|
||||
|
||||
it('deepseek: DEEPSEEK_BASE_URL already carries the /v1 suffix dsh itself never adds, reaching the mock at the real path dsh requests', async () => {
|
||||
// Confirmed by reading dsh's own bundled source: it fetches
|
||||
// `${DEEPSEEK_BASE_URL}/chat/completions` verbatim, no /v1 insertion of its own — so
|
||||
// this call (unlike gemini's above) passes DEEPSEEK_BASE_URL to callOpenAiCompat
|
||||
// UNMODIFIED, exactly mirroring what the real harness does, rather than the test
|
||||
// helping it along.
|
||||
it('deepseek: the session patch carries the /v1 base URL and exact model, reaching the mock at the real path', async () => {
|
||||
// dsh fetches `${baseURL}/chat/completions` verbatim, so the patch's baseURL already
|
||||
// carries /v1 and is passed to callOpenAiCompat unmodified.
|
||||
const injection = buildCustomModelInjection(entryOrThrow('deepseek'), endpointFor(mock), 'qwen3');
|
||||
if (injection.kind !== 'env') throw new Error('unreachable');
|
||||
expect(Object.keys(injection.envOverrides).sort()).toEqual(['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL']);
|
||||
expect(injection.envOverrides.DEEPSEEK_BASE_URL).toBe(`${mock.baseUrl}/v1`);
|
||||
if (injection.kind !== 'configDir') throw new Error('unreachable');
|
||||
expect(injection.dirEnvVar).toBe('DSH_HOME');
|
||||
expect(Object.keys(injection.extraEnv ?? {})).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY']);
|
||||
const provider = JSON.parse(injection.files[0].content)[0].config.providers['codeman-custom'];
|
||||
expect(provider.baseURL).toBe(`${mock.baseUrl}/v1`);
|
||||
expect(provider.models[0].id).toBe('qwen3');
|
||||
|
||||
await callOpenAiCompat(injection.envOverrides.DEEPSEEK_BASE_URL, injection.envOverrides.DEEPSEEK_API_KEY, 'qwen3');
|
||||
await callOpenAiCompat(provider.baseURL, injection.extraEnv!.CODEMAN_CUSTOM_MODEL_API_KEY, 'qwen3');
|
||||
|
||||
expect(mock.requests[0].path).toBe('/v1/chat/completions');
|
||||
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parse as parseToml } from 'smol-toml';
|
||||
import { getCli } from '../src/config/cli-registry/index.js';
|
||||
import {
|
||||
buildCustomModelInjection,
|
||||
@@ -112,6 +113,31 @@ describe('buildCustomModelInjection', () => {
|
||||
expect(parsed.provider.custom.models.qwen3).toEqual({});
|
||||
});
|
||||
|
||||
it('opencode: threads a discovered context length into the model limit as the full window (issue #749)', () => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', 16384);
|
||||
if (result.kind !== 'env') throw new Error('unreachable');
|
||||
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
|
||||
// `limit.context` IS the full window to opencode (it reserves for the reply itself),
|
||||
// so the discovered total passes through; `output` only caps the reply at a quarter.
|
||||
expect(parsed.provider.custom.models.qwen3).toEqual({ limit: { context: 16384, output: 4096 } });
|
||||
});
|
||||
|
||||
it('opencode: caps the response headroom reservation at 8192 for big windows', () => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', 262144);
|
||||
if (result.kind !== 'env') throw new Error('unreachable');
|
||||
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
|
||||
expect(parsed.provider.custom.models.qwen3).toEqual({ limit: { context: 262144, output: 8192 } });
|
||||
});
|
||||
|
||||
it('opencode: never invents a limit for an unknown or non-finite context length (issue #749 regression)', () => {
|
||||
for (const contextLength of [undefined, NaN]) {
|
||||
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', contextLength);
|
||||
if (result.kind !== 'env') throw new Error('unreachable');
|
||||
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
|
||||
expect(parsed.provider.custom.models.qwen3).toEqual({});
|
||||
}
|
||||
});
|
||||
|
||||
it('codex: configDir writes an isolated config.toml with model/base_url, and the key rides as extraEnv (never a literal TOML field)', () => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3');
|
||||
expect(result.kind).toBe('configDir');
|
||||
@@ -133,6 +159,30 @@ describe('buildCustomModelInjection', () => {
|
||||
expect(result.files[0].content).toContain('model = "weird\\"model"');
|
||||
});
|
||||
|
||||
it('codex: writes the discovered window as TOP-LEVEL model_context_window with a 75% compact limit (issue #750)', () => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3', 16384);
|
||||
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||
const doc = parseToml(result.files[0].content) as Record<string, unknown>;
|
||||
// Top-level is the shape codex reads: parked under [model_providers.custom] (its
|
||||
// provider table) the same keys would be silently ignored by codex itself.
|
||||
expect(doc.model_context_window).toBe(16384);
|
||||
expect(doc.model_auto_compact_token_limit).toBe(12288); // 75% of the window, floored
|
||||
const provider = (doc.model_providers as Record<string, Record<string, unknown>>).custom;
|
||||
expect(provider.model_context_window).toBeUndefined();
|
||||
expect(provider.model_auto_compact_token_limit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('codex: writes neither context line for an unknown, non-finite or zero context length', () => {
|
||||
for (const contextLength of [undefined, NaN, 0]) {
|
||||
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3', contextLength);
|
||||
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||
const doc = parseToml(result.files[0].content) as Record<string, unknown>;
|
||||
expect(doc.model_context_window, String(contextLength)).toBeUndefined();
|
||||
expect(doc.model_auto_compact_token_limit, String(contextLength)).toBeUndefined();
|
||||
expect(doc.model).toBe('qwen3'); // the rest of the file is still written
|
||||
}
|
||||
});
|
||||
|
||||
it('pi: configDir writes .pi/agent/models.json, redirected via HOME (verified live — PI_CONFIG_DIR does nothing for pi)', () => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('pi'), endpoint, 'qwen3');
|
||||
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||
@@ -179,29 +229,32 @@ describe('buildCustomModelInjection', () => {
|
||||
expect(result.extraEnv).toEqual({ XAI_API_KEY: 'my-key' });
|
||||
});
|
||||
|
||||
it('deepseek: env kind sets base URL (with a /v1 suffix appended) and key, no model var', () => {
|
||||
// appendV1Suffix is REQUIRED here, not cosmetic: confirmed by reading dsh's own
|
||||
// bundled source (@deepseek-ai/dsh-llm-deepseek) that it builds the request URL as
|
||||
// `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own, while
|
||||
// llama-swap/llama.cpp only serves "/v1/chat/completions" — without this, every
|
||||
// request 404s (confirmed live; this is the fix for the originally-reported
|
||||
// "dsh: HTTP_404: DeepSeek API error (HTTP 404)").
|
||||
it('deepseek: isolates the selected model route and passes its key by reference', () => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3');
|
||||
if (result.kind !== 'env') throw new Error('unreachable');
|
||||
expect(result.envOverrides).toEqual({
|
||||
DEEPSEEK_BASE_URL: 'http://192.168.1.50:8080/v1',
|
||||
DEEPSEEK_API_KEY: 'my-key',
|
||||
});
|
||||
if (result.kind !== 'configDir') throw new Error('Expected isolated DeepSeek config');
|
||||
expect(result.dirEnvVar).toBe('DSH_HOME');
|
||||
expect(result.extraEnv).toEqual({ CODEMAN_CUSTOM_MODEL_API_KEY: 'my-key' });
|
||||
expect(result.files[0].content).not.toContain('my-key');
|
||||
const patch = JSON.parse(result.files[0].content);
|
||||
// The patch references the DEDICATED var by name, never dsh's own DEEPSEEK_API_KEY:
|
||||
// dsh's llm-deepseek/web-search rows read that too and would leak this key to
|
||||
// api.deepseek.com on an in-session /model switch back to the official route.
|
||||
expect(patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'].apiKeyEnv).toBe(
|
||||
'CODEMAN_CUSTOM_MODEL_API_KEY'
|
||||
);
|
||||
expect(result.files[0].content).not.toContain('DEEPSEEK_API_KEY');
|
||||
expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config.model).toBe('qwen3');
|
||||
});
|
||||
|
||||
it('deepseek: appending the /v1 suffix is idempotent against a baseUrl that already ends in /v1', () => {
|
||||
it('deepseek: preserves an existing version suffix', () => {
|
||||
const result = buildCustomModelInjection(
|
||||
entryOrThrow('deepseek'),
|
||||
{ ...endpoint, baseUrl: 'http://192.168.1.50:8080/v1' },
|
||||
{ ...endpoint, baseUrl: endpoint.baseUrl + '/v1' },
|
||||
'qwen3'
|
||||
);
|
||||
if (result.kind !== 'env') throw new Error('unreachable');
|
||||
expect(result.envOverrides.DEEPSEEK_BASE_URL).toBe('http://192.168.1.50:8080/v1');
|
||||
if (result.kind !== 'configDir') throw new Error('Expected isolated DeepSeek config');
|
||||
expect(result.files[0].content).toContain('http://192.168.1.50:8080/v1');
|
||||
expect(result.files[0].content).not.toContain('/v1/v1');
|
||||
});
|
||||
|
||||
it('antigravity: unsupported', () => {
|
||||
@@ -232,7 +285,9 @@ describe('launchModel (the model launch param that selects the injected provider
|
||||
});
|
||||
|
||||
it('CLIs whose config selects the model on its own declare none', () => {
|
||||
for (const id of ['claude', 'opencode', 'codex', 'gemini', 'deepseek']) {
|
||||
// codex now declares launchModel (selected-model launch override) and is covered by
|
||||
// its own restart coverage, not here.
|
||||
for (const id of ['claude', 'opencode', 'gemini', 'deepseek']) {
|
||||
const result = buildCustomModelInjection(entryOrThrow(id), endpoint, 'qwen3');
|
||||
if (result.kind === 'unsupported') throw new Error('unreachable');
|
||||
expect(result.launchModel, id).toBeUndefined();
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @fileoverview Boot recovery rebuilds a saved custom-model selection through the SAME
|
||||
* context-length resolution the routes use: the model's discovered figure, falling back to
|
||||
* the endpoint's `defaultContextLength` (`resolveCustomModelContextLength`). Before the
|
||||
* shared helper, `_rebuildCustomModelEnv` read `modelContextLengths` alone, so a session
|
||||
* on a model that only the endpoint default covers came back from a Codeman restart with
|
||||
* `CLAUDE_CODE_MAX_CONTEXT_TOKENS` (or a config file's context figure) silently gone,
|
||||
* re-arming exactly the window overflow the var exists to prevent.
|
||||
*
|
||||
* `_rebuildCustomModelEnv` reads no `this` state (it re-derives everything from the endpoint
|
||||
* store and the Session it is handed), so it is driven straight off the prototype with an
|
||||
* empty receiver: `restoreMuxSessions()` itself cannot be reached under vitest, the same
|
||||
* constraint recorded in test/session-model-recovery.test.ts.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { getDataDir } from '../src/config/instance.js';
|
||||
import { writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
|
||||
import { customModelConfigDir, removeConfigDir, type AppliedCustomModel } from '../src/custom-model-injection-apply.js';
|
||||
import { Session } from '../src/session.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import type { CustomModelBookkeeping } from '../src/types/session.js';
|
||||
|
||||
type RebuildFn = (session: Session, saved: CustomModelBookkeeping) => Promise<AppliedCustomModel | undefined>;
|
||||
|
||||
/** The real method, with an empty receiver, see the file header for why that is sound. */
|
||||
function rebuildCustomModelEnv(session: Session, saved: CustomModelBookkeeping) {
|
||||
const fn = (WebServer.prototype as unknown as { _rebuildCustomModelEnv: RebuildFn })._rebuildCustomModelEnv;
|
||||
expect(typeof fn, '_rebuildCustomModelEnv vanished from WebServer.prototype').toBe('function');
|
||||
return fn.call({}, session, saved);
|
||||
}
|
||||
|
||||
function endpoint(overrides: Partial<CustomModelHost> = {}): CustomModelHost {
|
||||
return {
|
||||
id: 'ep',
|
||||
label: 'Ep',
|
||||
baseUrl: 'http://localhost:8080',
|
||||
apiKey: 'recovery-probe-key',
|
||||
models: ['qwen3'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('_rebuildCustomModelEnv: context-length fallback on boot recovery', () => {
|
||||
const configs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const id of configs.splice(0)) removeConfigDir(customModelConfigDir(id));
|
||||
});
|
||||
|
||||
it('falls back to the endpoint defaultContextLength when discovery recorded no per-model figure', async () => {
|
||||
await writeCustomModelHosts(getDataDir(), [endpoint({ defaultContextLength: 32768 })]);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
configs.push(session.id);
|
||||
|
||||
const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] });
|
||||
|
||||
expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('32768');
|
||||
});
|
||||
|
||||
it('a discovered per-model figure still wins over the endpoint default', async () => {
|
||||
await writeCustomModelHosts(getDataDir(), [
|
||||
endpoint({ modelContextLengths: { qwen3: 131072 }, defaultContextLength: 32768 }),
|
||||
]);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
configs.push(session.id);
|
||||
|
||||
const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] });
|
||||
|
||||
expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('131072');
|
||||
});
|
||||
|
||||
it('states nothing rather than guessing when neither source knows a window', async () => {
|
||||
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
configs.push(session.id);
|
||||
|
||||
const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] });
|
||||
|
||||
expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops the selection to undefined (kept for clearing) when the endpoint no longer exists', async () => {
|
||||
await writeCustomModelHosts(getDataDir(), []);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||
configs.push(session.id);
|
||||
|
||||
await expect(rebuildCustomModelEnv(session, { endpointId: 'gone', modelId: 'qwen3', envKeys: [] })).resolves.toBe(
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @fileoverview Boot recovery re-derives a saved custom-model selection through
|
||||
* `_rebuildCustomModelEnv` and rebuilds the BOOKKEEPING from what the CURRENT recipe
|
||||
* applied, not from the saved record. A selection persisted while deepseek still used
|
||||
* the old env redirect comes back env-shaped (envKeys `DEEPSEEK_BASE_URL`/
|
||||
* `DEEPSEEK_API_KEY`, no `configDir`) although the recipe now writes a per-session patch
|
||||
* home: restoring the saved record unchanged left `DSH_HOME` on a generated config dir
|
||||
* that clearing could never delete.
|
||||
*
|
||||
* Real-home resolution is NOT recovered from the session's env overrides any more:
|
||||
* `getEnvOverridesForPersist()` keeps only `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`, so a
|
||||
* session's own `DSH_HOME` never survives a real restart and the lookup only ever hit on
|
||||
* the first apply in-process. Recovery falls back to the server's `DSH_HOME` or the
|
||||
* recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s comment.
|
||||
*
|
||||
* `_restoreCustomModel` reads no `this` state beyond the rebuild helper (it re-derives
|
||||
* everything from the endpoint store and the Session it is handed), so it is driven off
|
||||
* the prototype with a minimal receiver: `restoreMuxSessions()` itself cannot be
|
||||
* reached under vitest, the same constraint recorded in test/session-model-recovery.test.ts.
|
||||
*/
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getDataDir } from '../src/config/instance.js';
|
||||
import { writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
|
||||
import { customModelConfigDir, removeConfigDir } from '../src/custom-model-injection-apply.js';
|
||||
import { Session } from '../src/session.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import type { CustomModelBookkeeping } from '../src/types/session.js';
|
||||
|
||||
/** The real pair, on a receiver that only carries the rebuild helper, see the header. */
|
||||
async function restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> {
|
||||
const proto = WebServer.prototype as unknown as {
|
||||
_rebuildCustomModelEnv: (session: Session, saved: CustomModelBookkeeping) => Promise<unknown>;
|
||||
_restoreCustomModel: (session: Session, saved: CustomModelBookkeeping) => Promise<void>;
|
||||
};
|
||||
expect(typeof proto._restoreCustomModel, '_restoreCustomModel vanished from WebServer.prototype').toBe('function');
|
||||
await proto._restoreCustomModel.call({ _rebuildCustomModelEnv: proto._rebuildCustomModelEnv }, session, saved);
|
||||
}
|
||||
|
||||
function endpoint(): CustomModelHost {
|
||||
return {
|
||||
id: 'ep',
|
||||
label: 'Ep',
|
||||
baseUrl: 'http://localhost:8080',
|
||||
apiKey: 'recovery-probe-key',
|
||||
models: ['qwen3'],
|
||||
};
|
||||
}
|
||||
|
||||
describe('_restoreCustomModel: bookkeeping rebuilt from the current recipe', () => {
|
||||
const configs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const id of configs.splice(0)) removeConfigDir(customModelConfigDir(id));
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('carries the configDir and env-key union for a selection saved by the old env recipe', async () => {
|
||||
// Saved exactly as 1.31.0-1.47.0 persisted a deepseek selection: the env recipe's two
|
||||
// keys, no configDir. The rebuilt record must carry the per-session config dir the
|
||||
// CURRENT recipe wrote (so clearing deletes it) while KEEPING the saved keys, which
|
||||
// the tmux session may still carry `setenv`s for (the union is what a clear unsets).
|
||||
const dshHome = join(homedir(), 'codeman-recovery-dsh-home');
|
||||
mkdirSync(join(dshHome, 'profiles'), { recursive: true });
|
||||
vi.stubEnv('DSH_HOME', dshHome);
|
||||
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
|
||||
configs.push(session.id);
|
||||
|
||||
await restoreCustomModel(session, {
|
||||
endpointId: 'ep',
|
||||
modelId: 'qwen3',
|
||||
label: 'Ep',
|
||||
envKeys: ['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY'],
|
||||
});
|
||||
|
||||
const persisted = session.getCustomModelForPersist();
|
||||
expect(persisted?.configDir).toBe(customModelConfigDir(session.id));
|
||||
expect(persisted?.launchModel).toBeUndefined();
|
||||
expect(new Set(persisted?.envKeys)).toEqual(
|
||||
new Set(['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY', 'DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'])
|
||||
);
|
||||
rmSync(dshHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('restores an env-shaped record unchanged when the endpoint is gone, so a clear still works', async () => {
|
||||
await writeCustomModelHosts(getDataDir(), []);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
|
||||
const saved: CustomModelBookkeeping = {
|
||||
endpointId: 'gone',
|
||||
modelId: 'qwen3',
|
||||
label: 'Gone',
|
||||
envKeys: ['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY'],
|
||||
};
|
||||
|
||||
await restoreCustomModel(session, saved);
|
||||
|
||||
expect(session.getCustomModelForPersist()).toEqual(saved);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,10 @@ import { describe, expect, it, vi } 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');
|
||||
// Only for the [hidden] CSS guard below: the filter toggles the `hidden` property, and
|
||||
// `.run-mode-option`'s own `display: flex` would silently defeat it without the
|
||||
// `!important` re-assertion in styles.css.
|
||||
const STYLES_CSS = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf-8');
|
||||
// Only for the real _showCenterStatus DOM tests below (`bootAppWithRealCenterStatus`) —
|
||||
// every other test in this file stubs _showCenterStatus itself and has no need of it.
|
||||
const PANELS_UI_JS = readFileSync(new URL('../src/web/public/panels-ui.js', import.meta.url), 'utf-8');
|
||||
@@ -40,6 +44,8 @@ function bootApp(
|
||||
cliAvailable?: (id: string) => boolean;
|
||||
activeCase?: { location?: string } | null;
|
||||
settingsEnabled?: boolean;
|
||||
/** Live sessions for the "in use by a session" fallback, in `this.sessions` Map shape. */
|
||||
sessions?: Array<Record<string, unknown>>;
|
||||
} = {}
|
||||
) {
|
||||
const dom = new JSDOM(
|
||||
@@ -55,6 +61,10 @@ function bootApp(
|
||||
<div class="modal" id="customModelPickModal">
|
||||
<h3 id="customModelPickTitle"></h3>
|
||||
<p id="customModelPickHint"></p>
|
||||
<input type="search" id="customModelPickSearch" aria-label="Search models"
|
||||
oninput="app.filterCustomModelPick(this.value)">
|
||||
<p id="customModelPickNoMatches" role="status" hidden>No models match</p>
|
||||
<p id="customModelPickUnknown" role="status" hidden>Nothing is marked: this endpoint did not report which model it has loaded, and none of your running sessions uses one. Pick the model to launch.</p>
|
||||
<div id="customModelPickList"></div>
|
||||
</div>
|
||||
<div class="modal" id="customModelSwapConfirmModal">
|
||||
@@ -81,6 +91,10 @@ function bootApp(
|
||||
app.cases = options.activeCase ? [{ name: 'testcase', ...options.activeCase }] : [{ name: 'testcase' }];
|
||||
app.loadAppSettingsFromStorage = () => ({ customModelEndpointsEnabled: options.settingsEnabled ?? true });
|
||||
app.isCliAvailable = options.cliAvailable ?? (() => true);
|
||||
// The real app keeps live sessions in a Map keyed by id (app.js), which is what
|
||||
// _getCustomModelInUseBySession walks; empty by default so a test that says nothing
|
||||
// about sessions sees no promotion, exactly like a box with none running.
|
||||
app.sessions = new Map((options.sessions || []).map((s, i) => [String(i), s]));
|
||||
app.showToast = () => {};
|
||||
// Real implementation lives in panels-ui.js, not evaluated into this harness (only
|
||||
// constants.js + session-ui.js are — see below) — a no-op default handle matching its
|
||||
@@ -315,6 +329,27 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
|
||||
expect(buttons[0].textContent).not.toContain('Currently loaded');
|
||||
});
|
||||
|
||||
it('treats a remembered "last used" id the host no longer lists as nothing promoted: no tag, the line shows', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
|
||||
});
|
||||
const origApiJson = app._apiJson;
|
||||
app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/llama-box/running-status') return { isLlamaSwap: false, running: [] };
|
||||
return origApiJson(path);
|
||||
};
|
||||
// The remembered model was deleted from this endpoint since the last launch.
|
||||
win.localStorage.setItem('codeman:customModelLastUsed:claude:llama-box', 'gone-model');
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
|
||||
const list = win.document.getElementById('customModelPickList')!;
|
||||
expect(list.textContent).not.toContain('Last used');
|
||||
// Without a promoted row the probe really did answer "nothing is known", so the
|
||||
// explanation line must show rather than leave two unmarked rows unexplained.
|
||||
expect(win.document.getElementById('customModelPickUnknown')!.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers the currently-loaded model over a stale "last used" entry when both are present', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3', 'phi4'] }],
|
||||
@@ -532,9 +567,349 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
expect(toastMessage).toMatch(/no models discovered/i);
|
||||
});
|
||||
|
||||
it('resets and focuses the search box every time the picker opens', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
|
||||
});
|
||||
win.app = app;
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
|
||||
// The caret lands in the box, so the first keystroke filters with no click.
|
||||
expect(win.document.activeElement).toBe(search);
|
||||
// A stale query from a previous open must not survive into the next one.
|
||||
search.value = 'zzz';
|
||||
search.dispatchEvent(new win.Event('input'));
|
||||
app.closeCustomModelPickModal();
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
expect(search.value).toBe('');
|
||||
expect(win.document.activeElement).toBe(search);
|
||||
expect(win.document.getElementById('customModelPickNoMatches')!.hasAttribute('hidden')).toBe(true);
|
||||
const visible = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')].filter(
|
||||
(b) => !b.hasAttribute('hidden')
|
||||
);
|
||||
expect(visible).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('leaves the search box unfocused on touch devices so the OS keyboard stays off the list', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
|
||||
});
|
||||
win.app = app;
|
||||
// The guard reads the global the browser defines (mobile-detection.js).
|
||||
(win as unknown as { MobileDetection: { isTouchDevice: () => boolean } }).MobileDetection = {
|
||||
isTouchDevice: () => true,
|
||||
};
|
||||
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
|
||||
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
|
||||
expect(win.document.activeElement).not.toBe(search);
|
||||
// Only the focus is skipped: the rows still render, unfiltered and functional.
|
||||
const visible = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')].filter(
|
||||
(b) => !b.hasAttribute('hidden')
|
||||
);
|
||||
expect(visible).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('filters rows case-insensitively on the model id, keeping the surviving row fully functional', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [
|
||||
{
|
||||
id: 'llama-box',
|
||||
label: 'llama.cpp',
|
||||
baseUrl: 'http://x',
|
||||
models: ['qwen3', 'llama3'],
|
||||
defaultModelId: 'qwen3',
|
||||
},
|
||||
],
|
||||
});
|
||||
let launched: unknown[] | null = null;
|
||||
app.runCustomModelEntry = async (...args: unknown[]) => {
|
||||
launched = args;
|
||||
};
|
||||
win.app = app;
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
|
||||
search.value = 'QWEN';
|
||||
search.dispatchEvent(new win.Event('input'));
|
||||
|
||||
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||
expect(buttons.filter((b) => b.hasAttribute('hidden')).map((b) => b.textContent!.trim())).toEqual(['llama3']);
|
||||
const [visible] = buttons.filter((b) => !b.hasAttribute('hidden'));
|
||||
// The surviving row keeps its Default badge and its real, callable click handler.
|
||||
expect(visible.textContent).toContain('qwen3');
|
||||
expect(visible.textContent).toContain('Default');
|
||||
(visible as unknown as HTMLButtonElement & { onclick: (e: unknown) => void }).onclick(
|
||||
new (win as any).Event('click')
|
||||
);
|
||||
expect(launched).toEqual(['claude', 'llama-box', 'qwen3']);
|
||||
});
|
||||
|
||||
it('shows the no-matches line only while a query matches nothing, and clears it with the query', async () => {
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
|
||||
});
|
||||
win.app = app;
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
|
||||
const noMatches = win.document.getElementById('customModelPickNoMatches')!;
|
||||
const isHidden = () =>
|
||||
win.document.getElementById('customModelPickList')!.querySelectorAll('button[hidden]').length;
|
||||
|
||||
// Whitespace is not a query: everything stays visible, no message.
|
||||
search.value = ' ';
|
||||
search.dispatchEvent(new win.Event('input'));
|
||||
expect(noMatches.hasAttribute('hidden')).toBe(true);
|
||||
expect(isHidden()).toBe(0);
|
||||
|
||||
search.value = 'ghost';
|
||||
search.dispatchEvent(new win.Event('input'));
|
||||
expect(noMatches.hasAttribute('hidden')).toBe(false);
|
||||
expect(isHidden()).toBe(2);
|
||||
|
||||
search.value = '';
|
||||
search.dispatchEvent(new win.Event('input'));
|
||||
expect(noMatches.hasAttribute('hidden')).toBe(true);
|
||||
expect(isHidden()).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps an HTML-significant model id inert in the picker rows too, and filterable by its raw text', async () => {
|
||||
// Same vector as the Run-menu row above, now via data-model-id: escapeHtml covers
|
||||
// the double quote, and the filter reads the decoded raw id back off dataset.
|
||||
const dangerousModel = '"><img src=x onerror=alert(1)>';
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', dangerousModel] }],
|
||||
});
|
||||
win.app = app;
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
const list = win.document.getElementById('customModelPickList')!;
|
||||
expect(list.querySelector('img')).toBeNull();
|
||||
|
||||
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
|
||||
search.value = 'src=x';
|
||||
search.dispatchEvent(new win.Event('input'));
|
||||
const visible = list.querySelectorAll('button:not([hidden])');
|
||||
expect(visible).toHaveLength(1);
|
||||
expect(visible[0].getAttribute('data-model-id')).toBe(dangerousModel);
|
||||
});
|
||||
|
||||
it('re-asserts display:none !important for hidden picker rows so the filter cannot be silently defeated', () => {
|
||||
// `.run-mode-option` carries its own `display: flex` (styles.css, restated in
|
||||
// mobile.css), which outranks the UA `[hidden]` rule: without this scoped
|
||||
// !important re-assertion, toggling `hidden` would change nothing on screen.
|
||||
const rule = STYLES_CSS.match(/#customModelPickModal \.run-mode-option\[hidden\]\s*\{[^}]*\}/);
|
||||
expect(rule, 'picker rows need a [hidden] rule scoped to #customModelPickModal').toBeTruthy();
|
||||
expect(rule![0]).toMatch(/display:\s*none\s*!important/);
|
||||
});
|
||||
|
||||
it('pins "No models match" hidden with an empty query and shown only when a query hides every row, in CSS too', async () => {
|
||||
// The visible symptom: the line carries `.form-hint`, whose `display: block`
|
||||
// outranks the UA `[hidden]` rule, so `hidden` alone showed "No models match"
|
||||
// while every model was listed with an empty search box. The attribute half
|
||||
// was always right (session-ui.js's `filterCustomModelPick`); the CSS half
|
||||
// needed the same `[hidden] { display: none !important }` re-assertion the
|
||||
// rows above carry. This test pins both states AND the CSS that makes them
|
||||
// actually render.
|
||||
const rule = STYLES_CSS.match(/#customModelPickModal \.custom-model-pick-no-matches\[hidden\]\s*\{[^}]*\}/);
|
||||
expect(rule, 'the no-matches line needs a [hidden] rule scoped to #customModelPickModal').toBeTruthy();
|
||||
expect(rule![0]).toMatch(/display:\s*none\s*!important/);
|
||||
|
||||
const { win, app } = bootApp({
|
||||
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
|
||||
});
|
||||
win.app = app;
|
||||
await app.selectCustomModelEntry('claude', 'llama-box');
|
||||
const noMatches = win.document.getElementById('customModelPickNoMatches')!;
|
||||
|
||||
// Empty query, models listed: the attribute must be set, and the CSS rule
|
||||
// above is what makes that attribute actually hide the line.
|
||||
expect(noMatches.hasAttribute('hidden')).toBe(true);
|
||||
|
||||
// A non-empty query that hides every row: shown again.
|
||||
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
|
||||
search.value = 'ghost';
|
||||
search.dispatchEvent(new win.Event('input'));
|
||||
expect(noMatches.hasAttribute('hidden')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Model Endpoint Profiles: _getCustomModelCurrentlyLoaded is client-side bounded', () => {
|
||||
/**
|
||||
* The reported bug: a gateway such as Bifrost (40 models, alphabetical, no llama-swap
|
||||
* `/running` route of its own) showed "Choose a model" with nothing marked, so the user
|
||||
* could not tell which model the endpoint was actually serving, while the same endpoint
|
||||
* reached from the Codex CLI appeared to "default to alpha order" for every other CLI.
|
||||
* The endpoint genuinely cannot answer, so the app falls back to what it does know: a
|
||||
* live session it has itself pointed at that endpoint. And it says so plainly when it
|
||||
* knows neither.
|
||||
*/
|
||||
describe('Custom Model Endpoint Profiles: gateways without /running fall back to a live session', () => {
|
||||
const GATEWAY = {
|
||||
id: 'bifrost',
|
||||
label: 'bifrost',
|
||||
baseUrl: 'https://bifrost.example',
|
||||
models: ['llama-swap/alpha-3b', 'llama-swap/qwen3.8-flash-next-unsloth-ud-iq4_xs', 'llama-swap/zeta-70b'],
|
||||
};
|
||||
|
||||
/** The gateway's exact shape: reachable, OpenAI-compatible, and not llama-swap. */
|
||||
function gatewayApp(sessions: Array<Record<string, unknown>>) {
|
||||
const booted = bootApp({ hosts: [GATEWAY], sessions });
|
||||
const origApiJson = booted.app._apiJson;
|
||||
booted.app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/bifrost/running-status') return { isLlamaSwap: false, running: [] };
|
||||
return origApiJson(path);
|
||||
};
|
||||
return booted;
|
||||
}
|
||||
|
||||
const live = (modelId: string, overrides: Record<string, unknown> = {}) => ({
|
||||
name: 'w9-Gateway',
|
||||
status: 'busy',
|
||||
pid: 4242,
|
||||
createdAt: 1_700_000_000_000,
|
||||
customModel: { endpointId: 'bifrost', modelId, label: 'bifrost' },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('marks the model a live session is running, at the top, for EVERY CLI, not just claude', async () => {
|
||||
const inUse = 'llama-swap/qwen3.8-flash-next-unsloth-ud-iq4_xs';
|
||||
for (const mode of ['opencode', 'deepseek', 'codex', 'claude']) {
|
||||
const { win, app } = gatewayApp([live(inUse)]);
|
||||
await app._openCustomModelPickModal(mode, GATEWAY);
|
||||
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||
expect(buttons).toHaveLength(3);
|
||||
expect(buttons[0].textContent, mode).toContain(inUse);
|
||||
expect(buttons[0].textContent, mode).toContain('In use by a session');
|
||||
// The other rows keep discovery order and carry no tag of their own.
|
||||
expect(buttons[1].textContent).toContain('llama-swap/alpha-3b');
|
||||
expect(buttons[2].querySelectorAll('.set-scope')).toHaveLength(0);
|
||||
// Nothing was fabricated about the endpoint itself.
|
||||
expect(win.document.getElementById('customModelPickList')!.textContent).not.toContain('Currently loaded');
|
||||
expect(win.document.getElementById('customModelPickUnknown')!.hasAttribute('hidden')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('a live session outranks this device’s "last used" memory, and both rank above nothing', async () => {
|
||||
const { win, app } = gatewayApp([live('llama-swap/zeta-70b')]);
|
||||
win.localStorage.setItem('codeman:customModelLastUsed:opencode:bifrost', 'llama-swap/alpha-3b');
|
||||
|
||||
await app._openCustomModelPickModal('opencode', GATEWAY);
|
||||
|
||||
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||
expect(buttons[0].textContent).toContain('llama-swap/zeta-70b');
|
||||
expect(buttons[0].textContent).toContain('In use by a session');
|
||||
expect(buttons[0].textContent).not.toContain('Last used');
|
||||
// The remembered pick is still listed, just not promoted or double-tagged.
|
||||
expect(buttons[1].textContent).toContain('llama-swap/alpha-3b');
|
||||
expect(buttons[1].querySelectorAll('.set-scope')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a stopped session, an exited process, an exited pane, another endpoint or an undiscovered model mark nothing', async () => {
|
||||
const inUse = 'llama-swap/qwen3.8-flash-next-unsloth-ud-iq4_xs';
|
||||
const cases: Array<[string, Record<string, unknown>]> = [
|
||||
['stopped', { status: 'stopped' }],
|
||||
['process exited (pid null)', { pid: null }],
|
||||
['pane exited', { paneExit: { at: 1, status: 0 } }],
|
||||
['other endpoint', { customModel: { endpointId: 'other', modelId: inUse } }],
|
||||
['no custom model', { customModel: undefined }],
|
||||
['undiscovered model', { customModel: { endpointId: 'bifrost', modelId: 'llama-swap/retired-model' } }],
|
||||
];
|
||||
for (const [label, overrides] of cases) {
|
||||
const { win, app } = gatewayApp([live(inUse, overrides)]);
|
||||
await app._openCustomModelPickModal('opencode', GATEWAY);
|
||||
const list = win.document.getElementById('customModelPickList')!;
|
||||
expect(list.textContent, label).not.toContain('In use by a session');
|
||||
expect(list.textContent, label).not.toContain('Last used');
|
||||
// Discovery order untouched: nothing got dragged to the top.
|
||||
expect(list.querySelectorAll('button')![0].textContent).toContain('llama-swap/alpha-3b');
|
||||
}
|
||||
});
|
||||
|
||||
it('the most recently launched live session wins when two share an endpoint', async () => {
|
||||
const { win, app } = gatewayApp([
|
||||
live('llama-swap/alpha-3b', { createdAt: 1 }),
|
||||
live('llama-swap/zeta-70b', { createdAt: 2 }),
|
||||
]);
|
||||
|
||||
await app._openCustomModelPickModal('opencode', GATEWAY);
|
||||
|
||||
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||
expect(buttons[0].textContent).toContain('llama-swap/zeta-70b');
|
||||
expect(buttons.filter((b) => b.textContent!.includes('In use by a session'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('says so plainly when nothing is known (a down endpoint answers the very same shape)', async () => {
|
||||
const { win, app } = gatewayApp([]);
|
||||
|
||||
await app._openCustomModelPickModal('opencode', GATEWAY);
|
||||
|
||||
const unknown = win.document.getElementById('customModelPickUnknown')!;
|
||||
expect(unknown.hasAttribute('hidden')).toBe(false);
|
||||
expect(unknown.textContent).toContain('did not report which model it has loaded');
|
||||
// What REALLY arrives here, not a fiction: `getLlamaSwapStatus` (custom-model-routes.ts)
|
||||
// turns EVERY upstream failure into `isLlamaSwap: false` (a gateway with no
|
||||
// `/running` route, a closed port, an unresolvable host, a 401), so this stub is
|
||||
// also exactly what a DOWN endpoint produces, which is why the line's wording says
|
||||
// only "did not report" and never why.
|
||||
// The same class-of-bug trap as the no-matches line: `.form-hint`'s `display: block`
|
||||
// outranks the UA `[hidden]` rule, so the attribute needs the CSS re-assertion.
|
||||
const rule = STYLES_CSS.match(/#customModelPickModal \.custom-model-pick-unknown\[hidden\]\s*\{[^}]*\}/);
|
||||
expect(rule, 'the unknown-endpoint line needs a [hidden] rule scoped to #customModelPickModal').toBeTruthy();
|
||||
expect(rule![0]).toMatch(/display:\s*none\s*!important/);
|
||||
});
|
||||
|
||||
it('hides that line whenever a row IS marked, and for a probe that never got any answer', async () => {
|
||||
// Marked by a live session: nothing to explain.
|
||||
const marked = gatewayApp([live('llama-swap/alpha-3b')]);
|
||||
await marked.app._openCustomModelPickModal('opencode', GATEWAY);
|
||||
expect(marked.win.document.getElementById('customModelPickUnknown')!.hasAttribute('hidden')).toBe(true);
|
||||
|
||||
// `null` (no answer at all) only happens when Codeman's OWN request to the route
|
||||
// failed or the client-side bound won the race. The route itself never answers this
|
||||
// way for an unreachable endpoint: every fetch failure arrives as `isLlamaSwap:
|
||||
// false`, pinned by the test above. The box may merely be asleep, so stay silent.
|
||||
const asleep = bootApp({ hosts: [GATEWAY] });
|
||||
asleep.app._apiJson = async (path: string) => (path.includes('running-status') ? null : []);
|
||||
await asleep.app._openCustomModelPickModal('opencode', GATEWAY);
|
||||
expect(asleep.win.document.getElementById('customModelPickUnknown')!.hasAttribute('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('llama-swap’s own answer still wins over a session pin, and keeps its own tag', async () => {
|
||||
const booted = bootApp({
|
||||
hosts: [GATEWAY],
|
||||
sessions: [live('llama-swap/alpha-3b')],
|
||||
});
|
||||
const origApiJson = booted.app._apiJson;
|
||||
booted.app._apiJson = async (path: string) => {
|
||||
if (path === '/api/model-endpoints/bifrost/running-status') {
|
||||
return { isLlamaSwap: true, running: [{ model: 'llama-swap/zeta-70b', state: 'ready' }] };
|
||||
}
|
||||
return origApiJson(path);
|
||||
};
|
||||
|
||||
await booted.app._openCustomModelPickModal('opencode', GATEWAY);
|
||||
|
||||
const buttons = [...booted.win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
|
||||
expect(buttons[0].textContent).toContain('llama-swap/zeta-70b');
|
||||
expect(buttons[0].textContent).toContain('Currently loaded');
|
||||
expect(buttons[0].textContent).not.toContain('In use by a session');
|
||||
// The session's model is an ordinary row again: one promotion, not two.
|
||||
expect(buttons.filter((b) => b.querySelector('.set-scope'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never trusts displayModel: a session whose terminal merely printed a model marks nothing', async () => {
|
||||
const booted = gatewayApp([
|
||||
{ name: 'w9-Other', status: 'busy', createdAt: 1, displayModel: { model: 'llama-swap/zeta-70b', source: 'screen' } },
|
||||
]);
|
||||
|
||||
await booted.app._openCustomModelPickModal('opencode', GATEWAY);
|
||||
|
||||
expect(booted.win.document.getElementById('customModelPickList')!.textContent).not.toContain('In use by a session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Model Endpoint Profiles: _getCustomModelRunningStatus is client-side bounded', () => {
|
||||
// `timeoutMs` driven in milliseconds rather than the real 800 — same reasoning as
|
||||
// `_watchLlamaSwapLoading`'s own `pollIntervalMs` a few describe blocks down: this
|
||||
// code runs inside the JSDOM window's own realm, whose setTimeout vi.useFakeTimers()
|
||||
@@ -549,26 +924,31 @@ describe('Custom Model Endpoint Profiles: _getCustomModelCurrentlyLoaded is clie
|
||||
// endpoint that is asleep or firewalled, distinct from one that answers an error.
|
||||
app._apiJson = () => new Promise(() => {});
|
||||
|
||||
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5);
|
||||
const status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 5);
|
||||
|
||||
expect(result).toBeNull();
|
||||
// `null` means "no answer at all" (Codeman's own request failed or the client-side
|
||||
// bound won): the picker keeps its "Nothing is marked" line hidden for exactly this
|
||||
// case (pinned in the gateway describe above). An endpoint that is merely down is
|
||||
// NOT here: the route turns every fetch failure into `isLlamaSwap: false`.
|
||||
expect(status).toBeNull();
|
||||
expect(app._pickReadyRunningModel(status, { id: 'llama-box', models: ['qwen3'] })).toBeNull();
|
||||
});
|
||||
|
||||
it('an endpoint that answers well within the bound is unaffected by it', async () => {
|
||||
const { app } = bootApp({});
|
||||
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] });
|
||||
|
||||
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5);
|
||||
const status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 5);
|
||||
|
||||
expect(result).toBe('qwen3');
|
||||
expect(app._pickReadyRunningModel(status, { id: 'llama-box', models: ['qwen3'] })).toBe('qwen3');
|
||||
});
|
||||
|
||||
it('a rejected probe settles quietly to null rather than leaving an unhandled rejection once the timeout has already won the race', async () => {
|
||||
const { app } = bootApp({});
|
||||
app._apiJson = () => new Promise((_resolve, reject) => setTimeout(() => reject(new Error('boom')), 10));
|
||||
|
||||
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 2);
|
||||
expect(result).toBeNull();
|
||||
const status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 2);
|
||||
expect(status).toBeNull();
|
||||
// Give the loser of the race a turn to actually reject and hit its own .catch —
|
||||
// an unswallowed rejection here would surface as an "Unhandled Errors" failure
|
||||
// for the whole test file, not a failed assertion in this test.
|
||||
@@ -745,7 +1125,7 @@ describe('Custom Model Endpoint Profiles: applying a picked entry', () => {
|
||||
});
|
||||
|
||||
it('routes through run() itself, so the Run in-flight lock actually engages', async () => {
|
||||
// CLAUDE.md, Run launch synchronization: the lock exists so a double click
|
||||
// src/web/CLAUDE.md, Run launch synchronization: the lock exists so a double click
|
||||
// cannot create duplicate sessions. A hardcoded dispatch table bypassing
|
||||
// run() would never set _runInFlight, which is what this pins.
|
||||
const { app } = bootApp({});
|
||||
@@ -1281,7 +1661,7 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own
|
||||
|
||||
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
|
||||
|
||||
expect(confirmArgs).toEqual(['qwen3', 16384, 40000]);
|
||||
expect(confirmArgs).toEqual(['qwen3', 16384, 40000, undefined]);
|
||||
expect(applyBodies).toEqual([
|
||||
{ endpointId: 'llama-box', modelId: 'qwen3' },
|
||||
{ endpointId: 'llama-box', modelId: 'qwen3', confirmedContext: true },
|
||||
@@ -1289,6 +1669,29 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own
|
||||
expect(win.localStorage.getItem('codeman:customModelLastUsed:claude:llama-box')).toBe('qwen3');
|
||||
});
|
||||
|
||||
it('forwards the slot count from the apply response into the modal', async () => {
|
||||
const { app, applyBodies } = launchHarness([
|
||||
{
|
||||
requiresContextWarning: true,
|
||||
modelId: 'qwen3',
|
||||
contextLength: 131072,
|
||||
minSafeContextTokens: 40000,
|
||||
slotCount: 4,
|
||||
},
|
||||
{ customModel: { endpointId: 'llama-box' }, restarted: true, modelSwapInProgress: false },
|
||||
]);
|
||||
let confirmArgs: unknown[] | undefined;
|
||||
app._confirmContextWarning = async (...args: unknown[]) => {
|
||||
confirmArgs = args;
|
||||
return true;
|
||||
};
|
||||
|
||||
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
|
||||
|
||||
expect(confirmArgs).toEqual(['qwen3', 131072, 40000, 4]);
|
||||
expect(applyBodies).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('declining the in-app context-warning modal keeps the native backend, never re-sends the apply, and must NEVER record this model as "last used" — it cannot work with this CLI at all', async () => {
|
||||
const { win, app, applyBodies } = launchHarness([
|
||||
{ requiresContextWarning: true, modelId: 'qwen3', contextLength: 16384, minSafeContextTokens: 40000 },
|
||||
@@ -1342,8 +1745,8 @@ describe('Custom Model Endpoint Profiles: _confirmContextWarning (in-app modal,
|
||||
expect(modal.classList.contains('active')).toBe(true);
|
||||
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
|
||||
expect(message).toContain('qwen3.8-27b-ud-q4_k_xl');
|
||||
expect(message).toContain('16,384');
|
||||
expect(message).toContain('40,000');
|
||||
expect(message).toContain((16384).toLocaleString()); // the modal formats for the user's locale
|
||||
expect(message).toContain((40000).toLocaleString());
|
||||
expect(message).toMatch(/llama-swap/i);
|
||||
expect(message).toMatch(/fit-ctx/i);
|
||||
|
||||
@@ -1361,6 +1764,49 @@ describe('Custom Model Endpoint Profiles: _confirmContextWarning (in-app modal,
|
||||
expect(win.document.getElementById('customModelContextWarningModal')!.classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('explains shared parallel slots, and the --parallel 1 fix, when the context is enough only as a whole', async () => {
|
||||
const { win, app } = bootApp({});
|
||||
void app._confirmContextWarning('qwen3.8-27b-ud-q4_k_xl', 131072, 40000, 4);
|
||||
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
|
||||
expect(message).toContain('qwen3.8-27b-ud-q4_k_xl');
|
||||
expect(message).toContain('4 parallel');
|
||||
expect(message).toContain('131,072');
|
||||
expect(message).toContain('32,768'); // per slot
|
||||
expect(message).toContain('--parallel 1');
|
||||
expect(message).toContain('Context size has been exceeded');
|
||||
// this is not the "first message fails outright" case, so it must not claim that
|
||||
expect(message).not.toMatch(/first message will fail/i);
|
||||
expect(win.document.getElementById('customModelContextWarningModal')!.classList.contains('active')).toBe(true);
|
||||
app._resolveContextWarningConfirm(false);
|
||||
});
|
||||
|
||||
it('says the context is split across the slots, about N each, from the discovered context alone', async () => {
|
||||
const { win, app } = bootApp({});
|
||||
void app._confirmContextWarning('qwen3', 65536, 40000, 4);
|
||||
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
|
||||
expect(message).toContain('65,536 tokens of context are split across 4 parallel server slots, about 16,384 each');
|
||||
app._resolveContextWarningConfirm(false);
|
||||
});
|
||||
|
||||
it('keeps the too-small-context message for a small model and adds the slot advice when slots are known', async () => {
|
||||
const { win, app } = bootApp({});
|
||||
void app._confirmContextWarning('qwen3', 16384, 40000, 4);
|
||||
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
|
||||
expect(message).toContain('16,384');
|
||||
expect(message).toMatch(/first message will fail/i);
|
||||
expect(message).toContain('4 parallel slots');
|
||||
expect(message).toContain('--parallel 1');
|
||||
app._resolveContextWarningConfirm(false);
|
||||
});
|
||||
|
||||
it('does not mention slots when there is one (or none known)', async () => {
|
||||
const { win, app } = bootApp({});
|
||||
void app._confirmContextWarning('qwen3', 16384, 40000, 1);
|
||||
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
|
||||
expect(message).not.toMatch(/parallel/i);
|
||||
app._resolveContextWarningConfirm(false);
|
||||
});
|
||||
|
||||
it('describes an unknown context length without printing a bogus number', async () => {
|
||||
const { win, app } = bootApp({});
|
||||
void app._confirmContextWarning('qwen3', undefined, 40000);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @fileoverview `guardSessionConfigDirHome` probes a session-supplied configDir home before
|
||||
* the apply runs synchronous fs calls on it (the #516 rule): a home that does not answer is
|
||||
* hidden from the apply, so it links from the server's own home instead and says so.
|
||||
*
|
||||
* The probe is mocked: a mount that never answers is the case under test, and a real one
|
||||
* cannot be made here.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../src/utils/bounded-path-probe.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/utils/bounded-path-probe.js')>();
|
||||
return { ...actual, probePathKind: vi.fn() };
|
||||
});
|
||||
|
||||
import { getCli } from '../src/config/cli-registry/registry.js';
|
||||
import { probePathKind } from '../src/utils/bounded-path-probe.js';
|
||||
import { guardSessionConfigDirHome } from '../src/web/routes/session-routes.js';
|
||||
|
||||
const probe = vi.mocked(probePathKind);
|
||||
const deepseek = () => getCli('deepseek')!;
|
||||
const env =
|
||||
(values: Record<string, string>) =>
|
||||
(name: string): string | undefined =>
|
||||
values[name];
|
||||
|
||||
describe('guardSessionConfigDirHome', () => {
|
||||
afterEach(() => {
|
||||
probe.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('hides only the home when it does not answer, and warns that the server home is used', async () => {
|
||||
probe.mockResolvedValue('unknown');
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const lookup = await guardSessionConfigDirHome(deepseek(), env({ DSH_HOME: '/mnt/dead/dsh', OTHER: 'kept' }));
|
||||
|
||||
expect(lookup('DSH_HOME')).toBeUndefined();
|
||||
expect(lookup('OTHER')).toBe('kept');
|
||||
expect(probe).toHaveBeenCalledWith('/mnt/dead/dsh', { pastCap: true });
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(String(warn.mock.calls[0][0])).toContain('/mnt/dead/dsh');
|
||||
});
|
||||
|
||||
it('passes a home that answers straight through', async () => {
|
||||
probe.mockResolvedValue('directory');
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const base = env({ DSH_HOME: '/home/u/.dsh' });
|
||||
|
||||
expect(await guardSessionConfigDirHome(deepseek(), base)).toBe(base);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never probes a relative home: the apply refuses to join one', async () => {
|
||||
const base = env({ DSH_HOME: 'relative/dsh' });
|
||||
|
||||
expect(await guardSessionConfigDirHome(deepseek(), base)).toBe(base);
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never probes for a CLI with no linkFromRealHome recipe', async () => {
|
||||
const base = env({ CODEX_HOME: '/mnt/dead/codex' });
|
||||
|
||||
expect(await guardSessionConfigDirHome(getCli('codex')!, base)).toBe(base);
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the parallel-slot awareness of the custom-model context floor
|
||||
* (`perSlotContext` / `exceedsSafeContextFloor`, src/web/routes/custom-model-routes.ts).
|
||||
*
|
||||
* llama-server splits its context across `--parallel` slots (by default they share one
|
||||
* unified KV pool) and Claude Code sends concurrent requests, each carrying its own ~40k
|
||||
* tokens of system prompt and tools. Measured against a real llama-swap server: with 4 slots
|
||||
* sharing ~131k tokens, four simultaneous 40k-token requests all failed with HTTP 500
|
||||
* "Context size has been exceeded"; the same requests against one slot all succeeded.
|
||||
*
|
||||
* Port: N/A (pure functions, no server).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CLAUDE_MIN_SAFE_CONTEXT_TOKENS,
|
||||
exceedsSafeContextFloor,
|
||||
perSlotContext,
|
||||
} from '../src/web/routes/custom-model-routes.js';
|
||||
import type { CliEntry } from '../src/config/cli-registry/types.js';
|
||||
|
||||
const withContextVar = {
|
||||
capabilities: { customModelInjection: { kind: 'env', contextLengthVar: 'CLAUDE_CODE_MAX_CONTEXT_TOKENS' } },
|
||||
} as unknown as Pick<CliEntry, 'capabilities'>;
|
||||
const withoutContextVar = {
|
||||
capabilities: { customModelInjection: { kind: 'env' } },
|
||||
} as unknown as Pick<CliEntry, 'capabilities'>;
|
||||
|
||||
describe('perSlotContext', () => {
|
||||
it('divides the context across the slots', () => {
|
||||
expect(perSlotContext(131072, 4)).toBe(32768);
|
||||
expect(perSlotContext(262144, 4)).toBe(65536);
|
||||
});
|
||||
|
||||
it('rounds down, since a fraction of a token is not usable', () => {
|
||||
expect(perSlotContext(100000, 3)).toBe(33333);
|
||||
});
|
||||
|
||||
it.each([undefined, 0, 1, -2, Number.NaN, Number.POSITIVE_INFINITY])(
|
||||
'leaves the context whole for an unusable or single slot count (%s)',
|
||||
(slots) => {
|
||||
expect(perSlotContext(65536, slots as number | undefined)).toBe(65536);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('exceedsSafeContextFloor with a slot count', () => {
|
||||
it('divides only the context it is given, so a larger /props figure can never skip the warning', () => {
|
||||
// a --fit-ctx floor of 65,536 over 4 slots is 16,384 each, whatever /props might say about the server
|
||||
expect(exceedsSafeContextFloor(withContextVar, 65536, 4)).toBe(true);
|
||||
expect(exceedsSafeContextFloor(withContextVar, 65536, 1)).toBe(false);
|
||||
// the measured failure: ~131k over 4 slots is ~33k each
|
||||
expect(exceedsSafeContextFloor(withContextVar, 131072, 4)).toBe(true);
|
||||
expect(exceedsSafeContextFloor(withContextVar, 262144, 4)).toBe(false);
|
||||
});
|
||||
|
||||
it('still warns when the context itself is below the minimum', () => {
|
||||
expect(exceedsSafeContextFloor(withContextVar, 16384, 4)).toBe(true);
|
||||
expect(exceedsSafeContextFloor(withContextVar, 16384, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps its existing behaviour when no slot count is given', () => {
|
||||
expect(exceedsSafeContextFloor(withContextVar, 16384)).toBe(true);
|
||||
expect(exceedsSafeContextFloor(withContextVar, CLAUDE_MIN_SAFE_CONTEXT_TOKENS)).toBe(false);
|
||||
expect(exceedsSafeContextFloor(withContextVar, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('flags a context that only clears the floor as a whole, not per slot', () => {
|
||||
expect(exceedsSafeContextFloor(withContextVar, 131072, 4)).toBe(true);
|
||||
expect(exceedsSafeContextFloor(withContextVar, 131072, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts exactly the floor per slot', () => {
|
||||
expect(exceedsSafeContextFloor(withContextVar, CLAUDE_MIN_SAFE_CONTEXT_TOKENS * 4, 4)).toBe(false);
|
||||
expect(exceedsSafeContextFloor(withContextVar, CLAUDE_MIN_SAFE_CONTEXT_TOKENS * 4 - 4, 4)).toBe(true);
|
||||
});
|
||||
|
||||
it('never applies to a CLI that declares no context variable, whatever the slots', () => {
|
||||
expect(exceedsSafeContextFloor(withoutContextVar, 4096, 8)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import {
|
||||
buildBaseUrl,
|
||||
buildStatusUrl,
|
||||
@@ -16,9 +17,49 @@ import {
|
||||
probeServer,
|
||||
} from '../src/daemon-control.js';
|
||||
|
||||
const PORT = 3216;
|
||||
/**
|
||||
* A port nothing listens on: bind 0, read what the OS handed out, close. Free at the
|
||||
* moment of use, unlike "the server's port + 1", which anything may hold.
|
||||
*/
|
||||
async function closedPort(): Promise<number> {
|
||||
const probe = http.createServer();
|
||||
await new Promise<void>((resolve) => probe.listen(0, '127.0.0.1', resolve));
|
||||
const { port: free } = probe.address() as AddressInfo;
|
||||
await new Promise<void>((resolve) => probe.close(() => resolve()));
|
||||
return free;
|
||||
}
|
||||
|
||||
describe('buildWebArgs', () => {
|
||||
it('forwards the tunnel opt-in between the network and multi-user flags', () => {
|
||||
const options = {
|
||||
host: '0.0.0.0',
|
||||
port: 8080,
|
||||
https: true,
|
||||
titleHostname: 'tower',
|
||||
allowUnauthenticatedNetwork: true,
|
||||
allowUnauthenticatedTunnel: true,
|
||||
multiuser: true,
|
||||
};
|
||||
expect(buildWebArgs(options)).toEqual([
|
||||
'web',
|
||||
'--host',
|
||||
'0.0.0.0',
|
||||
'--port',
|
||||
'8080',
|
||||
'--https',
|
||||
'--title-hostname',
|
||||
'tower',
|
||||
'--allow-unauthenticated-network',
|
||||
'--allow-unauthenticated-tunnel',
|
||||
'--multiuser',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([false, undefined])('omits the tunnel opt-in when it is %s', (allowUnauthenticatedTunnel) => {
|
||||
const options = { host: '127.0.0.1', port: 8080, https: false, allowUnauthenticatedTunnel };
|
||||
expect(buildWebArgs(options)).toEqual(['web', '--host', '127.0.0.1', '--port', '8080']);
|
||||
});
|
||||
|
||||
it('always passes host and port through explicitly', () => {
|
||||
expect(buildWebArgs({ host: '127.0.0.1', port: 3000, https: false })).toEqual([
|
||||
'web',
|
||||
@@ -143,6 +184,7 @@ describe('isProcessAlive', () => {
|
||||
|
||||
describe('probeServer', () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
@@ -157,7 +199,8 @@ describe('probeServer', () => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, data: { version: '9.9.9' } }));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(PORT, '127.0.0.1', resolve));
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -165,23 +208,23 @@ describe('probeServer', () => {
|
||||
});
|
||||
|
||||
it('reports up and reads the version back', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT}/api/status`);
|
||||
const result = await probeServer(`http://127.0.0.1:${port}/api/status`);
|
||||
expect(result.up).toBe(true);
|
||||
expect(result.version).toBe('9.9.9');
|
||||
});
|
||||
|
||||
it('counts a 401 as up, because auth being active proves a server is there', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT}/unauthorized`);
|
||||
const result = await probeServer(`http://127.0.0.1:${port}/unauthorized`);
|
||||
expect(result.up).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mistake an unrelated service squatting on the port for Codeman', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT}/foreign`);
|
||||
const result = await probeServer(`http://127.0.0.1:${port}/foreign`);
|
||||
expect(result.up).toBe(false);
|
||||
});
|
||||
|
||||
it('reports down when nothing is listening', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT + 1}/api/status`, 1000);
|
||||
const result = await probeServer(`http://127.0.0.1:${await closedPort()}/api/status`, 1000);
|
||||
expect(result.up).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getCli } from '../src/config/cli-registry/index.js';
|
||||
import { buildCustomModelInjection } from '../src/custom-model-injection.js';
|
||||
import {
|
||||
applyCustomModelInjection,
|
||||
removeConfigDir,
|
||||
customModelConfigDir,
|
||||
} from '../src/custom-model-injection-apply.js';
|
||||
|
||||
afterEach(() => {
|
||||
removeConfigDir(customModelConfigDir('deepseek-probe'));
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('DeepSeek custom endpoint isolation', () => {
|
||||
it('uses the chosen model and limits without changing installed profiles or embedding the key', () => {
|
||||
const realHome = join(homedir(), '.dsh');
|
||||
const profile = join(realHome, 'profiles', 'dsh-tui');
|
||||
mkdirSync(profile, { recursive: true });
|
||||
mkdirSync(join(realHome, 'sessions'), { recursive: true });
|
||||
writeFileSync(join(realHome, 'settings.yaml'), 'theme: dark');
|
||||
const original = join(profile, 'cordis.patch.yml');
|
||||
writeFileSync(original, 'original profile');
|
||||
vi.stubEnv('DSH_HOME', realHome);
|
||||
const entry = getCli('deepseek');
|
||||
if (!entry) throw new Error('Missing DeepSeek entry');
|
||||
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
|
||||
const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
|
||||
if (!applied?.configDir) throw new Error('Missing isolated profile');
|
||||
const content = readFileSync(join(applied.configDir, 'cordis.patch.yml'), 'utf8');
|
||||
expect(content).not.toContain(endpoint.apiKey);
|
||||
// The dedicated key var, never DEEPSEEK_API_KEY: dsh's own llm-deepseek/web-search
|
||||
// rows read that too, and an in-session /model switch back to the official route must
|
||||
// not take the custom endpoint's key to api.deepseek.com with it.
|
||||
expect(applied.envOverrides.CODEMAN_CUSTOM_MODEL_API_KEY).toBe(endpoint.apiKey);
|
||||
expect(applied.envOverrides.DEEPSEEK_API_KEY).toBeUndefined();
|
||||
const patch = JSON.parse(content);
|
||||
const provider = patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'];
|
||||
expect(provider.api).toBe('openai-completions');
|
||||
expect(provider.baseURL).toBe('http://box:8080/v1');
|
||||
expect(provider.models).toEqual([
|
||||
{ id: 'chosen-model', name: 'chosen-model', contextWindow: 131072, maxTokens: 8192 },
|
||||
]);
|
||||
expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config).toEqual({
|
||||
provider: 'codeman-custom',
|
||||
model: 'chosen-model',
|
||||
});
|
||||
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(realHome, 'profiles'));
|
||||
// The rest of the linkFromRealHome list, consumed generically: `sessions` is the
|
||||
// transcript root removeConfigDir would otherwise delete, and the user's settings.yaml
|
||||
// must keep applying through the redirect.
|
||||
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
|
||||
expect(realpathSync(join(applied.configDir, 'settings.yaml'))).toBe(join(realHome, 'settings.yaml'));
|
||||
// Re-apply onto the same config dir (boot recovery re-runs this every restart): the
|
||||
// lstat guard must skip every existing link, which the old existsSync check missed:
|
||||
// recreating them would raise EEXIST.
|
||||
const reapplied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
|
||||
expect(reapplied?.envOverrides.DSH_HOME).toBe(applied.configDir);
|
||||
removeConfigDir(applied.configDir);
|
||||
expect(readFileSync(original, 'utf8')).toBe('original profile');
|
||||
});
|
||||
|
||||
it('creates missing directory targets before linking, skips a missing file target, stays fine on re-apply', () => {
|
||||
// A fresh install (or relocated DSH_HOME) has never run a turn, so the real home
|
||||
// carries none of the linked entries. Linking a missing DIRECTORY left a dangling
|
||||
// symlink whose every write dsh attempts THROUGH it failed with ENOENT on EVERY turn,
|
||||
// and the lstat guard kept that dangling link forever. Directories are now created
|
||||
// (0700, what dsh itself does on a first turn) before linking; a missing FILE
|
||||
// (`settings.yaml`) is simply skipped. A stubbed HOME and CODEMAN_DATA_DIR keep both
|
||||
// the fake real home and the generated dir off the developer's own dotfiles.
|
||||
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-fresh-home-'));
|
||||
const realHome = join(sandbox, '.dsh');
|
||||
mkdirSync(realHome, { recursive: true }); // exists, but no sessions/, no profiles/, no settings.yaml
|
||||
vi.stubEnv('HOME', sandbox);
|
||||
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
|
||||
vi.stubEnv('DSH_HOME', ''); // force resolution down to the recipe's `realHomeFallback`
|
||||
try {
|
||||
const entry = getCli('deepseek');
|
||||
if (!entry) throw new Error('Missing DeepSeek entry');
|
||||
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
|
||||
const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
|
||||
if (!applied?.configDir) throw new Error('Missing isolated profile');
|
||||
|
||||
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
|
||||
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(realHome, 'profiles'));
|
||||
expect(statSync(join(realHome, 'sessions')).mode & 0o777).toBe(0o700);
|
||||
// The missing FILE target is skipped outright, not a dangling link either: lstat
|
||||
// must find nothing, so a later apply (once the file exists for real) links it.
|
||||
expect(existsSync(join(realHome, 'settings.yaml'))).toBe(false);
|
||||
expect(readlinkSafe(join(applied.configDir, 'settings.yaml'))).toBeUndefined();
|
||||
|
||||
// Re-apply stays fine (boot recovery re-runs it every restart): idempotent...
|
||||
const reapplied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
|
||||
expect(reapplied?.envOverrides.DSH_HOME).toBe(applied.configDir);
|
||||
// ...and a target deleted AFTER linking keeps the existing link untouched (the
|
||||
// lstat guard), rather than recreating it and throwing EEXIST.
|
||||
rmSync(join(realHome, 'sessions'), { recursive: true, force: true });
|
||||
expect(
|
||||
applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072)?.envOverrides.DSH_HOME
|
||||
).toBe(applied.configDir);
|
||||
expect(readlinkSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
|
||||
} finally {
|
||||
rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('links from the SESSION-supplied DSH_HOME, not the server environment one', () => {
|
||||
// Both web routes pass the session's own env-override lookup as the seventh
|
||||
// argument; a user who relocated the dsh home via an allowlisted DSH_HOME
|
||||
// override must get their state linked from THAT directory, not the server's.
|
||||
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-session-home-'));
|
||||
const serverHome = join(sandbox, 'server-home');
|
||||
const sessionHome = join(sandbox, 'session-home');
|
||||
mkdirSync(join(sessionHome, 'sessions'), { recursive: true });
|
||||
mkdirSync(join(sessionHome, 'profiles'), { recursive: true });
|
||||
writeFileSync(join(sessionHome, 'settings.yaml'), 'theme: light');
|
||||
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
|
||||
vi.stubEnv('DSH_HOME', serverHome);
|
||||
try {
|
||||
const entry = getCli('deepseek');
|
||||
if (!entry) throw new Error('Missing DeepSeek entry');
|
||||
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
|
||||
const applied = applyCustomModelInjection(
|
||||
entry,
|
||||
endpoint,
|
||||
'chosen-model',
|
||||
'deepseek-probe',
|
||||
131072,
|
||||
undefined,
|
||||
(k) => (k === 'DSH_HOME' ? sessionHome : undefined)
|
||||
);
|
||||
if (!applied?.configDir) throw new Error('Missing isolated profile');
|
||||
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(sessionHome, 'sessions'));
|
||||
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(sessionHome, 'profiles'));
|
||||
expect(realpathSync(join(applied.configDir, 'settings.yaml'))).toBe(join(sessionHome, 'settings.yaml'));
|
||||
// The server-environment home was never consulted, let alone created.
|
||||
expect(existsSync(serverHome)).toBe(false);
|
||||
} finally {
|
||||
rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('never self-links when the session override IS its own config dir; falls through to the server home', () => {
|
||||
// Boot recovery re-applies onto a session already holding a previous injection's
|
||||
// DSH_HOME value; linking that value would point the isolated directory at itself.
|
||||
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-self-home-'));
|
||||
const serverHome = join(sandbox, 'server-home');
|
||||
mkdirSync(join(serverHome, 'sessions'), { recursive: true });
|
||||
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
|
||||
vi.stubEnv('DSH_HOME', serverHome);
|
||||
try {
|
||||
const entry = getCli('deepseek');
|
||||
if (!entry) throw new Error('Missing DeepSeek entry');
|
||||
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
|
||||
const applied = applyCustomModelInjection(
|
||||
entry,
|
||||
endpoint,
|
||||
'chosen-model',
|
||||
'deepseek-probe',
|
||||
131072,
|
||||
undefined,
|
||||
(k) => (k === 'DSH_HOME' ? customModelConfigDir('deepseek-probe') : undefined)
|
||||
);
|
||||
if (!applied?.configDir) throw new Error('Missing isolated profile');
|
||||
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(serverHome, 'sessions'));
|
||||
} finally {
|
||||
rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('skips a relative DSH_HOME rather than creating it under the server cwd', () => {
|
||||
// A session may supply DSH_HOME; `work` would otherwise mkdir `work/sessions` in
|
||||
// the SERVER's working directory and leave relative links that dangle (a relative
|
||||
// home resolves against the CLI's cwd, which the server cannot know). Skipping it
|
||||
// falls through to the recipe's `realHomeFallback`, and nothing is created under cwd.
|
||||
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-relative-home-'));
|
||||
const fallbackHome = join(sandbox, '.dsh');
|
||||
mkdirSync(fallbackHome, { recursive: true });
|
||||
const relativeHome = `dsh-relative-probe-${process.pid}-${Date.now()}`;
|
||||
vi.stubEnv('HOME', sandbox); // the fallback resolves under homedir()
|
||||
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
|
||||
vi.stubEnv('DSH_HOME', relativeHome);
|
||||
try {
|
||||
const entry = getCli('deepseek');
|
||||
if (!entry) throw new Error('Missing DeepSeek entry');
|
||||
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
|
||||
const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
|
||||
if (!applied?.configDir) throw new Error('Missing isolated profile');
|
||||
expect(existsSync(join(process.cwd(), relativeHome))).toBe(false);
|
||||
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(fallbackHome, 'sessions'));
|
||||
} finally {
|
||||
rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** readlinkSync without the throw an absent link causes (ENOENT -> undefined). */
|
||||
function readlinkSafe(path: string): string | undefined {
|
||||
try {
|
||||
return readlinkSync(path);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function deepseekInstallCommand(platform: 'linux' | 'darwin'): string {
|
||||
const ds = STOCK_CLIS.find((c) => (c.id as string) === 'deepseek')!;
|
||||
return ds.discovery.install.command![platform]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the stock install command through `/bin/sh -c` (what CLI management does, `shell: true`)
|
||||
* with a fake `npm` that records each call, on a PATH that holds only the fake bins. So this
|
||||
* exercises the real string, not a copy of it.
|
||||
*/
|
||||
function runInstall(opts: { pnpmOnPath: boolean; npmFails?: boolean }): { status: number | null; npmCalls: string[] } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-install-'));
|
||||
dirs.push(dir);
|
||||
const npmBin = join(dir, 'npm-bin');
|
||||
const pnpmBin = join(dir, 'pnpm-bin');
|
||||
mkdirSync(npmBin);
|
||||
mkdirSync(pnpmBin);
|
||||
const log = join(dir, 'npm.log');
|
||||
writeFileSync(log, '');
|
||||
writeFileSync(join(npmBin, 'npm'), '#!/bin/sh\necho "$*" >> "$NPM_LOG"\nexit "${NPM_EXIT:-0}"\n');
|
||||
chmodSync(join(npmBin, 'npm'), 0o755);
|
||||
writeFileSync(join(pnpmBin, 'pnpm'), '#!/bin/sh\nexit 0\n');
|
||||
chmodSync(join(pnpmBin, 'pnpm'), 0o755);
|
||||
const result = spawnSync('/bin/sh', ['-c', deepseekInstallCommand('linux')], {
|
||||
env: {
|
||||
PATH: opts.pnpmOnPath ? `${npmBin}:${pnpmBin}` : npmBin,
|
||||
NPM_LOG: log,
|
||||
NPM_EXIT: opts.npmFails ? '1' : '0',
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const npmCalls = readFileSync(log, 'utf-8').split('\n').filter(Boolean);
|
||||
return { status: result.status, npmCalls };
|
||||
}
|
||||
|
||||
describe('DeepSeek install', () => {
|
||||
it('is the same command on linux and darwin', () => {
|
||||
expect(deepseekInstallCommand('darwin')).toBe(deepseekInstallCommand('linux'));
|
||||
});
|
||||
|
||||
it('installs pnpm with dsh when none is on PATH, since `dsh plugin` spawns a literal pnpm', () => {
|
||||
const { status, npmCalls } = runInstall({ pnpmOnPath: false });
|
||||
expect(status).toBe(0);
|
||||
expect(npmCalls).toEqual(['install -g @deepseek-ai/dsh', 'install -g pnpm']);
|
||||
});
|
||||
|
||||
it('leaves an existing pnpm alone (a corepack shim makes `npm install -g pnpm` fail with EEXIST)', () => {
|
||||
const { status, npmCalls } = runInstall({ pnpmOnPath: true });
|
||||
expect(status).toBe(0);
|
||||
expect(npmCalls).toEqual(['install -g @deepseek-ai/dsh']);
|
||||
});
|
||||
|
||||
it('reports a failed dsh install and does not go on to pnpm', () => {
|
||||
const { status, npmCalls } = runInstall({ pnpmOnPath: false, npmFails: true });
|
||||
expect(status).not.toBe(0);
|
||||
expect(npmCalls).toEqual(['install -g @deepseek-ai/dsh']);
|
||||
});
|
||||
|
||||
it("puts dsh's own directory first on the profile install's PATH", () => {
|
||||
const src = readFileSync(resolve(root, 'src/web/routes/system-routes.ts'), 'utf8');
|
||||
expect(src).toMatch(/env: \{ \.\.\.process\.env, PATH: \[dir, process\.env\.PATH\]/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* The model a dsh session's TUI route config pins (src/deepseek-route-config.ts),
|
||||
* shown in a session header when the TUI's screen names none.
|
||||
*
|
||||
* Fixture dsh homes are built in a temp directory. The two user layers below are the
|
||||
* owner's real files on 2026-10-07, verbatim apart from the comment text: the
|
||||
* profile layer pins the dsh-tui row's route (provider AND model), the home layer
|
||||
* pins `agent-default-model`, which is the headless default and NOT the TUI's.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DSH_TUI_PACKAGE,
|
||||
MAX_ROUTE_FILE_BYTES,
|
||||
effectiveDshHome,
|
||||
parseDshTuiPatches,
|
||||
readDeepSeekRouteModel,
|
||||
resolveDshTuiRouteModel,
|
||||
} from '../src/deepseek-route-config.js';
|
||||
|
||||
const PROFILE_LAYER = `# Your patch layer for this dsh profile, applied after every bundle layer:
|
||||
# a top-level YAML array of loader patch entries (id-targeted config
|
||||
# overrides, disables, and insert lists; \`!!js\` expressions allowed).
|
||||
#
|
||||
# Route the TUI at the 5090 box's live serve. A config route counts only
|
||||
# when it names BOTH provider and model.
|
||||
- id: dsh-tui
|
||||
config:
|
||||
provider: qwen5090
|
||||
model: qwen3.8-27b
|
||||
effort: medium
|
||||
preset: qwen5090
|
||||
`;
|
||||
const HOME_LAYER = `# Home-level patch layer. The default model is a composition entry,
|
||||
# not a settings key.
|
||||
- id: agent-default-model
|
||||
config:
|
||||
provider: qwen5090
|
||||
model: qwen3.8-27b
|
||||
`;
|
||||
const SETTINGS = `agent-default-model:
|
||||
provider: qwen5090
|
||||
model: qwen3.8-27b
|
||||
`;
|
||||
const TUI_BUNDLES = ['@deepseek-ai/dsh-base', DSH_TUI_PACKAGE];
|
||||
|
||||
const homes: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of homes.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A fixture dsh home: profiles (bundles + optional patch layer), a home layer, settings.yaml. */
|
||||
function makeHome(spec: {
|
||||
profiles?: Record<string, { bundles?: string[]; patch?: string }>;
|
||||
homePatch?: string;
|
||||
settings?: string;
|
||||
}): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-route-'));
|
||||
homes.push(root);
|
||||
const home = join(root, 'dsh');
|
||||
mkdirSync(join(home, 'profiles'), { recursive: true });
|
||||
for (const [name, p] of Object.entries(spec.profiles ?? {})) {
|
||||
mkdirSync(join(home, 'profiles', name), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, 'profiles', name, 'package.json'),
|
||||
JSON.stringify({ name: `dsh-profile-${name}`, dsh: { profile: { bundles: p.bundles ?? TUI_BUNDLES } } })
|
||||
);
|
||||
if (p.patch !== undefined) writeFileSync(join(home, 'profiles', name, 'cordis.patch.yml'), p.patch);
|
||||
}
|
||||
if (spec.homePatch !== undefined) writeFileSync(join(home, 'cordis.patch.yml'), spec.homePatch);
|
||||
if (spec.settings !== undefined) writeFileSync(join(home, 'settings.yaml'), spec.settings);
|
||||
return home;
|
||||
}
|
||||
|
||||
const read = (home: string, profile: string | undefined = 'dsh-tui', serverHome: string | null = home) =>
|
||||
readDeepSeekRouteModel({ profile, home, serverHome });
|
||||
|
||||
describe('readDeepSeekRouteModel', () => {
|
||||
it('a route pinned with both provider and model: the model', async () => {
|
||||
const home = makeHome({
|
||||
profiles: { 'dsh-tui': { patch: PROFILE_LAYER } },
|
||||
homePatch: HOME_LAYER,
|
||||
settings: SETTINGS,
|
||||
});
|
||||
expect(await read(home)).toBe('qwen3.8-27b');
|
||||
});
|
||||
|
||||
it('a half-pinned route (provider alone): nothing, the TUI drops it whole', async () => {
|
||||
const half = '- id: dsh-tui\n config:\n provider: qwen5090\n effort: medium\n';
|
||||
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: half } } }))).toBeNull();
|
||||
const modelOnly = '- id: dsh-tui\n config:\n model: qwen3.8-27b\n';
|
||||
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: modelOnly } } }))).toBeNull();
|
||||
});
|
||||
|
||||
it('no profile layer: nothing (the bundle pins a provider alone, by design)', async () => {
|
||||
expect(await read(makeHome({ profiles: { 'dsh-tui': {} }, homePatch: HOME_LAYER, settings: SETTINGS }))).toBeNull();
|
||||
});
|
||||
|
||||
it("settings.yaml and the home layer's agent-default-model are the headless default, never the TUI's", async () => {
|
||||
expect(await read(makeHome({ profiles: { 'dsh-tui': {} }, settings: SETTINGS }))).toBeNull();
|
||||
expect(await read(makeHome({ profiles: { 'dsh-tui': {} }, homePatch: HOME_LAYER }))).toBeNull();
|
||||
});
|
||||
|
||||
it('a layer that does not parse: nothing', async () => {
|
||||
const broken = [
|
||||
'- id: dsh-tui\n config:\n\tprovider: qwen5090\n\tmodel: x\n', // tab indentation
|
||||
'- id: dsh-tui\n config:\n \tprovider: qwen5090\n \tmodel: x\n', // a tab inside the indentation
|
||||
"- id: dsh-tui\n config:\n provider: 'qwen5090\n model: x\n", // unterminated quote
|
||||
'- id: dsh-tui\n config:\n provider: qwen5090\n model: qwen3.8-27b\n model: other\n', // duplicate key
|
||||
'dsh-tui:\n provider: qwen5090\n', // not a sequence
|
||||
];
|
||||
for (const patch of broken) {
|
||||
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch } } })), patch).toBeNull();
|
||||
}
|
||||
// The home layer is read too: a broken one blanks a good profile layer.
|
||||
const home = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } }, homePatch: '- id: [\n' });
|
||||
expect(await read(home)).toBeNull();
|
||||
});
|
||||
|
||||
it('a layer that is a symlink out of the dsh home: nothing; one inside it is followed', async () => {
|
||||
const home = makeHome({ profiles: { 'dsh-tui': {} } });
|
||||
const outside = join(home, '..', 'outside.yml');
|
||||
writeFileSync(outside, PROFILE_LAYER);
|
||||
symlinkSync(outside, join(home, 'profiles', 'dsh-tui', 'cordis.patch.yml'));
|
||||
expect(await read(home)).toBeNull();
|
||||
|
||||
const inside = makeHome({ profiles: { 'dsh-tui': {} } });
|
||||
writeFileSync(join(inside, 'shared-route.yml'), PROFILE_LAYER);
|
||||
symlinkSync(join(inside, 'shared-route.yml'), join(inside, 'profiles', 'dsh-tui', 'cordis.patch.yml'));
|
||||
expect(await read(inside)).toBe('qwen3.8-27b');
|
||||
});
|
||||
|
||||
it("a session's own DSH_HOME is the one read", async () => {
|
||||
const server = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } } });
|
||||
const own = makeHome({
|
||||
profiles: {
|
||||
'dsh-tui': { patch: '- id: dsh-tui\n config:\n provider: deepseek-official\n model: deepseek-v4-pro\n' },
|
||||
},
|
||||
});
|
||||
expect(await read(own, 'dsh-tui', server)).toBe('deepseek-v4-pro');
|
||||
expect(await read(server, 'dsh-tui', server)).toBe('qwen3.8-27b');
|
||||
// The status bar can be switched off in that home without touching the route.
|
||||
expect(effectiveDshHome((k) => (k === 'DSH_HOME' ? own : undefined))).toBe(own);
|
||||
});
|
||||
|
||||
it('the home layer outranks the profile layer, and replaces its whole config', async () => {
|
||||
const pin = '- id: dsh-tui\n config:\n provider: deepseek-official\n model: deepseek-v4-flash\n';
|
||||
const homeWins = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } }, homePatch: pin });
|
||||
expect(await read(homeWins)).toBe('deepseek-v4-flash');
|
||||
const half = '- id: dsh-tui\n config:\n effort: high\n';
|
||||
const halved = makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } }, homePatch: half });
|
||||
expect(await read(halved)).toBeNull();
|
||||
});
|
||||
|
||||
it('no profile named: the one the launch would boot (the first terminal profile)', async () => {
|
||||
const home = makeHome({
|
||||
profiles: {
|
||||
web: { bundles: ['@deepseek-ai/dsh-web-app'] },
|
||||
'dsh-tui': { patch: PROFILE_LAYER },
|
||||
},
|
||||
});
|
||||
expect(await read(home, undefined)).toBe('qwen3.8-27b');
|
||||
// An invalid name reads as unset at launch, so the default applies here too.
|
||||
expect(await read(home, '../web')).toBe('qwen3.8-27b');
|
||||
});
|
||||
|
||||
it('a profile that does not compose dsh-TUI: nothing (its route is not this reader’s to know)', async () => {
|
||||
const home = makeHome({ profiles: { other: { bundles: ['@someone/other-tui'], patch: PROFILE_LAYER } } });
|
||||
expect(await read(home, 'other')).toBeNull();
|
||||
});
|
||||
|
||||
it('a disabled row, a name mismatch, a missing home or profile: nothing', async () => {
|
||||
const disabled = `${PROFILE_LAYER}- id: dsh-tui\n disabled: true\n`;
|
||||
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: disabled } } }))).toBeNull();
|
||||
const mismatch = '- id: dsh-tui\n name: "@someone/else"\n config:\n provider: a\n model: b\n';
|
||||
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: mismatch } } }))).toBeNull();
|
||||
expect(await read(join(tmpdir(), 'no-such-dsh-home-xyz'))).toBeNull();
|
||||
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: PROFILE_LAYER } } }), 'missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('an oversized layer: nothing', async () => {
|
||||
const big = PROFILE_LAYER + `# ${'x'.repeat(MAX_ROUTE_FILE_BYTES)}\n`;
|
||||
expect(await read(makeHome({ profiles: { 'dsh-tui': { patch: big } } }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveDshHome', () => {
|
||||
it("the session's, else ~/.dsh; a relative value names no place", () => {
|
||||
expect(effectiveDshHome(() => '/srv/dsh')).toBe('/srv/dsh');
|
||||
expect(effectiveDshHome(() => ' ')).toMatch(/\.dsh$/);
|
||||
expect(effectiveDshHome(() => undefined)).toMatch(/\.dsh$/);
|
||||
expect(effectiveDshHome(() => 'dsh-copy')).toBeNull();
|
||||
expect(effectiveDshHome(() => '~/dsh')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDshTuiPatches (the narrow YAML subset)', () => {
|
||||
it("reads the owner's two layers", () => {
|
||||
expect(parseDshTuiPatches(PROFILE_LAYER)).toEqual([{ config: { provider: 'qwen5090', model: 'qwen3.8-27b' } }]);
|
||||
expect(parseDshTuiPatches(HOME_LAYER)).toEqual([]);
|
||||
});
|
||||
|
||||
it('an empty list, comments only, and a leading document marker', () => {
|
||||
expect(parseDshTuiPatches('[]\n')).toEqual([]);
|
||||
expect(parseDshTuiPatches('# nothing yet\n')).toEqual([]);
|
||||
expect(parseDshTuiPatches(`---\n${PROFILE_LAYER}`)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('quoted scalars, a key on its own line under `-`, a wider item indent', () => {
|
||||
expect(
|
||||
parseDshTuiPatches('- id: "dsh-tui"\n config:\n provider: "qwen5090"\n model: \'qwen3.8-27b\'\n')
|
||||
).toEqual([{ config: { provider: 'qwen5090', model: 'qwen3.8-27b' } }]);
|
||||
expect(parseDshTuiPatches('-\n id: dsh-tui\n config:\n provider: p\n model: m # trailing\n')).toEqual([
|
||||
{ config: { provider: 'p', model: 'm' } },
|
||||
]);
|
||||
expect(parseDshTuiPatches('- id: dsh-tui\n config:\n provider: p\n model: m\n')).toEqual([
|
||||
{ config: { provider: 'p', model: 'm' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('anything that could change the answer and is beyond the subset: null', () => {
|
||||
const cases = [
|
||||
'- id: dsh-tui\n config:\n provider: !!js process.env.P\n model: m\n', // a tag
|
||||
'- id: dsh-tui\n config: &route\n provider: p\n model: m\n', // an anchor
|
||||
'- id: other\n config: &r { provider: p, model: m }\n- id: dsh-tui\n config: *r\n', // an alias
|
||||
'- id: dsh-tui\n config:\n <<: { provider: p, model: m }\n', // a merge key
|
||||
'- id: dsh-tui\n config: { provider: p, model: m }\n', // a flow config
|
||||
'- id: dsh-tui\n config:\n provider: p\n model: 12\n', // a number, not a string
|
||||
'- id: dsh-tui\n config:\n provider: p\n model: |\n m\n', // a block scalar
|
||||
'- insert:\n - id: dsh-tui\n name: x\n', // re-inserting the row
|
||||
`${PROFILE_LAYER}---\n- id: dsh-tui\n`, // a second document
|
||||
'%YAML 1.2\n---\n- id: dsh-tui\n', // a directive
|
||||
// A quoted scalar spanning lines swallows the next "key": YAML reads note as
|
||||
// "start model: m" and the route as half-pinned.
|
||||
'- id: dsh-tui\n config:\n note: "start\n model: m"\n provider: p\n',
|
||||
// A plain scalar continued on the next line is one value ("m continued").
|
||||
'- id: dsh-tui\n config:\n provider: p\n model: m\n continued\n',
|
||||
// A group row whose config re-defines a dsh-tui row inside it.
|
||||
'- id: tui-group\n config:\n - id: dsh-tui\n config:\n provider: p\n model: m\n',
|
||||
'- id: tui-group\n config:\n - id: dsh-tui\n config:\n provider: p\n model: m\n',
|
||||
];
|
||||
for (const text of cases) expect(parseDshTuiPatches(text), text).toBeNull();
|
||||
});
|
||||
|
||||
it('an unrelated item it cannot follow is skipped, one about dsh-tui is not', () => {
|
||||
// `rules`' sequence at its key's own indent is valid YAML but beyond the subset.
|
||||
const odd = '- id: approval\n rules:\n - a\n config:\n policy: |\n line one\n';
|
||||
expect(parseDshTuiPatches(`${odd}${PROFILE_LAYER}`)).toEqual([
|
||||
{ config: { provider: 'qwen5090', model: 'qwen3.8-27b' } },
|
||||
]);
|
||||
expect(parseDshTuiPatches(`- id: dsh-tui\n config:\n rules:\n - a\n`)).toBeNull();
|
||||
});
|
||||
|
||||
it('a disabled flag, a name, and an empty config', () => {
|
||||
expect(parseDshTuiPatches('- id: dsh-tui\n disabled: true\n name: x\n config: {}\n')).toEqual([
|
||||
{ disabled: true, name: 'x', config: {} },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDshTuiRouteModel', () => {
|
||||
it('the last config wins whole; a layer that could not be read blanks the answer', () => {
|
||||
const full = { config: { provider: 'p', model: 'm' } };
|
||||
expect(resolveDshTuiRouteModel([[full], []])).toBe('m');
|
||||
expect(resolveDshTuiRouteModel([[full], [{ config: { provider: 'p' } }]])).toBeNull();
|
||||
expect(resolveDshTuiRouteModel([[full], null])).toBeNull();
|
||||
expect(resolveDshTuiRouteModel([[], []])).toBeNull();
|
||||
expect(resolveDshTuiRouteModel([[full, { disabled: true }]])).toBeNull();
|
||||
expect(resolveDshTuiRouteModel([[full, { name: '@someone/else', config: {} }]])).toBe('m');
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@
|
||||
import { describe, expect, it, beforeEach, beforeAll, afterAll } from 'vitest';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, chmodSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import {
|
||||
@@ -25,8 +26,6 @@ import {
|
||||
DEEPSEEK_STATE_TO_HOOK_EVENT,
|
||||
} from '../src/deepseek-status-shim.js';
|
||||
|
||||
const PORT = 3251;
|
||||
|
||||
describe('DeepSeek status shim: provisioning', () => {
|
||||
beforeEach(() => {
|
||||
resetDeepSeekStatusShimForTest();
|
||||
@@ -73,6 +72,7 @@ describe('DeepSeek status shim: provisioning', () => {
|
||||
|
||||
describe('DeepSeek status shim: the supervisor contract', () => {
|
||||
let server: Server | undefined;
|
||||
let port: number;
|
||||
const received: Array<{ body: unknown; secret: string | undefined }> = [];
|
||||
let status = 200;
|
||||
|
||||
@@ -96,7 +96,10 @@ describe('DeepSeek status shim: the supervisor contract', () => {
|
||||
res.end('{}');
|
||||
});
|
||||
});
|
||||
server.listen(PORT, '127.0.0.1', resolve);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
port = (server!.address() as AddressInfo).port;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
beforeAll(() => listen());
|
||||
@@ -120,7 +123,7 @@ describe('DeepSeek status shim: the supervisor contract', () => {
|
||||
const child = spawn(process.execPath, [path, ...args], {
|
||||
env: {
|
||||
...process.env,
|
||||
CODEMAN_API_URL: `http://127.0.0.1:${PORT}`,
|
||||
CODEMAN_API_URL: `http://127.0.0.1:${port}`,
|
||||
CODEMAN_SESSION_ID: 'sess-from-env',
|
||||
...env,
|
||||
},
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
* Two of these assertions exist because the obvious implementation was measured
|
||||
* to be wrong against real files:
|
||||
*
|
||||
* - dsh appends ONE ZSTD FRAME PER WRITE, and Node's `zlib` zstd decoder stops
|
||||
* at the first frame end. A 56-line transcript decoded as 1 line / 158 bytes,
|
||||
* which reads as "the worker never answered" rather than as an error. The
|
||||
* multi-frame fixtures below are the guard.
|
||||
* - dsh appends ONE ZSTD FRAME PER WRITE, and Node's `zlib` zstd decoder
|
||||
* before 24.22 and 26.10 stops at the first frame end. A 56-line transcript
|
||||
* decoded as 1 line / 158 bytes, which reads as "the worker never answered"
|
||||
* rather than as an error. Newer Node decodes every frame (nodejs/node#65865),
|
||||
* so the multi-frame fixtures below pin the walker itself, not Node's decoder.
|
||||
* - a fresh worker in a case directory that had been used before answered its
|
||||
* first `last-response` with the PREVIOUS session's reply. Session-to-
|
||||
* transcript pairing is therefore its own describe block.
|
||||
@@ -67,10 +68,6 @@ describe.skipIf(!zstdSupported())('zstd frame walking', () => {
|
||||
const lines = Array.from({ length: 40 }, (_, i) => JSON.stringify({ type: 'noise', seq: i }));
|
||||
const buf = framed(lines);
|
||||
|
||||
// The one-shot decoder is what this module had to replace.
|
||||
const oneShot = (zlib as unknown as { zstdDecompressSync?: (b: Buffer) => Buffer }).zstdDecompressSync!(buf);
|
||||
expect(oneShot.toString('utf8').trim().split('\n')).toHaveLength(1);
|
||||
|
||||
expect(zstdFrameRanges(buf)).toHaveLength(40);
|
||||
expect(decodeZstdFrames(buf).trim().split('\n')).toHaveLength(40);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dependencyRegistry } from '../src/config/dependency-registry.js';
|
||||
import {
|
||||
detectEnvironment,
|
||||
@@ -129,6 +132,7 @@ function fakeHost(env: ProbeEnvironment, over: Partial<ProbeHost> = {}): ProbeHo
|
||||
environment: env,
|
||||
which: () => null,
|
||||
fileExists: () => false,
|
||||
isExecutableFile: () => false,
|
||||
runVersion: () => null,
|
||||
windowsProgramRoots: () => [],
|
||||
windowsFileVersion: () => null,
|
||||
@@ -259,6 +263,121 @@ describe('checkTool with requireVersionMatch (generic binary names)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkTool with searchDirs (service PATH is minimal)', () => {
|
||||
const claudeLike: ToolDependency = {
|
||||
...tmuxTool,
|
||||
id: 'claude',
|
||||
label: 'Claude CLI',
|
||||
resolvers: [
|
||||
{
|
||||
match: ['linux'],
|
||||
resolver: { kind: 'path', bins: ['claude'], searchDirs: ['/home/u/.local/bin', '/opt/npm/bin/'] },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('finds a CLI that only lives in a searchDirs entry and runs --version on the absolute path', () => {
|
||||
const runVersion = vi.fn(() => 'claude 2.1.0');
|
||||
const host = fakeHost('linux', { isExecutableFile: (p) => p === '/opt/npm/bin/claude', runVersion });
|
||||
expect(checkTool(claudeLike, host)).toMatchObject({
|
||||
status: 'ok',
|
||||
path: '/opt/npm/bin/claude',
|
||||
version: '2.1.0',
|
||||
});
|
||||
expect(runVersion).toHaveBeenCalledWith('/opt/npm/bin/claude', ['--version']);
|
||||
});
|
||||
|
||||
it('still reports missing when neither PATH nor any search dir has it', () => {
|
||||
expect(checkTool(claudeLike, fakeHost('linux'))).toMatchObject({ status: 'missing' });
|
||||
});
|
||||
|
||||
it('prefers the PATH hit over a search dir', () => {
|
||||
const host = fakeHost('linux', {
|
||||
which: () => '/usr/bin/claude',
|
||||
isExecutableFile: () => true,
|
||||
runVersion: () => '1.0.0',
|
||||
});
|
||||
expect(checkTool(claudeLike, host)).toMatchObject({ path: '/usr/bin/claude' });
|
||||
});
|
||||
|
||||
// The run mode's resolver (createCliExecutableResolver) accepts a search-dir candidate only
|
||||
// as an absolute path to an executable regular file. A file that merely exists is not one.
|
||||
it('skips a search-dir file that exists but is not executable', () => {
|
||||
const runVersion = vi.fn(() => 'claude 2.1.0');
|
||||
const host = fakeHost('linux', { fileExists: () => true, isExecutableFile: () => false, runVersion });
|
||||
expect(checkTool(claudeLike, host)).toMatchObject({ status: 'missing' });
|
||||
expect(runVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a relative search dir (a custom clis.json entry) as the resolver does', () => {
|
||||
const relative: ToolDependency = {
|
||||
...claudeLike,
|
||||
resolvers: [{ match: ['linux'], resolver: { kind: 'path', bins: ['claude'], searchDirs: ['tools/bin'] } }],
|
||||
};
|
||||
const runVersion = vi.fn(() => 'claude 2.1.0');
|
||||
const host = fakeHost('linux', { fileExists: () => true, isExecutableFile: () => true, runVersion });
|
||||
expect(checkTool(relative, host)).toMatchObject({ status: 'missing' });
|
||||
expect(runVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The grok case: an npm squatter answers on the PATH while the real CLI sits in ~/.grok/bin.
|
||||
// The Run menu's resolver rejects the squatter and moves on to the search dirs; the doctor
|
||||
// used to stop at the PATH hit and report MISSING.
|
||||
const squatted: ToolDependency = {
|
||||
...claudeLike,
|
||||
id: 'pi',
|
||||
label: 'Pi CLI',
|
||||
resolvers: [
|
||||
{
|
||||
match: ['linux'],
|
||||
resolver: {
|
||||
kind: 'path',
|
||||
bins: ['pi'],
|
||||
versionRegex: PI_VERSION_REGEX,
|
||||
requireVersionMatch: true,
|
||||
searchDirs: ['/home/u/.local/bin', '/home/u/.npm-global/bin'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('finds the right binary in a search dir when a wrong one is on the PATH', () => {
|
||||
const host = fakeHost('linux', {
|
||||
which: () => '/usr/bin/pi',
|
||||
isExecutableFile: (p) => p === '/home/u/.npm-global/bin/pi',
|
||||
runVersion: (bin) => (bin === '/usr/bin/pi' ? 'Raspberry Pi utility\n' : '0.84.3\n'),
|
||||
});
|
||||
expect(checkTool(squatted, host)).toMatchObject({
|
||||
status: 'ok',
|
||||
path: '/home/u/.npm-global/bin/pi',
|
||||
version: '0.84.3',
|
||||
});
|
||||
});
|
||||
|
||||
it('version-checks each search-dir candidate and moves past one that fails', () => {
|
||||
const runVersion = vi.fn((bin: string) => (bin === '/home/u/.local/bin/pi' ? 'something else\n' : '0.84.3\n'));
|
||||
const host = fakeHost('linux', { isExecutableFile: () => true, runVersion });
|
||||
expect(checkTool(squatted, host)).toMatchObject({ status: 'ok', path: '/home/u/.npm-global/bin/pi' });
|
||||
expect(runVersion.mock.calls.map(([bin]) => bin)).toEqual(['/home/u/.local/bin/pi', '/home/u/.npm-global/bin/pi']);
|
||||
});
|
||||
|
||||
it('probes a search dir that is also on the PATH only once', () => {
|
||||
const runVersion = vi.fn(() => 'Raspberry Pi utility\n');
|
||||
const host = fakeHost('linux', { which: () => '/home/u/.local/bin/pi', isExecutableFile: () => true, runVersion });
|
||||
expect(checkTool(squatted, host)).toMatchObject({ status: 'missing' });
|
||||
expect(runVersion.mock.calls.map(([bin]) => bin)).toEqual(['/home/u/.local/bin/pi', '/home/u/.npm-global/bin/pi']);
|
||||
});
|
||||
|
||||
it('carries each enabled CLI’s expanded discovery.searchDirs onto its registry row', () => {
|
||||
const rows = dependencyRegistry().flatMap((t) => t.resolvers.map((r) => r.resolver));
|
||||
const withDirs = rows.filter((r) => r.kind === 'path' && r.searchDirs?.length);
|
||||
expect(withDirs.length).toBeGreaterThan(0);
|
||||
for (const r of withDirs) {
|
||||
if (r.kind === 'path') for (const d of r.searchDirs ?? []) expect(d.startsWith('~')).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAll', () => {
|
||||
it('maps every tool to a result', () => {
|
||||
const results = checkAll([tmuxTool, msTool], fakeHost('linux'));
|
||||
@@ -273,4 +392,20 @@ describe('createRealHost', () => {
|
||||
expect(typeof host.which).toBe('function');
|
||||
expect(Array.isArray(host.windowsProgramRoots())).toBe(true);
|
||||
});
|
||||
|
||||
it('counts only an executable regular file as a search-dir candidate', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'doctor-exec-'));
|
||||
try {
|
||||
const file = join(dir, 'tool');
|
||||
writeFileSync(file, '#!/bin/sh\necho 1.0.0\n', { mode: 0o644 });
|
||||
const host = createRealHost();
|
||||
expect(host.isExecutableFile(file)).toBe(false);
|
||||
chmodSync(file, 0o755);
|
||||
expect(host.isExecutableFile(file)).toBe(true);
|
||||
expect(host.isExecutableFile(dir)).toBe(false);
|
||||
expect(host.isExecutableFile(join(dir, 'absent'))).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -46,10 +46,17 @@ function lockedVersions(lock: PackageLock, packageName: string): string[] {
|
||||
return [...versions].sort();
|
||||
}
|
||||
|
||||
function expectEveryLockedVersionAtLeast(lock: PackageLock, packageName: string, minimum: string): void {
|
||||
function expectEveryLockedVersionAtLeast(
|
||||
lock: PackageLock,
|
||||
packageName: string,
|
||||
minimum: string,
|
||||
/** Exact versions deliberately outside this policy; each call site says why. */
|
||||
exempt: string[] = []
|
||||
): void {
|
||||
const versions = lockedVersions(lock, packageName);
|
||||
expect(versions, `${packageName} should be present in package-lock.json`).not.toHaveLength(0);
|
||||
for (const version of versions) {
|
||||
if (exempt.includes(version)) continue;
|
||||
expect(
|
||||
compareVersions(version, minimum),
|
||||
`${packageName}@${version} should be >= ${minimum}`
|
||||
@@ -136,7 +143,11 @@ describe('dependency security policy', () => {
|
||||
// <=10.1.1, so every 9.x is affected and the fix is only on the 10.x line.
|
||||
expectEveryLockedVersionAtLeast(lock, '@fastify/static', '10.1.2');
|
||||
expectEveryLockedVersionAtLeast(lock, 'ip-address', '10.2.0');
|
||||
expectEveryLockedVersionAtLeast(lock, 'uuid', '14.0.0');
|
||||
// Our own uuid stays >= 14. exceljs@4.4.0 (devDependency, vendored into the
|
||||
// XLSX preview worker at build time) pins uuid@8.3.2 and only calls v4();
|
||||
// GHSA-w5hq-g745-h8pq is MODERATE and covers v3/v5/v6 with a caller buffer,
|
||||
// so it is outside this CRITICAL/HIGH policy and unreachable from exceljs.
|
||||
expectEveryLockedVersionAtLeast(lock, 'uuid', '14.0.0', ['8.3.2']);
|
||||
// ⚠️ Floor stays 8.20.1, NOT 8.21.0. Production ws is already 8.21.0 and clear of
|
||||
// GHSA-96hv-2xvq-fx4p, but @remotion/renderer bundles its own ws@8.20.1 and remotion
|
||||
// is pinned to 4.0.473 on purpose (the compositor refuses to start on a version
|
||||
@@ -149,6 +160,10 @@ describe('dependency security policy', () => {
|
||||
expectEveryLockedVersionAtLeast(lock, 'find-my-way', '9.7.0');
|
||||
expectEveryLockedVersionAtLeast(lock, 'basic-ftp', '5.3.1');
|
||||
expectEveryLockedVersionAtLeast(lock, 'flatted', '3.4.2');
|
||||
// GHSA-px8p-9vwx-vf98 (unbounded loop on a ZIP64 marker in a local header)
|
||||
// covers <=0.8.2. The XLSX preview worker streams untrusted files through
|
||||
// fflate's Unzip before any admission callback runs.
|
||||
expectEveryLockedVersionAtLeast(lock, 'fflate', '0.8.3');
|
||||
expectNoVulnerableBraceExpansion(lock);
|
||||
expectNoVulnerableVite(lock);
|
||||
expectNoVulnerablePicomatch(lock);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,208 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* @fileoverview The glue between WebServer and the Discord bridge: alerts reach it even with no
|
||||
* Web Push subscription (with the rendered screen as the tail), and tab events reach it routed by
|
||||
* kind. The bridge and routes are tested on their own; this pins the lines that make the feature
|
||||
* exist. A real WebServer, never started, with the bridge replaced by a recorder.
|
||||
*
|
||||
* Port: N/A (no server start).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { sendNotification } = vi.hoisted(() => ({ sendNotification: vi.fn(async () => undefined) }));
|
||||
vi.mock('web-push', () => ({
|
||||
default: { sendNotification, setVapidDetails: vi.fn(), generateVAPIDKeys: vi.fn() },
|
||||
}));
|
||||
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { SseEvent } from '../src/web/sse-events.js';
|
||||
|
||||
type Internals = {
|
||||
pushStore: { getAll: () => never[]; dispose: () => void };
|
||||
discordBridge: Record<string, ReturnType<typeof vi.fn>>;
|
||||
sessions: Map<string, unknown>;
|
||||
mux: { capturePaneText?: (name: string) => string | null };
|
||||
lastResponseFor?: (session: unknown, context?: string) => Promise<{ text: string }>;
|
||||
sendPushNotifications: (event: string, data: Record<string, unknown>) => Promise<void>;
|
||||
broadcast: (event: string, data: unknown) => void;
|
||||
cleanupSession: (id: string, killMux?: boolean, reason?: string) => Promise<void>;
|
||||
discardPartiallyBuiltSession: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function server(): Internals {
|
||||
const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals;
|
||||
s.pushStore = { getAll: () => [], dispose: () => undefined };
|
||||
s.discordBridge = {
|
||||
notify: vi.fn(async () => null),
|
||||
sessionCreated: vi.fn(async () => undefined),
|
||||
sessionRenamed: vi.fn(async () => undefined),
|
||||
sessionDeleted: vi.fn(async () => undefined),
|
||||
forgetTab: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
return s;
|
||||
}
|
||||
|
||||
beforeEach(() => sendNotification.mockClear());
|
||||
|
||||
describe('bridge deps', () => {
|
||||
it('agentExited is true only for a session whose pane exit was observed', () => {
|
||||
const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals & {
|
||||
discordBridge: { deps: { agentExited: (id: string) => boolean } };
|
||||
};
|
||||
s.sessions.set('dead', { paneExit: { observedAt: 1 } });
|
||||
s.sessions.set('alive', {});
|
||||
const { agentExited } = s.discordBridge.deps;
|
||||
expect(agentExited('dead')).toBe(true);
|
||||
expect(agentExited('alive')).toBe(false);
|
||||
expect(agentExited('missing')).toBe(false);
|
||||
});
|
||||
|
||||
it("types a reply as the person's own input, so it can name the tab", async () => {
|
||||
const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals & {
|
||||
discordBridge: { deps: { sendInput: (id: string, text: string) => Promise<boolean> } };
|
||||
};
|
||||
const writeViaMux = vi.fn(async () => true);
|
||||
s.sessions.set('s1', { writeViaMux });
|
||||
expect(await s.discordBridge.deps.sendInput('s1', 'yes\r')).toBe(true);
|
||||
expect(writeViaMux).toHaveBeenCalledWith('yes\r', { fromUser: true });
|
||||
expect(await s.discordBridge.deps.sendInput('missing', 'yes\r')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendPushNotifications -> Discord', () => {
|
||||
it('posts a permission prompt with zero push subscriptions, with the rendered screen as the tail', async () => {
|
||||
const s = server();
|
||||
s.sessions.set('s1', { muxName: 'codeman-s1', textOutput: '', mode: 'claude' });
|
||||
const capture = vi.fn(() => 'Which option?\n──────────\n 1. Cap GPUs\n\n 2. Cap CPU\n');
|
||||
s.mux = { capturePaneText: capture };
|
||||
await s.sendPushNotifications('hook:permission_prompt', {
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1-app',
|
||||
tool_name: 'Bash',
|
||||
});
|
||||
expect(s.discordBridge.notify).toHaveBeenCalledTimes(1);
|
||||
const msg = s.discordBridge.notify.mock.calls[0][0];
|
||||
expect(msg).toMatchObject({
|
||||
event: 'hook:permission_prompt',
|
||||
title: 'Permission Required',
|
||||
body: '[w1-app] Tool: Bash',
|
||||
urgency: 'critical',
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1-app',
|
||||
});
|
||||
// A function, so the screen is read (a synchronous tmux exec) only once the bridge will post.
|
||||
expect(typeof msg.tail).toBe('function');
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
expect(msg.tail()).toBe('Which option?\n 1. Cap GPUs\n 2. Cap CPU');
|
||||
expect(capture).toHaveBeenCalledTimes(1);
|
||||
expect(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the whole last message from the transcript on a finished turn, and keeps the screen for a dialog', async () => {
|
||||
const s = server();
|
||||
s.sessions.set('s5', { muxName: 'codeman-s5', textOutput: '', mode: 'claude' });
|
||||
const reader = vi.fn(async () => ({ text: 'The full answer.\n\n- one\n- two', timestamp: '2026-10-30T00:00:00Z' }));
|
||||
s.lastResponseFor = reader;
|
||||
s.mux = { capturePaneText: () => 'screen\n❯\n status' };
|
||||
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's5', sessionName: 'w5' });
|
||||
const idle = s.discordBridge.notify.mock.calls[0][0];
|
||||
expect(await idle.fullText()).toEqual({
|
||||
text: 'The full answer.\n\n- one\n- two',
|
||||
version: '2026-10-30T00:00:00Z',
|
||||
});
|
||||
expect(idle.tail()).toBe('screen'); // the composer row and the status under it are cut
|
||||
await s.sendPushNotifications('hook:permission_prompt', { sessionId: 's5', sessionName: 'w5' });
|
||||
const dialog = s.discordBridge.notify.mock.calls[1][0];
|
||||
expect(dialog.fullText).toBeUndefined();
|
||||
expect(dialog.tail()).toBe('screen\n❯\n status');
|
||||
});
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp', 'copilot'])(
|
||||
'a finished turn in a %s tab posts the whole last message',
|
||||
async (mode) => {
|
||||
const s = server();
|
||||
s.sessions.set('s6', { muxName: 'codeman-s6', textOutput: '', mode });
|
||||
s.lastResponseFor = vi.fn(async () => ({ text: '1. First?\n2. Second?\n3. Third?' }));
|
||||
s.mux = { capturePaneText: () => 'screen' };
|
||||
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's6', sessionName: 'w6' });
|
||||
const msg = s.discordBridge.notify.mock.calls[0][0];
|
||||
expect((await msg.fullText()).text).toBe('1. First?\n2. Second?\n3. Third?');
|
||||
}
|
||||
);
|
||||
|
||||
it('a shell tab has no turns, so it keeps the screen tail', async () => {
|
||||
const s = server();
|
||||
s.sessions.set('s7', { muxName: 'codeman-s7', textOutput: '', mode: 'shell' });
|
||||
s.lastResponseFor = vi.fn(async () => ({ text: 'x' }));
|
||||
s.mux = { capturePaneText: () => 'prompt$' };
|
||||
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's7', sessionName: 'w7' });
|
||||
const msg = s.discordBridge.notify.mock.calls[0][0];
|
||||
expect(await msg.fullText()).toBeUndefined();
|
||||
expect(msg.tail()).toBe('prompt$');
|
||||
});
|
||||
|
||||
it('falls back to plain text output when there is no tmux pane, and to no tail for an unknown session', async () => {
|
||||
const s = server();
|
||||
s.sessions.set('s2', { muxName: null, textOutput: 'plain answer\n', mode: 'claude' });
|
||||
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's2', sessionName: 'w2' });
|
||||
expect(s.discordBridge.notify.mock.calls[0][0].tail()).toBe('plain answer');
|
||||
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 'gone', sessionName: 'w3' });
|
||||
expect(s.discordBridge.notify.mock.calls[1][0].tail).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends nothing for an event Web Push does not carry', async () => {
|
||||
const s = server();
|
||||
await s.sendPushNotifications('session:created', { sessionId: 's1', sessionName: 'w1' });
|
||||
expect(s.discordBridge.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcast -> Discord tab lifecycle', () => {
|
||||
it('routes created, updated and deleted by kind, reading both payload shapes', () => {
|
||||
const s = server();
|
||||
s.broadcast(SseEvent.SessionCreated, { session: { id: 's1', name: 'w1', createdAt: 123 } });
|
||||
expect(s.discordBridge.sessionCreated).toHaveBeenCalledWith('s1', 'w1');
|
||||
s.broadcast(SseEvent.SessionUpdated, { id: 's1', name: 'renamed' });
|
||||
expect(s.discordBridge.sessionRenamed).toHaveBeenCalledWith('s1', 'renamed');
|
||||
});
|
||||
|
||||
it('does not touch the thread on session:deleted, which a detach also fires', () => {
|
||||
const s = server();
|
||||
s.broadcast(SseEvent.SessionDeleted, { id: 's1' });
|
||||
expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tells the bridge about a real delete (killMux true) but not a detach (killMux false)', async () => {
|
||||
const s = server();
|
||||
await s.cleanupSession('gone', false);
|
||||
expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled();
|
||||
await s.cleanupSession('gone', true);
|
||||
expect(s.discordBridge.sessionDeleted).toHaveBeenCalledWith('gone');
|
||||
});
|
||||
|
||||
it('a discarded half-built session clears the per-tab state but keeps its thread', async () => {
|
||||
const s = server();
|
||||
s.sessions.set('rebuild', {
|
||||
owner: 'u',
|
||||
ralphTracker: { stopWatchingFixPlan: () => undefined, fullReset: () => undefined },
|
||||
removeAllListeners: () => undefined,
|
||||
stop: async () => undefined,
|
||||
});
|
||||
await s.discardPartiallyBuiltSession('rebuild');
|
||||
expect(s.discordBridge.forgetTab).toHaveBeenCalledWith('rebuild');
|
||||
expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores an event with no session id', () => {
|
||||
const s = server();
|
||||
s.broadcast(SseEvent.SessionUpdated, {});
|
||||
expect(s.discordBridge.sessionRenamed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the bridge when the server stops', async () => {
|
||||
const s = server();
|
||||
await (s as unknown as { stop: () => Promise<void> }).stop();
|
||||
expect(s.discordBridge.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* @fileoverview The Discord startup hint reaches Claude as `--append-system-prompt`, only while the
|
||||
* bridge and its `startupHint` setting are on, and never changes a launch that does not use it.
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { getCli } from '../src/config/cli-registry/registry.js';
|
||||
import { buildSpawnCommandFromRegistry } from '../src/session-cli-registry-bridge.js';
|
||||
import {
|
||||
DiscordBridge,
|
||||
DISCORD_STARTUP_HINT,
|
||||
getDiscordStartupHint,
|
||||
writeDiscordConfig,
|
||||
DEFAULT_DISCORD_CONFIG,
|
||||
} from '../src/discord-bridge.js';
|
||||
|
||||
const SID = '0f9c2b14-1111-2222-3333-444455556666';
|
||||
const BASE = 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"';
|
||||
|
||||
function launch(mode: 'claude' | 'codex'): string | undefined {
|
||||
return buildSpawnCommandFromRegistry(getCli(mode)!, { mode, sessionId: SID, claudeCliVersion: null });
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'discord-hint-'));
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
function bridge() {
|
||||
return new DiscordBridge({
|
||||
configDir: dir,
|
||||
fetchImpl: async () => new Response('{}'),
|
||||
sendInput: async () => true,
|
||||
socketFactory: () => ({ send() {}, close() {}, on() {} }),
|
||||
});
|
||||
}
|
||||
|
||||
describe('startup hint on the Claude launch', () => {
|
||||
it('is absent by default, so a launch is byte-identical to before', () => {
|
||||
expect(getDiscordStartupHint()).toBeUndefined();
|
||||
expect(launch('claude')).toBe(BASE);
|
||||
});
|
||||
|
||||
it('is appended, single-quoted, while the bridge is on', async () => {
|
||||
await writeDiscordConfig(dir, {
|
||||
...DEFAULT_DISCORD_CONFIG,
|
||||
enabled: true,
|
||||
token: 'tok',
|
||||
guildId: '111111111111111111',
|
||||
parentChannelId: '222222222222222222',
|
||||
allowedUserIds: ['333333333333333333'],
|
||||
});
|
||||
const b = bridge();
|
||||
await b.reload();
|
||||
expect(getDiscordStartupHint()).toBe(DISCORD_STARTUP_HINT);
|
||||
expect(launch('claude')).toBe(`${BASE} --append-system-prompt '${DISCORD_STARTUP_HINT}'`);
|
||||
expect(DISCORD_STARTUP_HINT).not.toContain("'");
|
||||
expect(launch('codex')).not.toContain('append-system-prompt');
|
||||
b.stop();
|
||||
expect(launch('claude')).toBe(BASE);
|
||||
});
|
||||
|
||||
it('is absent when startupHint is switched off', async () => {
|
||||
await writeDiscordConfig(dir, {
|
||||
...DEFAULT_DISCORD_CONFIG,
|
||||
enabled: true,
|
||||
token: 'tok',
|
||||
guildId: '111111111111111111',
|
||||
parentChannelId: '222222222222222222',
|
||||
allowedUserIds: ['333333333333333333'],
|
||||
startupHint: false,
|
||||
});
|
||||
const b = bridge();
|
||||
await b.reload();
|
||||
expect(launch('claude')).toBe(BASE);
|
||||
b.stop();
|
||||
});
|
||||
});
|
||||
@@ -306,8 +306,8 @@ describe('adopted container: run modes come from the CONTAINER, not the host', (
|
||||
* exactly what happened twice: a comment added above the assertion pushed the
|
||||
* asserted line past the cutoff and CI failed on a test that was still true.
|
||||
*/
|
||||
const refreshFn = (src) => {
|
||||
const start = src.indexOf('_refreshRunModeAvailability(menu) {');
|
||||
const methodFn = (src, signature) => {
|
||||
const start = src.indexOf(signature);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const open = src.indexOf('{', start);
|
||||
let depth = 0;
|
||||
@@ -315,23 +315,30 @@ describe('adopted container: run modes come from the CONTAINER, not the host', (
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
throw new Error('unbalanced braces in _refreshRunModeAvailability');
|
||||
throw new Error(`unbalanced braces in ${signature}`);
|
||||
};
|
||||
// The per-mode decision lives in ONE helper, shared by the Run menu and the
|
||||
// Cmd+K new-session row (panels-ui.js), so the two cannot drift apart.
|
||||
const ruleFn = (src) => methodFn(src, '_runModeAvailableForCase(mode, caseName) {');
|
||||
|
||||
it('gates a docker case on availableModes instead of host CLI probes', () => {
|
||||
// The sandbox host had codex but no claude while the adopted container had
|
||||
// claude and no codex; gating on the host hid the only mode that worked.
|
||||
const fn = refreshFn(ui);
|
||||
const fn = ruleFn(ui);
|
||||
expect(fn).toContain("location === 'docker'");
|
||||
expect(fn).toContain('availableModes');
|
||||
// Non-docker cases must keep the original host probe (#201).
|
||||
expect(fn).toContain('this.isCliAvailable(mode)');
|
||||
// And the Run menu asks that helper rather than keeping its own copy.
|
||||
expect(methodFn(ui, '_refreshRunModeAvailability(menu) {')).toContain(
|
||||
'this._runModeAvailableForCase(mode, caseName)'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an owned container ungated when nothing was probed', () => {
|
||||
// Our base image ships every CLI, so an absent list means "unknown", and
|
||||
// treating unknown as "nothing available" would empty the menu.
|
||||
expect(refreshFn(ui)).toMatch(/containerModes \?[^:]*:\s*true/);
|
||||
expect(ruleFn(ui)).toMatch(/containerModes \?[^:]*:\s*true/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('docker server image divergence is declared, not accidental', () => {
|
||||
// untouched by this change because two other open PRs already modify it. Asserting the
|
||||
// omissions here makes the divergence reviewable without editing the file: if someone adds
|
||||
// a CLI there, or the intent changes, this fails and the list has to be restated.
|
||||
const SERVER_INTENTIONAL_OMISSIONS = new Set(['antigravity', 'pi', 'grok', 'deepseek', 'omp']);
|
||||
const SERVER_INTENTIONAL_OMISSIONS = new Set(['antigravity', 'pi', 'grok', 'deepseek', 'omp', 'copilot']);
|
||||
|
||||
it('installs exactly the CLIs it declares, and no more', () => {
|
||||
for (const entry of enabledAgents) {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync, chownSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
@@ -309,6 +309,21 @@ describe('Update-Codeman.sh (the scripted major-update path — docker/README.md
|
||||
const casesPath = join(dir, 'cases');
|
||||
const socketPath = join(dir, 'docker.sock'); // deliberately NOT a real socket — see above
|
||||
mkdirSync(appdataPath);
|
||||
// Both scripts derive PUID:PGID from the appdata owner and REFUSE a
|
||||
// root-owned one (correctly: the container must never run as root).
|
||||
// mkdirSync gives it the runner's uid, so under a root runner (a CI or
|
||||
// agent container) the update path stopped at that guard before its
|
||||
// build. Give it the non-root owner docker/README.md's own
|
||||
// `chown -R 99:100` example sets up, as a real deployment would have.
|
||||
if (process.getuid?.() === 0) {
|
||||
try {
|
||||
chownSync(appdataPath, 99, 100);
|
||||
} catch {
|
||||
// No CAP_CHOWN (root with --cap-drop ALL, as Codeman's own Docker
|
||||
// case containers run) or a single-uid user namespace: leave it
|
||||
// root-owned and let the scripts' own guard refuse, as before.
|
||||
}
|
||||
}
|
||||
mkdirSync(casesPath);
|
||||
writeFileSync(socketPath, '');
|
||||
|
||||
|
||||
@@ -106,6 +106,16 @@ describe('buildDockerLaunchCommand', () => {
|
||||
expect(cmd).toContain('tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID');
|
||||
expect(cmd).toContain('new-session -A -s codeman-dkr-1a2b3c4d');
|
||||
expect(cmd).toContain("sh -lc '");
|
||||
// The in-container tmux keeps prefix C-q. Without this reduction its stock
|
||||
// prefix table runs switch-client / choose-tree for the inner client.
|
||||
// Bind, unbind-all, bind again: an empty prefix table makes unbind-key -a
|
||||
// fail and abort the rest of the \; chain.
|
||||
const firstBind = cmd.indexOf('bind-key -T prefix C-q send-prefix');
|
||||
const unbind = cmd.indexOf('unbind-key -a -T prefix');
|
||||
const secondBind = cmd.indexOf('bind-key -T prefix C-q send-prefix', unbind);
|
||||
expect(firstBind).toBeGreaterThan(cmd.indexOf('prefix C-q'));
|
||||
expect(unbind).toBeGreaterThan(firstBind);
|
||||
expect(secondBind).toBeGreaterThan(unbind);
|
||||
});
|
||||
|
||||
it('pins a deterministic conversation id with a reboot-surviving fallback (fresh launch)', () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
persistDockerCaseClaudeSessionId,
|
||||
probeDockerCliVersion,
|
||||
readDockerCases,
|
||||
readDockerCasesOrThrow,
|
||||
readDockerHosts,
|
||||
resolveClaudeJsonSeedMount,
|
||||
resolveDockerClaudeArtifacts,
|
||||
@@ -70,6 +71,28 @@ describe('docker-hosts storage', () => {
|
||||
expect(await readDockerCases(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The git fetch paths read the list through the strict reader: the writer truncates and rewrites the file in
|
||||
* place, and a list read as `[]` there would let a fetch run git inside a Docker workspace.
|
||||
*/
|
||||
it('has a strict reader that rejects a list it cannot parse, where the plain reader returns []', async () => {
|
||||
expect(await readDockerCasesOrThrow(dir)).toEqual([]); // missing: no cases
|
||||
await writeDockerCases(dir, [CASE]);
|
||||
expect(await readDockerCasesOrThrow(dir)).toEqual([CASE]);
|
||||
const path = join(dir, 'docker-cases.json');
|
||||
for (const text of ['', '[{"name":"myproj","hostWorkspacePath":"/home/arkon/cas', '{"name":"myproj"}', 'null']) {
|
||||
writeFileSync(path, text);
|
||||
expect(await readDockerCases(dir)).toEqual([]);
|
||||
const err = await readDockerCasesOrThrow(dir).then(
|
||||
() => null,
|
||||
(e: Error) => e
|
||||
);
|
||||
expect(err?.message).toMatch(/not a valid JSON array/);
|
||||
// The parser's message quotes the file; the strict reader's never does.
|
||||
expect(err?.message).not.toContain('/home/arkon/cas');
|
||||
}
|
||||
});
|
||||
|
||||
it('persists the last Claude conversation id keyed by container name', async () => {
|
||||
await writeDockerCases(dir, [CASE, { ...CASE, name: 'other', container: 'custom-name' }]);
|
||||
await persistDockerCaseClaudeSessionId(dir, dockerContainerName(CASE.name), 'conv-1');
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment node
|
||||
// The contract GET /api/doctor's default runner relies on: the same entry script, given
|
||||
// `doctor --json`, prints a parseable DependencyReportJson on stdout, even when it exits
|
||||
// non-zero because something required is missing.
|
||||
//
|
||||
// Hermetic: the doctor runs `--version` on every CLI it finds, and a suite must never execute
|
||||
// whatever happens to be installed on the machine running it (cli-executable-resolver.ts
|
||||
// @fileoverview). Each run gets a temp HOME and a PATH holding only `which` and `node`, and a
|
||||
// clis.json in that HOME's data dir drops the registry's absolute search dirs
|
||||
// (`/usr/local/bin`), so the only CLI the doctor can find is a fixture this file wrote.
|
||||
|
||||
import { execFile, execFileSync } from 'node:child_process';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
|
||||
const ROOT = join(import.meta.dirname, '..');
|
||||
|
||||
interface DoctorRun {
|
||||
report: { tools: Array<{ id: string; status: string; path?: string; category: string }> } & Record<string, any>;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function hermeticDoctorEnv(): { home: string; bare: string; env: NodeJS.ProcessEnv; cleanup: () => void } {
|
||||
const home = mkdtempSync(join(tmpdir(), 'doctor-home-'));
|
||||
const bare = mkdtempSync(join(tmpdir(), 'doctor-path-'));
|
||||
symlinkSync(execFileSync('sh', ['-c', 'command -v which'], { encoding: 'utf-8' }).trim(), join(bare, 'which'));
|
||||
symlinkSync(process.execPath, join(bare, 'node'));
|
||||
// Overrides deep-merge by id and arrays replace wholesale, so this keeps every stock entry
|
||||
// and only narrows its search dirs to the `~` ones, which resolve inside the temp HOME.
|
||||
const clis = Object.fromEntries(
|
||||
STOCK_CLIS.map((e) => [e.id, { discovery: { searchDirs: e.discovery.searchDirs.filter((d) => !isAbsolute(d)) } }])
|
||||
);
|
||||
mkdirSync(join(home, '.codeman'), { recursive: true });
|
||||
// 0600 or the registry ignores the file (isUnsafePermissions).
|
||||
writeFileSync(join(home, '.codeman', 'clis.json'), JSON.stringify({ schemaVersion: 1, clis }), { mode: 0o600 });
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, PATH: bare };
|
||||
delete env.CODEMAN_DATA_DIR;
|
||||
delete env.CODEMAN_INSTANCE;
|
||||
return {
|
||||
home,
|
||||
bare,
|
||||
env,
|
||||
cleanup: () => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(bare, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runDoctor(env: NodeJS.ProcessEnv): Promise<DoctorRun> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[
|
||||
join(ROOT, 'node_modules/tsx/dist/cli.mjs'),
|
||||
join(ROOT, 'src/index.ts'),
|
||||
'doctor',
|
||||
'--json',
|
||||
'--category',
|
||||
'core',
|
||||
],
|
||||
{ timeout: 60_000, cwd: ROOT, env },
|
||||
(err, out, stderr) => (out ? resolve({ report: JSON.parse(out), stderr }) : reject(err ?? new Error('no output')))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe('codeman doctor --json', () => {
|
||||
it('prints a report that includes Node and a summary, whatever the exit code', async () => {
|
||||
const h = hermeticDoctorEnv();
|
||||
try {
|
||||
const { report, stderr } = await runDoctor(h.env);
|
||||
expect(report.platform.environment).toMatch(/linux|darwin|win32|wsl/);
|
||||
expect(report.summary).toEqual(expect.objectContaining({ ok: expect.any(Number), exitCode: expect.any(Number) }));
|
||||
const node = report.tools.find((t) => t.id === 'node');
|
||||
expect(node?.status).toBe('ok');
|
||||
expect(report.tools.every((t) => t.category === 'core')).toBe(true);
|
||||
// The override was accepted (an ignored or invalid clis.json warns on stderr), and nothing
|
||||
// the doctor found, and so ran, lives outside this test's own temp dirs.
|
||||
expect(stderr).not.toContain('[cli-registry]');
|
||||
for (const t of report.tools.filter((t) => t.path)) {
|
||||
expect(t.path!.startsWith(h.bare) || t.path!.startsWith(h.home)).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
// The report an operator got wrong in production: under systemd the PATH is minimal, so a CLI
|
||||
// installed in ~/.local/bin read `missing` while the Run menu (which also searches the registry's
|
||||
// searchDirs) found it.
|
||||
it('finds a CLI that lives only in a registry searchDirs entry when the PATH is minimal', async () => {
|
||||
const h = hermeticDoctorEnv();
|
||||
try {
|
||||
mkdirSync(join(h.home, '.local/bin'), { recursive: true });
|
||||
const fake = join(h.home, '.local/bin/claude');
|
||||
writeFileSync(fake, '#!/bin/sh\necho "2.1.0 (Claude Code)"\n');
|
||||
chmodSync(fake, 0o755);
|
||||
const { report } = await runDoctor(h.env);
|
||||
const claude = report.tools.find((t) => t.id === 'claude');
|
||||
expect(claude).toMatchObject({ status: 'ok', path: fake });
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/** @fileoverview Settings → System → Diagnostics in a real browser, with GET /api/doctor stubbed at the network layer. */
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const REPORT = {
|
||||
platform: { environment: 'linux' },
|
||||
summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 },
|
||||
tools: [
|
||||
{
|
||||
id: 'node',
|
||||
label: 'Node.js',
|
||||
category: 'core',
|
||||
required: true,
|
||||
usedBy: [],
|
||||
status: 'ok',
|
||||
version: '22.1.0',
|
||||
path: '/usr/bin/node',
|
||||
},
|
||||
{
|
||||
id: 'tmux',
|
||||
label: 'tmux',
|
||||
category: 'core',
|
||||
required: true,
|
||||
usedBy: [],
|
||||
status: 'missing',
|
||||
installHint: 'apt install tmux',
|
||||
},
|
||||
// Host-supplied strings must be rendered as text, never as markup.
|
||||
{
|
||||
id: 'x',
|
||||
label: '<img src=x onerror=window.__pwned=1>',
|
||||
category: 'other',
|
||||
required: false,
|
||||
usedBy: [],
|
||||
status: 'missing',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('Diagnostics panel in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
// A controlling service worker can swallow requests before page.route() sees them, letting the
|
||||
// real /api/doctor (a forked Node process) answer instead; block it so the stub is reliable.
|
||||
page = await (await browser.newContext({ serviceWorkers: 'block' })).newPage();
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
it('lists each tool with status, version, path and install hint, and renders host strings as text', async () => {
|
||||
await page.route('**/api/doctor', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data: REPORT }) })
|
||||
);
|
||||
await page.click('#doctorRunBtn');
|
||||
await page.waitForFunction(() => /1 ok/.test(document.getElementById('doctorResult')?.textContent ?? ''));
|
||||
const text = await page.textContent('#doctorResult');
|
||||
expect(text).toContain('1 ok · 1 required missing · 0 optional missing (linux)');
|
||||
expect(text).toContain('✓ Node.js ok · 22.1.0');
|
||||
expect(text).toContain('/usr/bin/node');
|
||||
expect(text).toContain('✗ tmux missing · required');
|
||||
// A missing OPTIONAL tool is not an error: ○, as the terminal doctor marks it.
|
||||
expect(text).toContain('○ <img src=x onerror=window.__pwned=1> missing · optional');
|
||||
expect(text).toContain('Install: apt install tmux');
|
||||
expect(text).toContain('<img src=x onerror=window.__pwned=1>'); // shown literally
|
||||
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
|
||||
expect(await page.$('#doctorResult img')).toBeNull();
|
||||
expect(await page.isDisabled('#doctorRunBtn')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the server’s message when the check fails, and re-enables the button', async () => {
|
||||
await page.unroute('**/api/doctor');
|
||||
await page.route('**/api/doctor', (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: false, errorCode: 'OPERATION_FAILED', error: 'doctor failed: boom' }),
|
||||
})
|
||||
);
|
||||
await page.click('#doctorRunBtn');
|
||||
await page.waitForFunction(() => /boom/.test(document.getElementById('doctorResult')?.textContent ?? ''));
|
||||
expect(await page.isDisabled('#doctorRunBtn')).toBe(false);
|
||||
});
|
||||
|
||||
it('hides the Diagnostics group from a non-admin in multi-user mode and shows it to an admin', async () => {
|
||||
const visible = (user: Record<string, unknown>) =>
|
||||
page.evaluate((u) => {
|
||||
(window as any).__codemanUser = u;
|
||||
document.dispatchEvent(new CustomEvent('codeman:me'));
|
||||
return getComputedStyle(document.getElementById('doctorGroup')!).display !== 'none';
|
||||
}, user);
|
||||
expect(await visible({ multiUser: true, role: 'user' })).toBe(false);
|
||||
expect(await visible({ multiUser: true, role: 'admin' })).toBe(true);
|
||||
expect(await visible({ multiUser: false })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { safeRmHomeTree } from './mocks/index.js';
|
||||
|
||||
const TEST_PORT = 3110;
|
||||
const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
@@ -13,9 +12,9 @@ describe('Edge Cases and Error Handling', () => {
|
||||
const createdCases: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(TEST_PORT, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TEST_PORT}`;
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -263,9 +262,9 @@ describe('Concurrent Session Handling', () => {
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(TEST_PORT + 1, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TEST_PORT + 1}`;
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -360,9 +359,9 @@ describe('API Request Validation', () => {
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(TEST_PORT + 2, false, true);
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
baseUrl = `http://localhost:${TEST_PORT + 2}`;
|
||||
baseUrl = `http://localhost:${server.boundPort}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Local edit-source reader against a temp projects dir: the transcript lookup, byte-offset
|
||||
* reads of the main transcript and its subagents, and the image probe.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
findClaudeTranscript,
|
||||
probeLocalPaths,
|
||||
readClaudeEditSources,
|
||||
resolveClaudeTranscript,
|
||||
} from '../src/edit-history-local-source.js';
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'edit-src-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function put(rel: string, content: string | Buffer, mtimeSec?: number): string {
|
||||
const path = join(root, rel);
|
||||
mkdirSync(join(path, '..'), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
if (mtimeSec) utimesSync(path, mtimeSec, mtimeSec);
|
||||
return path;
|
||||
}
|
||||
|
||||
describe('findClaudeTranscript', () => {
|
||||
it('returns null when no transcript exists under any id', async () => {
|
||||
put('p/other.jsonl', 'x\n');
|
||||
expect(await findClaudeTranscript(['sid'], root)).toBeNull();
|
||||
expect(await findClaudeTranscript(['sid'], join(root, 'missing'))).toBeNull();
|
||||
});
|
||||
|
||||
it('takes the first id that has a transcript, the newest one across project dirs', async () => {
|
||||
put('a/sid.jsonl', 'old\n', 1000);
|
||||
const newest = put('b/sid.jsonl', 'new\n', 2000);
|
||||
put('c/second.jsonl', 'x\n');
|
||||
expect(await findClaudeTranscript(['bad id!', 'sid', 'second'], root)).toBe(newest);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClaudeTranscript', () => {
|
||||
it('takes the watcher path (what the hooks reported) as is', async () => {
|
||||
expect(await resolveClaudeTranscript('/hooked/conv.jsonl', ['sid'], join(root, 'cfg'))).toBe('/hooked/conv.jsonl');
|
||||
});
|
||||
|
||||
it("without one, looks under the session's own claude config dir", async () => {
|
||||
const path = put('cfg/projects/p/sid.jsonl', 'x\n');
|
||||
expect(await resolveClaudeTranscript(null, ['sid'], join(root, 'cfg'))).toBe(path);
|
||||
expect(await resolveClaudeTranscript(undefined, ['other'], join(root, 'cfg'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readClaudeEditSources', () => {
|
||||
it('returns null for a missing file or a path that is not an absolute <id>.jsonl', async () => {
|
||||
const path = put('p/sid.jsonl', 'x\n');
|
||||
expect(await readClaudeEditSources(join(root, 'p/gone.jsonl'), [], 100)).toBeNull();
|
||||
expect(await readClaudeEditSources('p/sid.jsonl', [], 100)).toBeNull();
|
||||
expect(await readClaudeEditSources(path.replace('.jsonl', '.txt'), [], 100)).toBeNull();
|
||||
expect(await readClaudeEditSources(put('p/bad id.jsonl', 'x\n'), [], 100)).toBeNull();
|
||||
});
|
||||
|
||||
it('names the conversation after the file', async () => {
|
||||
const res = await readClaudeEditSources(put('p/conv-1.jsonl', 'x\n'), [], 100);
|
||||
expect(res?.transcriptId).toBe('conv-1');
|
||||
});
|
||||
|
||||
it('reads a new file from its end within the budget, a requested one from its offset', async () => {
|
||||
const path = put('p/sid.jsonl', '0123456789');
|
||||
const fresh = await readClaudeEditSources(path, [], 4);
|
||||
expect(fresh?.files).toEqual([{ relPath: 'main', size: 10, startOffset: 6, chunk: Buffer.from('6789') }]);
|
||||
const cont = await readClaudeEditSources(path, [{ relPath: 'main', offset: 3, len: 2 }], 100);
|
||||
expect(cont?.files[0]).toMatchObject({ startOffset: 3, chunk: Buffer.from('34') });
|
||||
});
|
||||
|
||||
it('clamps an offset past the end to the size (a replaced, shorter file)', async () => {
|
||||
const path = put('p/sid.jsonl', 'abc');
|
||||
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 50 }], 100);
|
||||
expect(res?.files[0]).toMatchObject({ size: 3, startOffset: 3, chunk: Buffer.alloc(0) });
|
||||
});
|
||||
|
||||
it('lists subagents with their meta for new ones only, and shares the budget main first', async () => {
|
||||
const path = put('p/sid.jsonl', 'MAIN');
|
||||
put('p/sid/subagents/agent-a1.jsonl', 'AAAA');
|
||||
put('p/sid/subagents/agent-a1.meta.json', '{"description":"Fix it"}');
|
||||
put('p/sid/subagents/agent-b2.jsonl', 'BBBB');
|
||||
put('p/sid/subagents/agent-b2.meta.json', '{"description":"Other"}');
|
||||
put('p/sid/subagents/notes.txt', 'ignored');
|
||||
const res = await readClaudeEditSources(path, [{ relPath: 'subagents/agent-b2.jsonl', offset: 0 }], 6);
|
||||
expect(res?.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([
|
||||
['main', 0, 'MAIN'],
|
||||
['subagents/agent-a1.jsonl', 2, 'AA'],
|
||||
['subagents/agent-b2.jsonl', 0, ''],
|
||||
]);
|
||||
expect(res?.metas).toEqual({ a1: '{"description":"Fix it"}' });
|
||||
});
|
||||
|
||||
it('ignores invalid requests', async () => {
|
||||
const path = put('p/sid.jsonl', 'abcdef');
|
||||
const res = await readClaudeEditSources(
|
||||
path,
|
||||
[
|
||||
{ relPath: '../etc/passwd', offset: 0 },
|
||||
{ relPath: 'main', offset: -1 },
|
||||
],
|
||||
3
|
||||
);
|
||||
// The invalid main request is dropped, so main is read like a new file.
|
||||
expect(res?.files[0]).toMatchObject({ startOffset: 3, chunk: Buffer.from('def') });
|
||||
});
|
||||
|
||||
it('keeps multibyte bytes intact (byte offsets, not string lengths)', async () => {
|
||||
const path = put('p/sid.jsonl', Buffer.from('äöü\n', 'utf8'));
|
||||
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 2 }], 100);
|
||||
expect(res?.files[0].chunk).toEqual(Buffer.from('äöü\n', 'utf8').subarray(2));
|
||||
});
|
||||
|
||||
it('with onlyRequested, reads the requested files and nothing else', async () => {
|
||||
const path = put('p/sid.jsonl', 'MAIN');
|
||||
put('p/sid/subagents/agent-a1.jsonl', 'AAAA');
|
||||
put('p/sid/subagents/agent-b2.jsonl', 'BBBB');
|
||||
const res = await readClaudeEditSources(path, [{ relPath: 'subagents/agent-b2.jsonl', offset: 1, len: 2 }], 100, {
|
||||
onlyRequested: true,
|
||||
});
|
||||
expect(res?.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([
|
||||
['subagents/agent-b2.jsonl', 1, 'BB'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the end of a block that does not fit the budget', async () => {
|
||||
const path = put('p/sid.jsonl', '0123456789');
|
||||
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 2, len: 6 }], 3);
|
||||
expect(res?.files[0]).toMatchObject({ startOffset: 5, chunk: Buffer.from('567') });
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeLocalPaths', () => {
|
||||
it('reports size and mtime of files, expands ~/ against home, null for missing and directories', async () => {
|
||||
put('home/img.png', 'PNG!', 1_700_000_000);
|
||||
mkdirSync(join(root, 'dir'));
|
||||
const res = await probeLocalPaths(['~/img.png', join(root, 'dir'), join(root, 'nope.png')], join(root, 'home'));
|
||||
expect(res[0]).toEqual({ realPath: join(root, 'home/img.png'), kind: 'file', size: 4, mtimeMs: 1_700_000_000_000 });
|
||||
expect(res[1]).toBeNull();
|
||||
expect(res[2]).toBeNull();
|
||||
});
|
||||
|
||||
it('touches a path only after the bounded probe saw a file there', async () => {
|
||||
put('a.png', 'PNG!', 1_700_000_000);
|
||||
put('b.png', 'PNG!', 1_700_000_000);
|
||||
const asked: string[] = [];
|
||||
const res = await probeLocalPaths([join(root, 'a.png'), join(root, 'b.png'), 'rel.png'], root, async (p) => {
|
||||
asked.push(p);
|
||||
return p.endsWith('a.png') ? 'unknown' : 'file';
|
||||
});
|
||||
// a.png exists, but its filesystem did not answer the bounded probe: no unbounded stat.
|
||||
expect(res.map((r) => r?.realPath ?? null)).toEqual([null, join(root, 'b.png'), null]);
|
||||
expect(asked).toEqual([join(root, 'a.png'), join(root, 'b.png')]); // relative: never probed
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Edit history parser: claude transcript lines → edit records and Bash image candidates.
|
||||
* Fixture lines mirror real transcript records (Claude Code 2.1.300), trimmed to the
|
||||
* fields the parser reads plus the bulky ones it must ignore (`originalFile`, `content`).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
classifyEditPath,
|
||||
extractImagePathCandidates,
|
||||
MAX_HUNK_BYTES,
|
||||
MAX_IMAGE_CANDIDATES,
|
||||
MAX_PENDING_BASH,
|
||||
MAX_PENDING_COMMAND_CHARS,
|
||||
MAX_PENDING_FIELD_CHARS,
|
||||
parseClaudeEditLines,
|
||||
} from '../src/edit-history-parse.js';
|
||||
|
||||
const CWD = '/work/repo';
|
||||
|
||||
function toolResultLine(id: string, ts: string, toolUseResult: unknown, extra: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
isSidechain: false,
|
||||
timestamp: ts,
|
||||
cwd: CWD,
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function bashUseLine(id: string, ts: string, command: string, extra: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
isSidechain: false,
|
||||
timestamp: ts,
|
||||
cwd: CWD,
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
const hunk = (lines: string[]) => ({ oldStart: 3, oldLines: 2, newStart: 3, newLines: 3, lines });
|
||||
|
||||
const editLine = toolResultLine('toolu_edit', '2026-10-15T19:01:28.880Z', {
|
||||
filePath: '/work/repo/src/app.ts',
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
originalFile: 'x'.repeat(5000),
|
||||
structuredPatch: [hunk([' keep', '-old', '+new', '+added'])],
|
||||
userModified: false,
|
||||
replaceAll: false,
|
||||
});
|
||||
|
||||
describe('parseClaudeEditLines: edits', () => {
|
||||
it('turns an Edit result into a record without the whole-file fields', () => {
|
||||
const { records } = parseClaudeEditLines(editLine + '\n');
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: 'toolu_edit',
|
||||
ts: Date.parse('2026-10-15T19:01:28.880Z'),
|
||||
path: '/work/repo/src/app.ts',
|
||||
kind: 'code',
|
||||
tool: 'Edit',
|
||||
created: false,
|
||||
added: 2,
|
||||
removed: 1,
|
||||
hunks: [hunk([' keep', '-old', '+new', '+added'])],
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(records)).not.toContain('xxxxx');
|
||||
});
|
||||
|
||||
it('reads replaceAll edits like any other Edit', () => {
|
||||
const line = toolResultLine('toolu_all', '2026-10-15T19:02:00.000Z', {
|
||||
filePath: '/work/repo/a.ts',
|
||||
oldString: 'x',
|
||||
newString: 'y',
|
||||
originalFile: '',
|
||||
structuredPatch: [hunk(['-x', '+y']), { ...hunk(['-x', '+y']), oldStart: 9, newStart: 9 }],
|
||||
userModified: false,
|
||||
replaceAll: true,
|
||||
});
|
||||
const [rec] = parseClaudeEditLines(line).records;
|
||||
expect(rec).toMatchObject({ tool: 'Edit', added: 2, removed: 2 });
|
||||
expect(rec.hunks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('marks a Write that created the file, and one that replaced it', () => {
|
||||
const create = toolResultLine('toolu_c', '2026-10-15T19:03:00.000Z', {
|
||||
type: 'create',
|
||||
filePath: '/work/repo/new.md',
|
||||
content: 'hello\nworld',
|
||||
structuredPatch: [],
|
||||
originalFile: null,
|
||||
});
|
||||
const update = toolResultLine('toolu_u', '2026-10-15T19:04:00.000Z', {
|
||||
type: 'update',
|
||||
filePath: '/work/repo/new.md',
|
||||
content: 'hi',
|
||||
structuredPatch: [hunk(['-hello', '-world', '+hi'])],
|
||||
originalFile: 'hello\nworld',
|
||||
});
|
||||
const { records } = parseClaudeEditLines([create, update].join('\n'));
|
||||
expect(records.map((r) => [r.tool, r.created, r.added, r.removed])).toEqual([
|
||||
['Write', true, 2, 0],
|
||||
['Write', false, 1, 2],
|
||||
]);
|
||||
expect(records[0].hunks).toEqual([
|
||||
{ oldStart: 0, oldLines: 0, newStart: 1, newLines: 2, lines: ['+hello', '+world'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('a created file’s diff drops the trailing newline and is capped like any hunk', () => {
|
||||
const big = 'x'.repeat(1000) + '\n';
|
||||
const line = toolResultLine('toolu_big', '2026-10-15T19:03:30.000Z', {
|
||||
type: 'create',
|
||||
filePath: '/work/repo/big.txt',
|
||||
content: big.repeat(100),
|
||||
structuredPatch: [],
|
||||
originalFile: null,
|
||||
});
|
||||
const [rec] = parseClaudeEditLines(line).records;
|
||||
expect(rec.added).toBe(100);
|
||||
expect(rec.truncated).toBe(true);
|
||||
expect(rec.hunks[0].lines.length).toBeLessThan(100);
|
||||
expect(rec.hunks[0].lines.every((l) => l === '+' + 'x'.repeat(1000))).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies an image written by Write as an image', () => {
|
||||
const line = toolResultLine('toolu_svg', '2026-10-15T19:05:00.000Z', {
|
||||
type: 'create',
|
||||
filePath: '/work/repo/icon.SVG',
|
||||
content: '<svg/>',
|
||||
structuredPatch: [],
|
||||
originalFile: null,
|
||||
});
|
||||
expect(parseClaudeEditLines(line).records[0].kind).toBe('image');
|
||||
});
|
||||
|
||||
it('keeps a result of an unknown edit tool that has a path and a patch', () => {
|
||||
const line = toolResultLine('toolu_nb', '2026-10-15T19:06:00.000Z', {
|
||||
filePath: '/work/repo/n.ipynb',
|
||||
structuredPatch: [hunk(['+cell'])],
|
||||
});
|
||||
expect(parseClaudeEditLines(line).records[0]).toMatchObject({ tool: 'edit', added: 1 });
|
||||
});
|
||||
|
||||
it('skips failed tool results (a string), unrelated results and lines that do not parse', () => {
|
||||
const failed = toolResultLine('toolu_err', '2026-10-15T19:07:00.000Z', 'Error: String to replace not found');
|
||||
const read = toolResultLine('toolu_read', '2026-10-15T19:07:01.000Z', { type: 'text', file: { filePath: '/x' } });
|
||||
const text = [failed, read, '{"type":"user",broken', '', 'not json'].join('\n');
|
||||
expect(parseClaudeEditLines(text).records).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a first line cut mid-record (a byte range starts inside it)', () => {
|
||||
const cut = editLine.slice(40);
|
||||
expect(parseClaudeEditLines(cut + '\n' + editLine).records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('carries the subagent id of a sidechain line', () => {
|
||||
const line = toolResultLine('toolu_sub', '2026-10-15T19:08:00.000Z', JSON.parse(editLine).toolUseResult, {
|
||||
isSidechain: true,
|
||||
agentId: 'a0038561ea4d57a42',
|
||||
});
|
||||
expect(parseClaudeEditLines(line).records[0].agentId).toBe('a0038561ea4d57a42');
|
||||
});
|
||||
|
||||
it('caps an oversized patch and says so, counting lines from the whole patch', () => {
|
||||
const big = Array.from({ length: 4000 }, (_, i) => `+line ${i} ${'y'.repeat(40)}`);
|
||||
const line = toolResultLine('toolu_big', '2026-10-15T19:09:00.000Z', {
|
||||
type: 'create',
|
||||
filePath: '/work/repo/big.ts',
|
||||
content: '',
|
||||
structuredPatch: [hunk(big)],
|
||||
originalFile: null,
|
||||
});
|
||||
const [rec] = parseClaudeEditLines(line).records;
|
||||
expect(rec.truncated).toBe(true);
|
||||
expect(rec.added).toBe(4000);
|
||||
expect(JSON.stringify(rec.hunks).length).toBeLessThanOrEqual(MAX_HUNK_BYTES + 1024);
|
||||
expect(rec.hunks[0].lines.length).toBeLessThan(4000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseClaudeEditLines: Bash image candidates', () => {
|
||||
const start = '2026-10-15T20:00:00.000Z';
|
||||
const end = '2026-10-15T20:00:05.000Z';
|
||||
|
||||
it('pairs a Bash call with its result and collects image paths from command and output', () => {
|
||||
const use = bashUseLine('toolu_b', start, 'python3 render.py --out out/frame.png');
|
||||
const res = toolResultLine('toolu_b', end, {
|
||||
stdout: 'Saved to: file:///tmp/gen/a b.png\nwrote /work/repo/out/thumb.webp.',
|
||||
stderr: '',
|
||||
interrupted: false,
|
||||
isImage: false,
|
||||
});
|
||||
const { candidates, pending } = parseClaudeEditLines([use, res].join('\n'));
|
||||
expect(pending).toEqual([]);
|
||||
expect(candidates).toEqual([
|
||||
{
|
||||
toolUseId: 'toolu_b',
|
||||
start: Date.parse(start),
|
||||
end: Date.parse(end),
|
||||
paths: ['/work/repo/out/frame.png', '/tmp/gen/a b.png', '/work/repo/out/thumb.webp'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('yields no candidate for a Bash call without image paths', () => {
|
||||
const use = bashUseLine('toolu_n', start, 'git status');
|
||||
const res = toolResultLine('toolu_n', end, { stdout: 'clean', stderr: '', interrupted: false, isImage: false });
|
||||
expect(parseClaudeEditLines([use, res].join('\n')).candidates).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses the tool_result text when the Bash call failed (toolUseResult is a string)', () => {
|
||||
const use = bashUseLine('toolu_f', start, 'make render');
|
||||
const res = JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: end,
|
||||
cwd: CWD,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ tool_use_id: 'toolu_f', type: 'tool_result', is_error: true, content: 'Exit 2\nwrote out/x.jpg' }],
|
||||
},
|
||||
toolUseResult: 'Error: Exit 2',
|
||||
});
|
||||
expect(parseClaudeEditLines([use, res].join('\n')).candidates[0].paths).toEqual(['/work/repo/out/x.jpg']);
|
||||
});
|
||||
|
||||
it('hands an unanswered Bash call back as pending and resolves it in the next chunk', () => {
|
||||
const first = parseClaudeEditLines(bashUseLine('toolu_p', start, 'convert a.svg out.png') + '\n');
|
||||
expect(first.candidates).toEqual([]);
|
||||
expect(first.pending).toEqual([
|
||||
{ id: 'toolu_p', ts: Date.parse(start), command: 'convert a.svg out.png', cwd: CWD },
|
||||
]);
|
||||
const res = toolResultLine('toolu_p', end, { stdout: '', stderr: '', interrupted: false, isImage: false });
|
||||
const second = parseClaudeEditLines(res, first.pending);
|
||||
expect(second.pending).toEqual([]);
|
||||
expect(second.candidates[0].paths).toEqual(['/work/repo/a.svg', '/work/repo/out.png']);
|
||||
});
|
||||
|
||||
it('carries the subagent id onto the candidate', () => {
|
||||
const use = bashUseLine('toolu_s', start, 'render > shot.png', { isSidechain: true, agentId: 'abc' });
|
||||
const res = toolResultLine('toolu_s', end, { stdout: '', stderr: '', interrupted: false, isImage: false });
|
||||
expect(parseClaudeEditLines([use, res].join('\n')).candidates[0].agentId).toBe('abc');
|
||||
});
|
||||
|
||||
it('bounds the pending list, dropping the oldest', () => {
|
||||
const lines = Array.from({ length: MAX_PENDING_BASH + 5 }, (_, i) =>
|
||||
bashUseLine(`toolu_${i}`, start, 'sleep 1')
|
||||
).join('\n');
|
||||
const { pending } = parseClaudeEditLines(lines);
|
||||
expect(pending).toHaveLength(MAX_PENDING_BASH);
|
||||
expect(pending[0].id).toBe('toolu_5');
|
||||
});
|
||||
|
||||
it(`keeps no call with an id, cwd or agent id over ${MAX_PENDING_FIELD_CHARS} characters (the store would refuse it on load)`, () => {
|
||||
const long = 'x'.repeat(MAX_PENDING_FIELD_CHARS + 1);
|
||||
const lines = [
|
||||
bashUseLine(long, start, 'render > a.png'),
|
||||
bashUseLine('toolu_cwd', start, 'render > a.png', { cwd: '/' + long }),
|
||||
bashUseLine('toolu_agent', start, 'render > a.png', { agentId: long }),
|
||||
bashUseLine('toolu_ok', start, 'render > a.png', { cwd: '/' + 'x'.repeat(MAX_PENDING_FIELD_CHARS - 1) }),
|
||||
];
|
||||
expect(parseClaudeEditLines(lines.join('\n')).pending.map((p) => p.id)).toEqual(['toolu_ok']);
|
||||
});
|
||||
|
||||
it('keeps only the head of a long command (the pending list is persisted)', () => {
|
||||
const command = 'render > out.png && ' + 'x'.repeat(MAX_PENDING_COMMAND_CHARS * 2);
|
||||
const { pending } = parseClaudeEditLines(bashUseLine('toolu_long', start, command));
|
||||
expect(pending[0].command).toBe(command.slice(0, MAX_PENDING_COMMAND_CHARS));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseClaudeEditLines: images sent to the user', () => {
|
||||
it('takes the image attachments of a SendUserFile result, absolute paths only, without a window', () => {
|
||||
const line = toolResultLine(
|
||||
'toolu_send',
|
||||
'2026-10-15T20:50:12.538Z',
|
||||
{
|
||||
caption: 'Vier Alternativen',
|
||||
attachments: [
|
||||
{ path: '/tmp/s/icons/bremse_alt1_pedal.png', file_uuid: 'x' },
|
||||
{ path: '/tmp/s/icons/bremse_alt1_pedal.png' },
|
||||
{ path: '/tmp/s/report.pdf' },
|
||||
{ path: 'relative.png' },
|
||||
{ nope: 1 },
|
||||
],
|
||||
},
|
||||
{ agentId: 'a1' }
|
||||
);
|
||||
const { candidates, records } = parseClaudeEditLines(line);
|
||||
expect(records).toEqual([]);
|
||||
const at = Date.parse('2026-10-15T20:50:12.538Z');
|
||||
expect(candidates).toEqual([
|
||||
{
|
||||
toolUseId: 'toolu_send',
|
||||
start: at,
|
||||
end: at,
|
||||
paths: ['/tmp/s/icons/bremse_alt1_pedal.png'],
|
||||
sent: true,
|
||||
agentId: 'a1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractImagePathCandidates', () => {
|
||||
it('keeps a colon inside a file:// path (timestamped names)', () => {
|
||||
expect(extractImagePathCandidates('', 'Saved to file:///tmp/plot-12:30.png', '', CWD)).toEqual([
|
||||
'/tmp/plot-12:30.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves relative paths against cwd and keeps ~/ paths literal', () => {
|
||||
expect(extractImagePathCandidates('cp a.png ../b.jpeg ~/shots/c.gif', '', '', CWD)).toEqual([
|
||||
'/work/repo/a.png',
|
||||
'/work/b.jpeg',
|
||||
'~/shots/c.gif',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads quoted paths with spaces', () => {
|
||||
expect(extractImagePathCandidates(`magick "in dir/x.png" 'out dir/y.avif'`, '', '', CWD)).toEqual([
|
||||
'/work/repo/in dir/x.png',
|
||||
'/work/repo/out dir/y.avif',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores URLs, globs and names that only contain an image extension', () => {
|
||||
const cmd = 'curl https://x.org/a.png; ls *.png out/frame-?.png; mv a.png.bak b.pngx; echo c.png';
|
||||
expect(extractImagePathCandidates(cmd, '', '', CWD)).toEqual(['/work/repo/c.png']);
|
||||
});
|
||||
|
||||
it('splits key=value and trailing punctuation off', () => {
|
||||
expect(extractImagePathCandidates('render --out=out/a.png', 'done (out/b.png), see out/c.png.', '', CWD)).toEqual([
|
||||
'/work/repo/out/a.png',
|
||||
'/work/repo/out/b.png',
|
||||
'/work/repo/out/c.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates and caps the list', () => {
|
||||
const many = Array.from({ length: MAX_IMAGE_CANDIDATES + 10 }, (_, i) => `f${i}.png`).join(' ');
|
||||
const out = extractImagePathCandidates(`a.png a.png ${many}`, '', '', CWD);
|
||||
expect(out).toHaveLength(MAX_IMAGE_CANDIDATES);
|
||||
expect(out[0]).toBe('/work/repo/a.png');
|
||||
expect(out[1]).toBe('/work/repo/f0.png');
|
||||
});
|
||||
|
||||
it('drops relative paths when cwd is unknown', () => {
|
||||
expect(extractImagePathCandidates('x.png /abs/y.png', '', '', '')).toEqual(['/abs/y.png']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractImagePathCandidates: linear in the line length (#693 review)', () => {
|
||||
// Claude Code keeps up to 30,000 characters of Bash output, all of it agent-printed, and the
|
||||
// scan runs synchronously in the request. Before the fix one such line took ~2.7 s.
|
||||
const N = 30_000;
|
||||
// Generous: the gated suite runs at nice 19 next to the live service. Linear is ~1 ms.
|
||||
const BUDGET_MS = 1000;
|
||||
const timed = (text: string) => {
|
||||
const t = performance.now();
|
||||
const out = extractImagePathCandidates('render', text, '', CWD);
|
||||
return { out, ms: performance.now() - t };
|
||||
};
|
||||
const base64 = Buffer.from(Array.from({ length: (N * 3) / 4 }, (_, i) => (i * 131 + 7) & 255)).toString('base64');
|
||||
|
||||
it.each([
|
||||
...'#+%@![{^:/a.~-$'.split('').map((c) => [`30,000 × ${c}`, c.repeat(N)] as const),
|
||||
['a base64 blob', base64] as const,
|
||||
['repeated file:///', 'file:///'.repeat(N / 8)] as const,
|
||||
['repeated file:///x', 'file:///x'.repeat(N / 9)] as const,
|
||||
['repeated file:///a:', 'file:///a:'.repeat(N / 10)] as const,
|
||||
['file:/// then 30,000 characters', 'file:///' + 'a'.repeat(N)] as const,
|
||||
['an unclosed quote', '"' + 'a'.repeat(N)] as const,
|
||||
['repeated =a', '=a'.repeat(N / 2)] as const,
|
||||
])('%s stays under the time budget and still finds a path after it', (_label, line) => {
|
||||
const { out, ms } = timed(`${line}\nsaved out/plot.png`);
|
||||
expect(ms).toBeLessThan(BUDGET_MS);
|
||||
expect(out).toContain('/work/repo/out/plot.png');
|
||||
});
|
||||
|
||||
it('parses a chunk of 139 Bash results with a 30,000-character base64 blob each under budget', () => {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 139; i++) {
|
||||
lines.push(bashUseLine(`toolu_${i}`, '2026-10-15T20:00:00.000Z', 'cat img.b64'));
|
||||
lines.push(
|
||||
toolResultLine(`toolu_${i}`, '2026-10-15T20:00:01.000Z', {
|
||||
stdout: base64,
|
||||
stderr: '',
|
||||
interrupted: false,
|
||||
isImage: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
const t = performance.now();
|
||||
parseClaudeEditLines(lines.join('\n'));
|
||||
expect(performance.now() - t).toBeLessThan(3000); // 6.6 s before the fix
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyEditPath', () => {
|
||||
it.each([
|
||||
['/a/b.png', 'image'],
|
||||
['/a/b.JPG', 'image'],
|
||||
['/a/b.jpeg', 'image'],
|
||||
['/a/b.webp', 'image'],
|
||||
['/a/b.gif', 'image'],
|
||||
['/a/b.avif', 'image'],
|
||||
['/a/b.svg', 'image'],
|
||||
['/a/b.ts', 'code'],
|
||||
['/a/png', 'code'],
|
||||
['/a/b.png.orig', 'code'],
|
||||
])('%s → %s', (path, kind) => {
|
||||
expect(classifyEditPath(path)).toBe(kind);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Edit history end to end: the real reader (edit-history-local-source.ts), sync and store on
|
||||
* temp transcripts: what the fakes in edit-history-sync.test.ts cannot show (file order,
|
||||
* a new conversation next to the old one, every line read exactly once).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { readClaudeEditSources } from '../src/edit-history-local-source.js';
|
||||
import { EditHistoryStore } from '../src/edit-history-store.js';
|
||||
import { EditHistorySync, MAX_FORWARD_PASSES, type EditHistorySession } from '../src/web/edit-history-sync.js';
|
||||
|
||||
let root: string;
|
||||
const opened: EditHistoryStore[] = [];
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'edit-pipeline-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await Promise.all(opened.splice(0).map((s) => s.remove()));
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
|
||||
|
||||
/** One edit result line of `pad` bytes or more, newline included. */
|
||||
function editLine(id: string, i: number, pad = 0, agentId?: string): string {
|
||||
const rec: Record<string, unknown> = {
|
||||
type: 'user',
|
||||
timestamp: new Date(T0 + i * 1000).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult: {
|
||||
filePath: `/w/${id}.ts`,
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
|
||||
},
|
||||
pad: '',
|
||||
};
|
||||
if (agentId) rec.agentId = agentId;
|
||||
rec.pad = 'p'.repeat(Math.max(0, pad - JSON.stringify(rec).length - 1));
|
||||
return JSON.stringify(rec) + '\n';
|
||||
}
|
||||
|
||||
function setup(budget?: number, base = root) {
|
||||
const project = join(base, 'projects', 'p');
|
||||
mkdirSync(project, { recursive: true });
|
||||
const at = (transcriptId: string) => join(project, `${transcriptId}.jsonl`);
|
||||
/** The transcript watcher's path (the hooks report it; `/clear` moves it). */
|
||||
let current = at('convA');
|
||||
const sync = new EditHistorySync({
|
||||
transcriptPath: async () => (existsSync(current) ? current : null),
|
||||
readSources: readClaudeEditSources,
|
||||
probe: async (paths) => paths.map(() => null),
|
||||
loadStore: async (id) => {
|
||||
const store = await EditHistoryStore.load(id, { dir: join(base, 'store'), debounceMs: 10 });
|
||||
opened.push(store);
|
||||
return store;
|
||||
},
|
||||
deleteStore: (id) => EditHistoryStore.delete(id, { dir: join(base, 'store') }),
|
||||
sweepStores: async () => 0,
|
||||
budget,
|
||||
});
|
||||
const session: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'convA' };
|
||||
const ids = async () =>
|
||||
(await sync.getStore('s1'))
|
||||
.list()
|
||||
.map((e) => e.id)
|
||||
.sort();
|
||||
return {
|
||||
project,
|
||||
at,
|
||||
sync,
|
||||
session,
|
||||
ids,
|
||||
switchTo: (transcriptId: string) => (current = at(transcriptId)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('a new conversation (/clear, resume)', () => {
|
||||
it('reads the previous transcript to its end before the new one', async () => {
|
||||
const { at, sync, session, ids, switchTo } = setup();
|
||||
appendFileSync(at('convA'), editLine('a1', 1));
|
||||
await sync.sync(session);
|
||||
// The turn's last edits land, then /clear before the panel's debounced re-read.
|
||||
appendFileSync(at('convA'), editLine('a2', 2) + editLine('a3', 3));
|
||||
switchTo('convB');
|
||||
appendFileSync(at('convB'), editLine('b1', 4));
|
||||
await sync.sync(session);
|
||||
expect(await ids()).toEqual(['a1', 'a2', 'a3', 'b1']);
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.getTranscriptId()).toBe('convB');
|
||||
expect(store.knownSources()).toEqual(['main']);
|
||||
});
|
||||
|
||||
it('a previous backlog larger than one sync reads on with the next ones; the new transcript follows', async () => {
|
||||
const { at, sync, session, ids, switchTo } = setup(600);
|
||||
appendFileSync(at('convA'), editLine('a0', 0));
|
||||
await sync.sync(session);
|
||||
const backlog = Array.from({ length: MAX_FORWARD_PASSES * 3 }, (_, i) => `a${i + 1}`);
|
||||
appendFileSync(at('convA'), backlog.map((id, i) => editLine(id, i + 1, 500)).join(''));
|
||||
switchTo('convB');
|
||||
appendFileSync(at('convB'), editLine('b1', 99));
|
||||
await sync.sync(session);
|
||||
expect(await ids()).not.toContain('b1');
|
||||
for (let i = 0; i < 5; i++) await sync.sync(session);
|
||||
expect(await ids()).toEqual(['a0', ...backlog, 'b1'].sort());
|
||||
});
|
||||
|
||||
it('subagents of the previous conversation are read to their end too', async () => {
|
||||
const { project, at, sync, session, ids, switchTo } = setup();
|
||||
const sub = join(project, 'convA', 'subagents');
|
||||
mkdirSync(sub, { recursive: true });
|
||||
appendFileSync(at('convA'), editLine('a1', 1));
|
||||
appendFileSync(join(sub, 'agent-x.jsonl'), editLine('x1', 2, 0, 'x'));
|
||||
await sync.sync(session);
|
||||
appendFileSync(join(sub, 'agent-x.jsonl'), editLine('x2', 3, 0, 'x'));
|
||||
switchTo('convB');
|
||||
appendFileSync(at('convB'), editLine('b1', 4));
|
||||
await sync.sync(session);
|
||||
expect(await ids()).toEqual(['a1', 'b1', 'x1', 'x2']);
|
||||
});
|
||||
|
||||
it('a previous transcript that is gone does not hold up the new one', async () => {
|
||||
const { at, sync, session, ids, switchTo } = setup();
|
||||
appendFileSync(at('convA'), editLine('a1', 1));
|
||||
await sync.sync(session);
|
||||
rmSync(at('convA'));
|
||||
switchTo('convB');
|
||||
appendFileSync(at('convB'), editLine('b1', 2));
|
||||
await sync.sync(session);
|
||||
expect(await ids()).toEqual(['a1', 'b1']);
|
||||
});
|
||||
|
||||
it('"Load older" before the forward read moved on reads the stored conversation, not the new file', async () => {
|
||||
const { at, sync, session, ids, switchTo } = setup(600);
|
||||
const older = Array.from({ length: 4 }, (_, i) => `a${i}`);
|
||||
appendFileSync(at('convA'), older.map((id, i) => editLine(id, i, 500)).join(''));
|
||||
await sync.sync(session);
|
||||
expect(await ids()).toEqual(['a3']);
|
||||
switchTo('convB');
|
||||
appendFileSync(at('convB'), editLine('b1', 99));
|
||||
await sync.sync(session, { older: true });
|
||||
expect(await ids()).toEqual(['a2', 'a3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('every line exactly once (seeded random transcripts)', () => {
|
||||
/** Deterministic: a seed fixes budget, line lengths, subagents, partial writes and sync points. */
|
||||
async function run(seed: number): Promise<string | null> {
|
||||
let state = seed;
|
||||
const rnd = () => {
|
||||
state = (state * 1103515245 + 12345) & 0x7fffffff;
|
||||
return state / 0x7fffffff;
|
||||
};
|
||||
const ri = (a: number, b: number) => a + Math.floor(rnd() * (b - a + 1));
|
||||
const budget = ri(400, 1500);
|
||||
const { project, at, sync, session } = setup(budget, mkdtempSync(join(root, 'run-')));
|
||||
const main = at('convA');
|
||||
const subDir = join(project, 'convA', 'subagents');
|
||||
const expected = new Set<string>();
|
||||
const tooLong = new Set<string>();
|
||||
const agents: string[] = [];
|
||||
const tails = new Map<string, string>();
|
||||
let n = 0;
|
||||
const write = () => {
|
||||
if (rnd() < 0.15 && agents.length < 6) {
|
||||
mkdirSync(subDir, { recursive: true });
|
||||
agents.push(`a${agents.length}`);
|
||||
}
|
||||
const k = ri(0, agents.length);
|
||||
const agent = k === 0 ? undefined : agents[k - 1];
|
||||
const file = agent ? join(subDir, `agent-${agent}.jsonl`) : main;
|
||||
const id = `e${n++}`;
|
||||
// Some lines are longer than the budget: skipped, never shown, never blocking.
|
||||
const line = editLine(id, n, rnd() < 0.12 ? ri(budget + 2, budget * 3) : ri(250, budget - 2), agent);
|
||||
if (Buffer.byteLength(line) < budget) expected.add(id);
|
||||
else if (Buffer.byteLength(line) > budget) tooLong.add(id);
|
||||
// Sometimes half a line: claude is still writing it.
|
||||
const cut = rnd() < 0.15 ? ri(1, line.length - 1) : line.length;
|
||||
appendFileSync(file, (tails.get(file) ?? '') + line.slice(0, cut));
|
||||
if (cut < line.length) tails.set(file, line.slice(cut));
|
||||
else tails.delete(file);
|
||||
};
|
||||
for (let i = ri(0, 20); i > 0; i--) write(); // backlog before the panel opens
|
||||
if (!existsSync(main)) appendFileSync(main, '');
|
||||
for (let r = ri(5, 25); r > 0; r--) {
|
||||
for (let i = ri(0, 6); i > 0; i--) write();
|
||||
await sync.sync(session);
|
||||
}
|
||||
for (const [file, rest] of tails) appendFileSync(file, rest);
|
||||
for (let i = 0; i < 40; i++) await sync.sync(session);
|
||||
const store = await sync.getStore('s1');
|
||||
for (let clicks = 0; store.backfillTruncated && clicks < 400; clicks++) await sync.sync(session, { older: true });
|
||||
const got = new Set(store.list().map((e) => e.id));
|
||||
const missing = [...expected].filter((id) => !got.has(id));
|
||||
const extra = [...got].filter((id) => tooLong.has(id));
|
||||
if (missing.length || extra.length || store.backfillTruncated) {
|
||||
return `seed ${seed} (budget ${budget}): missing ${missing.join(',')} extra ${extra.join(',')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
it('reads every line shorter than the budget once, forward and back, and none longer', async () => {
|
||||
const failures: string[] = [];
|
||||
for (let seed = 1; seed <= 40; seed++) {
|
||||
await Promise.all(opened.splice(0).map((s) => s.remove()));
|
||||
const r = await run(seed);
|
||||
if (r) failures.push(r);
|
||||
}
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Edit history image probe against a filesystem that stopped answering (#693 review): a Bash
|
||||
* call printed a path on a dead hard mount, whose stat() never settles. The real sync with the
|
||||
* real probe (`probeLocalPaths` → bounded `probePathKind`) must still finish, the next queued
|
||||
* sync too, and `remove()` must not wait on it. Own file: the stalled probe stays recorded
|
||||
* process-wide for the rest of the run.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const DEAD = '/mnt/dead-nas';
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100';
|
||||
});
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import('node:fs/promises')>();
|
||||
const hangs = (p: unknown) => String(p).startsWith('/mnt/dead-nas');
|
||||
const stat = ((p: Parameters<typeof real.stat>[0], ...rest: unknown[]) =>
|
||||
hangs(p) ? new Promise(() => {}) : (real.stat as (...a: unknown[]) => unknown)(p, ...rest)) as typeof real.stat;
|
||||
const realpath = ((p: Parameters<typeof real.realpath>[0], ...rest: unknown[]) =>
|
||||
hangs(p)
|
||||
? new Promise(() => {})
|
||||
: (real.realpath as (...a: unknown[]) => unknown)(p, ...rest)) as typeof real.realpath;
|
||||
return { ...real, stat, realpath, default: { ...real, stat, realpath } };
|
||||
});
|
||||
|
||||
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { EditHistoryStore } from '../src/edit-history-store.js';
|
||||
import { probeLocalPaths, type EditSources } from '../src/edit-history-local-source.js';
|
||||
import { EditHistorySync, type EditHistorySession } from '../src/web/edit-history-sync.js';
|
||||
|
||||
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
|
||||
const session: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' };
|
||||
|
||||
function bashPair(id: string, start: number, end: number, stdout: string): string {
|
||||
const use = {
|
||||
type: 'assistant',
|
||||
timestamp: new Date(start).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command: 'render' } }] },
|
||||
};
|
||||
const res = {
|
||||
type: 'user',
|
||||
timestamp: new Date(end).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: stdout }] },
|
||||
toolUseResult: { stdout, stderr: '', interrupted: false, isImage: false },
|
||||
};
|
||||
return JSON.stringify(use) + '\n' + JSON.stringify(res) + '\n';
|
||||
}
|
||||
|
||||
/** Rejects after `ms`, so a hang fails the test instead of timing it out silently. */
|
||||
function within<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
|
||||
return Promise.race([
|
||||
p,
|
||||
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(`${what} still pending after ${ms} ms`)), ms)),
|
||||
]);
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
let stores: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'edit-stall-'));
|
||||
stores = join(dir, 'stores');
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('edit history image probe on an unresponsive mount', () => {
|
||||
it('finishes sync, a queued sync and remove() while one printed path never answers', async () => {
|
||||
const ok = join(dir, 'ok.png');
|
||||
writeFileSync(ok, 'PNG!');
|
||||
utimesSync(ok, new Date(T0 + 2000), new Date(T0 + 2000));
|
||||
let main = Buffer.from(bashPair('b1', T0, T0 + 5000, `${DEAD}/plots/a.png\nsaved ${ok}\n`));
|
||||
|
||||
const sync = new EditHistorySync({
|
||||
transcriptPath: async () => '/p/conv1.jsonl',
|
||||
readSources: async (_path, requests, budget): Promise<EditSources> => {
|
||||
const req = requests.find((r) => r.relPath === 'main');
|
||||
const start = Math.min(req?.offset ?? 0, main.length);
|
||||
const len = Math.min(budget, main.length - start);
|
||||
return {
|
||||
transcriptId: 'conv1',
|
||||
files: [{ relPath: 'main', size: main.length, startOffset: start, chunk: main.subarray(start, start + len) }],
|
||||
metas: {},
|
||||
};
|
||||
},
|
||||
probe: (paths) => probeLocalPaths(paths),
|
||||
loadStore: (id) => EditHistoryStore.load(id, { dir: stores, debounceMs: 0 }),
|
||||
deleteStore: (id) => EditHistoryStore.delete(id, { dir: stores }),
|
||||
sweepStores: async () => 0,
|
||||
});
|
||||
|
||||
const first = sync.sync(session);
|
||||
main = Buffer.concat([main, Buffer.from(bashPair('b2', T0 + 6000, T0 + 7000, `${DEAD}/plots/b.png\n`))]);
|
||||
const second = sync.sync(session);
|
||||
expect(await within(first, 1500, 'first sync')).toEqual({ state: 'ok' });
|
||||
expect(await within(second, 1500, 'queued sync')).toEqual({ state: 'ok' });
|
||||
expect((await sync.getStore('s1')).list().map((e) => e.path)).toEqual([ok]);
|
||||
await within(sync.remove('s1'), 1500, 'remove()');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,704 @@
|
||||
/**
|
||||
* Edit history store: byte ranges, cursors, dedupe, cap, persistence.
|
||||
* Transcript bytes are built from JSONL lines shaped like real claude records.
|
||||
*/
|
||||
import { mkdtempSync, rmSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
EditHistoryStore,
|
||||
entryBytes,
|
||||
MAIN_SOURCE,
|
||||
MAX_AGENT_META_CHARS,
|
||||
MAX_AGENTS,
|
||||
MAX_ENTRIES,
|
||||
MAX_FILE_BYTES,
|
||||
MAX_STORE_BYTES,
|
||||
parseAgentMeta,
|
||||
READ_BUDGET_BYTES,
|
||||
TMP_TAG,
|
||||
} from '../src/edit-history-store.js';
|
||||
import { MAX_HUNK_BYTES } from '../src/edit-history-parse.js';
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'edit-history-store-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const T0 = Date.parse('2026-10-15T19:00:00.000Z');
|
||||
const iso = (s: number) => new Date(T0 + s * 1000).toISOString();
|
||||
|
||||
function editLine(id: string, sec: number, path = `/work/${id}.ts`, extra: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: iso(sec),
|
||||
cwd: '/work',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult: {
|
||||
filePath: path,
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
originalFile: 'a',
|
||||
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
|
||||
userModified: false,
|
||||
replaceAll: false,
|
||||
},
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
/** An Edit whose diff carries `size` bytes of added text. */
|
||||
function bigEditLine(id: string, sec: number, size: number): string {
|
||||
const line = JSON.parse(editLine(id, sec));
|
||||
line.toolUseResult.structuredPatch[0].lines = ['-a', '+' + 'x'.repeat(size)];
|
||||
return JSON.stringify(line);
|
||||
}
|
||||
|
||||
function bashUse(id: string, sec: number, command: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: iso(sec),
|
||||
cwd: '/work',
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
|
||||
});
|
||||
}
|
||||
|
||||
function bashResult(id: string, sec: number): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: iso(sec),
|
||||
cwd: '/work',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] },
|
||||
toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false },
|
||||
});
|
||||
}
|
||||
|
||||
const jsonl = (lines: string[]) => Buffer.from(lines.map((l) => l + '\n').join(''));
|
||||
|
||||
/**
|
||||
* Read like the reader (edit-history-local-source.ts): on from the cursor of the current
|
||||
* conversation (clamped to the size), else the file's last `budget` bytes; then apply.
|
||||
* Null when nothing is new.
|
||||
*/
|
||||
function syncForward(
|
||||
store: EditHistoryStore,
|
||||
file: Buffer,
|
||||
budget = READ_BUDGET_BYTES,
|
||||
source = MAIN_SOURCE,
|
||||
tid = 'conv-1'
|
||||
) {
|
||||
const cursor = tid === store.getTranscriptId() ? store.getCursor(source) : undefined;
|
||||
const offset = cursor ? Math.min(cursor.offset, file.length) : Math.max(0, file.length - budget);
|
||||
const len = Math.min(budget, file.length - offset);
|
||||
if (cursor && offset === cursor.offset && len === 0) return null;
|
||||
const chunk = file.subarray(offset, offset + len);
|
||||
return store.apply({ transcriptId: tid, source, startOffset: offset, chunk, size: file.length });
|
||||
}
|
||||
|
||||
function syncOlder(store: EditHistoryStore, file: Buffer, budget?: number, source = MAIN_SOURCE, tid = 'conv-1') {
|
||||
const range = store.olderRange(source, budget);
|
||||
if (!range) return null;
|
||||
const chunk = file.subarray(range.offset, range.offset + range.len);
|
||||
return store.applyOlder({ transcriptId: tid, source, startOffset: range.offset, chunk, size: file.length });
|
||||
}
|
||||
|
||||
const ids = (store: EditHistoryStore) => store.list().map((e) => e.id);
|
||||
|
||||
describe('EditHistoryStore: forward reads', () => {
|
||||
it('reads a small file whole, then only what was appended', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
let file = jsonl([editLine('e1', 1), editLine('e2', 2)]);
|
||||
expect(syncForward(store, file)?.added).toBe(2);
|
||||
expect(store.getCursor(MAIN_SOURCE)).toEqual({ offset: file.length, floor: 0 });
|
||||
expect(syncForward(store, file)).toBeNull();
|
||||
file = Buffer.concat([file, jsonl([editLine('e3', 3)])]);
|
||||
expect(syncForward(store, file)?.added).toBe(1);
|
||||
expect(ids(store)).toEqual(['e3', 'e2', 'e1']);
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a partial last line (still being written) for the next read', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const full = jsonl([editLine('e1', 1), editLine('e2', 2)]);
|
||||
const firstLineEnd = full.indexOf(0x0a) + 1;
|
||||
const partial = full.subarray(0, firstLineEnd + 30);
|
||||
expect(syncForward(store, partial)?.added).toBe(1);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(firstLineEnd);
|
||||
expect(syncForward(store, full)?.added).toBe(1);
|
||||
expect(ids(store)).toEqual(['e2', 'e1']);
|
||||
});
|
||||
|
||||
it('deduplicates by tool_use_id', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl([editLine('e1', 1), editLine('e1', 1)]);
|
||||
expect(syncForward(store, file)?.added).toBe(1);
|
||||
expect(store.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('starts a large file near its end, drops the cut line and keeps the floor there', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const lines = Array.from({ length: 20 }, (_, i) => editLine(`e${i}`, i));
|
||||
const file = jsonl(lines);
|
||||
const budget = Math.floor(file.length / 3);
|
||||
syncForward(store, file, budget);
|
||||
const cursor = store.getCursor(MAIN_SOURCE)!;
|
||||
expect(cursor.offset).toBe(file.length);
|
||||
expect(cursor.floor).toBeGreaterThan(file.length - budget);
|
||||
expect(file[cursor.floor - 1]).toBe(0x0a);
|
||||
expect(store.backfillTruncated).toBe(true);
|
||||
expect(ids(store)[0]).toBe('e19');
|
||||
expect(ids(store)).not.toContain('e0');
|
||||
});
|
||||
|
||||
it('reads a backlog in budget-sized forward steps without losing a line', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const first = jsonl([editLine('e0', 0)]);
|
||||
syncForward(store, first);
|
||||
const more = Array.from({ length: 30 }, (_, i) => editLine(`e${i + 1}`, i + 1));
|
||||
const file = Buffer.concat([first, jsonl(more)]);
|
||||
const budget = 700;
|
||||
for (let i = 0; i < 100 && syncForward(store, file, budget); i++);
|
||||
expect(store.list()).toHaveLength(31);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length);
|
||||
});
|
||||
|
||||
it('skips a line longer than the budget instead of re-reading it forever', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const huge = editLine('big', 1, '/work/big.ts', { pad: 'x'.repeat(5000) });
|
||||
const first = jsonl([editLine('e0', 0)]);
|
||||
syncForward(store, first);
|
||||
const file = Buffer.concat([first, jsonl([huge, editLine('e2', 2)])]);
|
||||
for (let i = 0; i < 50 && syncForward(store, file, 1000); i++);
|
||||
expect(ids(store)).toEqual(['e2', 'e0']);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length);
|
||||
});
|
||||
|
||||
it('reads the line after a skipped one whole, wherever it falls against the chunk grid', async () => {
|
||||
const budget = 1000;
|
||||
const first = jsonl([editLine('e0', 0)]);
|
||||
const lost: number[] = [];
|
||||
for (let pad = 0; pad < budget; pad++) {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
syncForward(store, first);
|
||||
const huge = JSON.stringify({ type: 'user', message: { content: 'h'.repeat(2400 + pad) } });
|
||||
const next = editLine('next', 2, '/work/next.ts', { pad: 'p'.repeat(300) }); // < budget
|
||||
const file = Buffer.concat([first, jsonl([huge, next, editLine('e3', 3)])]);
|
||||
for (let i = 0; i < 50 && syncForward(store, file, budget); i++);
|
||||
if (!store.get('next') || !store.get('e3')) lost.push(pad);
|
||||
await store.remove();
|
||||
}
|
||||
expect(lost).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a multibyte character split by the chunk boundary intact', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const path = '/work/Größe – Ümlaut 😀.ts';
|
||||
const file = jsonl([editLine('e1', 1), editLine('e2', 2, path)]);
|
||||
const emoji = file.indexOf(Buffer.from('😀'));
|
||||
// Cut inside the 4-byte emoji, then continue.
|
||||
const cut = file.subarray(0, emoji + 2);
|
||||
syncForward(store, cut);
|
||||
syncForward(store, file);
|
||||
expect(store.get('e2')?.path).toBe(path);
|
||||
});
|
||||
|
||||
it('restarts a file that shrank (replaced) from its end, with the next read', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
|
||||
const replaced = jsonl([editLine('n1', 3)]);
|
||||
// The reader clamps the cursor to the size: an empty chunk at the end, and the cursor goes.
|
||||
expect(syncForward(store, replaced)?.added).toBe(0);
|
||||
expect(store.getCursor(MAIN_SOURCE)).toBeUndefined();
|
||||
expect(syncForward(store, replaced)?.added).toBe(1);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(replaced.length);
|
||||
});
|
||||
|
||||
it('ignores a chunk that does not start at the cursor', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl([editLine('e1', 1), editLine('e2', 2)]);
|
||||
syncForward(store, file);
|
||||
const res = store.apply({ transcriptId: 'conv-1', source: MAIN_SOURCE, startOffset: 3, chunk: file, size: 999 });
|
||||
expect(res.added).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: conversations and sources', () => {
|
||||
it('starts new cursors for a new transcriptId and keeps the old entries', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
const second = jsonl([editLine('f1', 5)]);
|
||||
syncForward(store, second, undefined, MAIN_SOURCE, 'conv-2');
|
||||
expect(store.getTranscriptId()).toBe('conv-2');
|
||||
expect(ids(store)).toEqual(['f1', 'e1']);
|
||||
});
|
||||
|
||||
it('keeps one cursor per source and the subagent id on its entries', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const main = jsonl([editLine('m1', 1)]);
|
||||
const sub = jsonl([editLine('s1', 2, '/work/s.ts', { isSidechain: true, agentId: 'a1' })]);
|
||||
const subSource = 'subagents/agent-a1.jsonl';
|
||||
syncForward(store, main);
|
||||
syncForward(store, sub, undefined, subSource);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(main.length);
|
||||
expect(store.getCursor(subSource)?.offset).toBe(sub.length);
|
||||
expect(store.get('s1')?.agentId).toBe('a1');
|
||||
});
|
||||
|
||||
it('records agent meta from meta.json text', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
store.setAgentMeta(
|
||||
'a1',
|
||||
JSON.stringify({
|
||||
agentType: 'coding',
|
||||
description: 'Impl: fix',
|
||||
worktreePath: '/w/.claude/worktrees/agent-a1',
|
||||
worktreeBranch: 'worktree-agent-a1',
|
||||
toolUseId: 'toolu_x',
|
||||
})
|
||||
);
|
||||
store.setAgentMeta('a2', 'not json');
|
||||
expect(store.getAgents()).toEqual({
|
||||
a1: {
|
||||
description: 'Impl: fix',
|
||||
agentType: 'coding',
|
||||
worktreePath: '/w/.claude/worktrees/agent-a1',
|
||||
worktreeBranch: 'worktree-agent-a1',
|
||||
},
|
||||
});
|
||||
expect(store.hasAgentMeta('a2')).toBe(false);
|
||||
expect(parseAgentMeta('{"description":"d"}')).toEqual({ description: 'd', agentType: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: older blocks', () => {
|
||||
it('loads older lines block by block down to the start, without duplicates at the seam', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl(Array.from({ length: 30 }, (_, i) => editLine(`e${i}`, i)));
|
||||
const budget = Math.floor(file.length / 4);
|
||||
syncForward(store, file, budget);
|
||||
const before = store.list().length;
|
||||
expect(before).toBeLessThan(30);
|
||||
let rounds = 0;
|
||||
while (syncOlder(store, file, budget) && rounds < 20) rounds++;
|
||||
expect(rounds).toBeGreaterThan(1);
|
||||
expect(store.list()).toHaveLength(30);
|
||||
expect(store.getCursor(MAIN_SOURCE)?.floor).toBe(0);
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
expect(store.olderRange(MAIN_SOURCE, budget)).toBeNull();
|
||||
});
|
||||
|
||||
it('skips a line longer than the budget going back and reaches the lines before it', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const huge = JSON.stringify({ type: 'user', message: { content: 'x'.repeat(6000) } });
|
||||
const older = Array.from({ length: 10 }, (_, i) => editLine(`old${i}`, i));
|
||||
const newer = Array.from({ length: 5 }, (_, i) => editLine(`new${i}`, 100 + i));
|
||||
const file = jsonl([...older, huge, ...newer]);
|
||||
const budget = 4000;
|
||||
syncForward(store, file, budget);
|
||||
expect(ids(store).sort()).toEqual(newer.map((_, i) => `new${i}`).sort());
|
||||
let rounds = 0;
|
||||
while (syncOlder(store, file, budget) && rounds < 30) rounds++;
|
||||
expect(store.list()).toHaveLength(15);
|
||||
expect(store.getCursor(MAIN_SOURCE)).toMatchObject({ floor: 0 });
|
||||
expect(store.getCursor(MAIN_SOURCE)?.floorMidLine).toBeUndefined();
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('going back, reads the line before a skipped one whole, wherever it falls against the block grid', async () => {
|
||||
const budget = 1000;
|
||||
const huge = JSON.stringify({ type: 'user', message: { content: 'h'.repeat(2500) } });
|
||||
const lost: number[] = [];
|
||||
for (let pad = 0; pad < budget; pad++) {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
const lines = [editLine('p0', 0), editLine('p1', 1), editLine('before', 2), huge];
|
||||
for (let i = 0; i < 6; i++)
|
||||
lines.push(editLine(`t${i}`, 10 + i, `/work/t${i}.ts`, i === 0 ? { pad: 'p'.repeat(pad) } : {}));
|
||||
const file = jsonl(lines);
|
||||
syncForward(store, file, budget);
|
||||
for (let i = 0; i < 50 && syncOlder(store, file, budget); i++);
|
||||
if (!store.get('before') || !store.get('p0')) lost.push(pad);
|
||||
await store.remove();
|
||||
}
|
||||
expect(lost).toEqual([]);
|
||||
});
|
||||
|
||||
it('a source whose file is gone (dropSource) offers no older lines and keeps its entries', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const sub = 'subagents/agent-a1.jsonl';
|
||||
const file = jsonl(Array.from({ length: 10 }, (_, i) => editLine(`s${i}`, i, `/work/s${i}.ts`, { agentId: 'a1' })));
|
||||
syncForward(store, file, 400, sub);
|
||||
expect(store.sourcesWithOlder()).toEqual([sub]);
|
||||
const kept = ids(store);
|
||||
store.dropSource(sub);
|
||||
expect(store.getCursor(sub)).toBeUndefined();
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
expect(ids(store)).toEqual(kept);
|
||||
});
|
||||
|
||||
it('refuses an older chunk that does not end at the floor', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl(Array.from({ length: 10 }, (_, i) => editLine(`e${i}`, i)));
|
||||
syncForward(store, file, 300);
|
||||
const res = store.applyOlder({
|
||||
transcriptId: 'conv-1',
|
||||
source: MAIN_SOURCE,
|
||||
startOffset: 0,
|
||||
chunk: file.subarray(0, 10),
|
||||
size: file.length,
|
||||
});
|
||||
expect(res.added).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: Bash images', () => {
|
||||
it('pairs a Bash call and its result across a chunk boundary', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const part1 = jsonl([bashUse('b1', 1, 'python render.py -o out/frame.png')]);
|
||||
const file = Buffer.concat([part1, jsonl([bashResult('b1', 4)])]);
|
||||
expect(syncForward(store, part1)?.candidates).toEqual([]);
|
||||
const res = syncForward(store, file)!;
|
||||
expect(res.candidates).toEqual([
|
||||
{ toolUseId: 'b1', start: T0 + 1000, end: T0 + 4000, paths: ['/work/out/frame.png'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds confirmed images as entries without a diff, once', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const img = { toolUseId: 'b1', path: '/work/out/frame.png', ts: T0 + 4000, agentId: 'a1' };
|
||||
expect(store.addImageEntries([img, img])).toBe(1);
|
||||
expect(store.addImageEntries([img])).toBe(0);
|
||||
expect(store.list()[0]).toEqual({
|
||||
id: 'b1:/work/out/frame.png',
|
||||
ts: T0 + 4000,
|
||||
path: '/work/out/frame.png',
|
||||
kind: 'image',
|
||||
tool: 'bash',
|
||||
created: false,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
hunks: [],
|
||||
truncated: false,
|
||||
agentId: 'a1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: cap', () => {
|
||||
it(`keeps the newest ${MAX_ENTRIES} entries`, () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl(Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i)));
|
||||
const res = syncForward(store, file, file.length);
|
||||
expect(res?.added).toBe(MAX_ENTRIES);
|
||||
expect(store.list()).toHaveLength(MAX_ENTRIES);
|
||||
expect(store.get('e19')).toBeUndefined();
|
||||
expect(store.get('e20')).toBeDefined();
|
||||
// Older than everything kept: counts as nothing added.
|
||||
expect(store.addImageEntries([{ toolUseId: 'old', path: '/a.png', ts: T0 - 1000 }])).toBe(0);
|
||||
});
|
||||
|
||||
it('offers no older block once the cap is full (it would be dropped on read)', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const lines = Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i));
|
||||
const file = jsonl(lines);
|
||||
const tail = jsonl(lines.slice(-MAX_ENTRIES)).length;
|
||||
syncForward(store, file, tail + 10);
|
||||
expect(store.list()).toHaveLength(MAX_ENTRIES);
|
||||
expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0);
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it(`keeps at most ${MAX_STORE_BYTES} bytes of diff text, dropping the oldest`, () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const n = 300;
|
||||
const file = jsonl(Array.from({ length: n }, (_, i) => bigEditLine(`e${i}`, i, 20_000)));
|
||||
syncForward(store, file, file.length);
|
||||
const kept = store.list();
|
||||
expect(kept.length).toBeLessThan(n);
|
||||
expect(kept.reduce((sum, e) => sum + entryBytes(e), 0)).toBeLessThanOrEqual(MAX_STORE_BYTES);
|
||||
expect(store.get(`e${n - 1}`)).toBeDefined();
|
||||
expect(store.get('e0')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('offers no older block once the byte cap is full', () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
const file = jsonl(Array.from({ length: 300 }, (_, i) => bigEditLine(`e${i}`, i, 20_000)));
|
||||
syncForward(store, file, 5_000_000);
|
||||
expect(store.list().length).toBeLessThan(MAX_ENTRIES);
|
||||
expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0);
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('caps the agent meta strings', () => {
|
||||
const meta = parseAgentMeta(JSON.stringify({ description: 'd'.repeat(5000), agentType: 'coding' }));
|
||||
expect(meta?.description).toHaveLength(MAX_AGENT_META_CHARS);
|
||||
expect(meta?.agentType).toBe('coding');
|
||||
});
|
||||
|
||||
it(`keeps at most ${MAX_AGENTS} subagent labels, dropping the oldest no entry uses`, () => {
|
||||
const store = new EditHistoryStore('s1', { dir });
|
||||
syncForward(store, jsonl([editLine('e1', 1, '/work/a.ts', { agentId: 'a0' })]));
|
||||
for (let i = 0; i <= MAX_AGENTS + 4; i++) store.setAgentMeta(`a${i}`, `{"description":"d${i}"}`);
|
||||
const agents = store.getAgents();
|
||||
expect(Object.keys(agents)).toHaveLength(MAX_AGENTS);
|
||||
expect(agents.a0).toBeDefined(); // used by an entry
|
||||
expect(agents[`a${MAX_AGENTS + 4}`]).toBeDefined(); // the newest
|
||||
expect(agents.a1).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistoryStore: persistence', () => {
|
||||
it('reloads entries, cursors, pending Bash calls and agents from disk', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
const file = jsonl([editLine('e1', 1), bashUse('b1', 2, 'render > a.png')]);
|
||||
syncForward(store, file);
|
||||
store.setAgentMeta('a1', '{"description":"d","agentType":"coding"}');
|
||||
await store.flush();
|
||||
const loaded = await EditHistoryStore.load('s1', { dir });
|
||||
expect(ids(loaded)).toEqual(['e1']);
|
||||
expect(loaded.getCursor(MAIN_SOURCE)).toEqual(store.getCursor(MAIN_SOURCE));
|
||||
expect(loaded.getTranscriptId()).toBe('conv-1');
|
||||
expect(loaded.getAgents()).toEqual({ a1: { description: 'd', agentType: 'coding' } });
|
||||
const done = Buffer.concat([file, jsonl([bashResult('b1', 3)])]);
|
||||
expect(syncForward(loaded, done)?.candidates[0]?.paths).toEqual(['/work/a.png']);
|
||||
});
|
||||
|
||||
it('writes the file 0600 in a 0700 directory, also over a wider existing file', async () => {
|
||||
const sub = join(dir, 'edit-history');
|
||||
const store = new EditHistoryStore('s1', { dir: sub, debounceMs: 60_000 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
await store.flush();
|
||||
expect(statSync(sub).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600);
|
||||
writeFileSync(join(sub, 's1.json'), '{}', { mode: 0o644 });
|
||||
await store.flush();
|
||||
expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('applies the caps and drops malformed entries when loading', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
syncForward(store, jsonl(Array.from({ length: MAX_ENTRIES }, (_, i) => editLine(`e${i}`, i))));
|
||||
await store.flush();
|
||||
const data = JSON.parse(readFileSync(join(dir, 's1.json'), 'utf-8'));
|
||||
const extra = JSON.parse(editLine('late', MAX_ENTRIES + 1));
|
||||
data.entries.push(
|
||||
{ ...store.get('e0')!, id: 'newest', ts: T0 + 10_000_000 },
|
||||
{ id: 'broken', path: 7, hunks: [] },
|
||||
{ id: 'nohunks', path: '/a.ts' },
|
||||
extra
|
||||
);
|
||||
writeFileSync(join(dir, 's1.json'), JSON.stringify(data));
|
||||
const loaded = await EditHistoryStore.load('s1', { dir });
|
||||
expect(loaded.list()).toHaveLength(MAX_ENTRIES);
|
||||
expect(loaded.get('newest')).toBeDefined();
|
||||
expect(loaded.get('e0')).toBeUndefined();
|
||||
expect(loaded.get('broken')).toBeUndefined();
|
||||
expect(loaded.get('nohunks')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops broken cursors, pending Bash calls and agent labels when loading (a damaged file)', async () => {
|
||||
writeFileSync(
|
||||
join(dir, 's1.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
sessionId: 's1',
|
||||
transcriptId: 'conv-1',
|
||||
cursors: {
|
||||
main: { offset: 1.5, floor: 0 },
|
||||
'subagents/agent-x.jsonl': { offset: -5, floor: 0 },
|
||||
'subagents/agent-y.jsonl': { offset: 10, floor: 20 },
|
||||
'subagents/agent-z.jsonl': { offset: 10, floor: 5, floorMidLine: true },
|
||||
},
|
||||
pendingBash: [null, 7, { id: 'b1', ts: 1, command: 'render > a.png', cwd: '/work' }],
|
||||
agents: { x: null, y: 'text', z: { description: 'd', agentType: 'coding' } },
|
||||
entries: [{ id: 'e1', ts: 1, path: '/a.ts', hunks: [null] }],
|
||||
})
|
||||
);
|
||||
const loaded = await EditHistoryStore.load('s1', { dir });
|
||||
expect(loaded.knownSources()).toEqual(['subagents/agent-z.jsonl']);
|
||||
expect(loaded.getCursor('subagents/agent-z.jsonl')).toEqual({ offset: 10, floor: 5, floorMidLine: true });
|
||||
expect(loaded.getAgents()).toEqual({ z: { description: 'd', agentType: 'coding' } });
|
||||
expect(loaded.list()).toEqual([]);
|
||||
// The surviving pending call still pairs with its result, and nothing throws.
|
||||
expect(syncForward(loaded, jsonl([bashResult('b1', 3)]))?.candidates[0]?.paths).toEqual(['/work/a.png']);
|
||||
});
|
||||
|
||||
it('rebuilds loaded entries under the same bounds: known fields only, string diff lines, the per-edit cap', async () => {
|
||||
const good = {
|
||||
ts: 1,
|
||||
path: '/a.ts',
|
||||
kind: 'code',
|
||||
tool: 'Edit',
|
||||
created: false,
|
||||
added: 1,
|
||||
removed: 0,
|
||||
truncated: false,
|
||||
};
|
||||
writeFileSync(
|
||||
join(dir, 's1.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
sessionId: 's1',
|
||||
transcriptId: 'conv-1',
|
||||
cursors: {},
|
||||
pendingBash: [
|
||||
{ id: 'b1', ts: 1, command: 'ls', cwd: '/work' },
|
||||
{ id: 'b2', ts: 1, command: 'ls', cwd: '/' + 'c'.repeat(5000) },
|
||||
{ id: 'p'.repeat(5000), ts: 1, command: 'ls', cwd: '/work' },
|
||||
],
|
||||
agents: { ['a'.repeat(MAX_AGENT_META_CHARS + 1)]: { description: 'd', agentType: 't' } },
|
||||
entries: [
|
||||
{ ...good, id: 'extra', hunks: [], blob: 'x'.repeat(1000) },
|
||||
{ ...good, id: 'objlines', hunks: [{ oldStart: 1, lines: [{ k: 'y' }, '+kept'] }] },
|
||||
{ ...good, id: 'bighunk', hunks: [{ lines: ['+' + 'q'.repeat(MAX_HUNK_BYTES)] }] },
|
||||
{ ...good, id: 'badtool', tool: 'Rm', hunks: [] },
|
||||
],
|
||||
})
|
||||
);
|
||||
const loaded = await EditHistoryStore.load('s1', { dir, debounceMs: 60_000 });
|
||||
expect(
|
||||
loaded
|
||||
.list()
|
||||
.map((e) => e.id)
|
||||
.sort()
|
||||
).toEqual(['bighunk', 'extra', 'objlines']);
|
||||
expect(loaded.get('extra')).not.toHaveProperty('blob');
|
||||
expect(loaded.get('objlines')!.hunks[0].lines).toEqual(['+kept']);
|
||||
expect(loaded.get('bighunk')).toMatchObject({ truncated: true, hunks: [{ lines: [] }] });
|
||||
expect(loaded.getAgents()).toEqual({});
|
||||
await loaded.flush();
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(dir, 's1.json'), 'utf-8')).pendingBash.map((p: { id: string }) => p.id)
|
||||
).toEqual(['b1']);
|
||||
});
|
||||
|
||||
it("counts an entry's id against the byte cap", () => {
|
||||
const e = {
|
||||
id: 'i'.repeat(1000),
|
||||
ts: 1,
|
||||
path: '/a.ts',
|
||||
kind: 'code',
|
||||
tool: 'Edit',
|
||||
created: false,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
hunks: [],
|
||||
truncated: false,
|
||||
} as const;
|
||||
expect(entryBytes(e) - entryBytes({ ...e, id: 'i' })).toBe(999);
|
||||
});
|
||||
|
||||
it(`does not parse a store file larger than ${MAX_FILE_BYTES} bytes`, async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
await store.flush();
|
||||
const text = readFileSync(join(dir, 's1.json'), 'utf-8');
|
||||
expect((await EditHistoryStore.load('s1', { dir })).get('e1')).toBeDefined();
|
||||
writeFileSync(join(dir, 's1.json'), text + ' '.repeat(MAX_FILE_BYTES)); // still valid JSON
|
||||
expect((await EditHistoryStore.load('s1', { dir })).list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('sweep() deletes the histories of sessions that are gone and stale tmp files, nothing else', async () => {
|
||||
for (const name of [
|
||||
'live.json',
|
||||
'gone.json',
|
||||
`gone.json.${process.pid + 1}.tmp`,
|
||||
// An earlier run that had this process's pid (a container's pid 1): stale as well.
|
||||
`live.json.${process.pid}.tmp`,
|
||||
`live.json.${process.pid}-0a1b2c3d.tmp`,
|
||||
`live.json.${TMP_TAG}.tmp`,
|
||||
'notes.txt',
|
||||
]) {
|
||||
writeFileSync(join(dir, name), '{}');
|
||||
}
|
||||
expect(await EditHistoryStore.sweep((id) => id === 'live', { dir })).toBe(1);
|
||||
expect(readdirSync(dir).sort()).toEqual([`live.json.${TMP_TAG}.tmp`, 'live.json', 'notes.txt'].sort());
|
||||
expect(await EditHistoryStore.sweep(() => false, { dir: join(dir, 'missing') })).toBe(0);
|
||||
});
|
||||
|
||||
it('takes only a plain transcript id from the file (the sync builds a path from it)', async () => {
|
||||
const cursors = { main: { offset: 10, floor: 0 } };
|
||||
for (const transcriptId of ['../../etc/passwd', 'a/b', '', 7]) {
|
||||
writeFileSync(join(dir, 's1.json'), JSON.stringify({ version: 1, transcriptId, cursors, entries: [] }));
|
||||
const store = await EditHistoryStore.load('s1', { dir });
|
||||
expect(store.getTranscriptId()).toBeNull();
|
||||
expect(store.knownSources()).toEqual([]); // offsets into no known conversation
|
||||
}
|
||||
writeFileSync(join(dir, 's1.json'), JSON.stringify({ version: 1, transcriptId: 'conv-1.a_b', cursors }));
|
||||
const store = await EditHistoryStore.load('s1', { dir });
|
||||
expect(store.getTranscriptId()).toBe('conv-1.a_b');
|
||||
expect(store.knownSources()).toEqual(['main']);
|
||||
});
|
||||
|
||||
it('settle() writes pending changes and nothing for a store without any', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
await store.settle();
|
||||
expect((await EditHistoryStore.load('s1', { dir })).get('e1')).toBeDefined();
|
||||
const loaded = await EditHistoryStore.load('s1', { dir });
|
||||
rmSync(join(dir, 's1.json'));
|
||||
await loaded.settle();
|
||||
await new EditHistoryStore('empty', { dir }).settle();
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('delete() removes a stored history without parsing it', async () => {
|
||||
writeFileSync(join(dir, 's1.json'), 'not json');
|
||||
await EditHistoryStore.delete('s1', { dir });
|
||||
await EditHistoryStore.delete('missing', { dir });
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a file of an unknown version', async () => {
|
||||
writeFileSync(join(dir, 'v9.json'), JSON.stringify({ version: 9, entries: [{ id: 'x' }] }));
|
||||
expect((await EditHistoryStore.load('v9', { dir })).list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('writes debounced', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 20 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
const path = join(dir, 's1.json');
|
||||
expect(existsSync(path)).toBe(false);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
expect(JSON.parse(readFileSync(path, 'utf-8')).entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('starts empty from a missing or corrupt file', async () => {
|
||||
expect((await EditHistoryStore.load('none', { dir })).list()).toEqual([]);
|
||||
const store = new EditHistoryStore('bad', { dir });
|
||||
await store.flush();
|
||||
writeFileSync(join(dir, 'bad.json'), '{broken');
|
||||
expect((await EditHistoryStore.load('bad', { dir })).list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('remove() deletes the file and stops later writes', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 10 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
await store.flush();
|
||||
await store.remove();
|
||||
expect(existsSync(join(dir, 's1.json'))).toBe(false);
|
||||
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
await store.flush();
|
||||
expect(existsSync(join(dir, 's1.json'))).toBe(false);
|
||||
expect(store.list()).toEqual([]); // the content is forgotten too
|
||||
});
|
||||
|
||||
it('remove() during a write leaves nothing behind (the write cannot rename over the deleted file)', async () => {
|
||||
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
|
||||
syncForward(store, jsonl([editLine('e1', 1)]));
|
||||
const writing = store.flush();
|
||||
// Let the write get past its first check, so remove() meets it under way.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
await Promise.all([writing, store.remove()]);
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults to the data directory', () => {
|
||||
const store = new EditHistoryStore('s1');
|
||||
expect((store as unknown as { file: string }).file).toMatch(/edit-history[\\/]s1\.json$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,699 @@
|
||||
/**
|
||||
* Edit history sync: reader, store, image confirmation, single-flight, failure states and the
|
||||
* "reads only on request" wiring. Reader and probe are fakes; the store is real (temp dir).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { EditHistoryStore } from '../src/edit-history-store.js';
|
||||
import type {
|
||||
EditSourceReadOptions,
|
||||
EditSourceRequest,
|
||||
EditSources,
|
||||
PathProbe,
|
||||
} from '../src/edit-history-local-source.js';
|
||||
import {
|
||||
confirmImages,
|
||||
EditHistorySync,
|
||||
MAX_LOADED_STORES,
|
||||
MAX_OLDER_SOURCES,
|
||||
NO_TRANSCRIPT,
|
||||
REMOVED,
|
||||
type EditHistorySession,
|
||||
type EditHistorySyncDeps,
|
||||
unsupportedReason,
|
||||
} from '../src/web/edit-history-sync.js';
|
||||
|
||||
let dir: string;
|
||||
/** Stores the tests opened; removed before the temp dir, so no late save recreates it. */
|
||||
const opened: EditHistoryStore[] = [];
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'edit-sync-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await Promise.all(opened.splice(0).map((s) => s.remove()));
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
|
||||
|
||||
function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string {
|
||||
return (
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: new Date(ts).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
||||
toolUseResult: {
|
||||
filePath: path,
|
||||
oldString: 'a',
|
||||
newString: 'b',
|
||||
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
|
||||
},
|
||||
...extra,
|
||||
}) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
function bashPair(id: string, start: number, end: number, command: string): string {
|
||||
const use = JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: new Date(start).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
|
||||
});
|
||||
const res = JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: new Date(end).toISOString(),
|
||||
cwd: '/w',
|
||||
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] },
|
||||
toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false },
|
||||
});
|
||||
return use + '\n' + res + '\n';
|
||||
}
|
||||
|
||||
/** An in-memory transcript: files by relPath, served with the reader contract. */
|
||||
class FakeHost {
|
||||
transcriptId = 'conv1';
|
||||
files = new Map<string, Buffer>();
|
||||
metas: Record<string, string> = {};
|
||||
calls: EditSourceRequest[][] = [];
|
||||
paths: string[] = [];
|
||||
/** What `transcriptPath` answers (the transcript watcher's path); null = none yet. */
|
||||
get path(): string | null {
|
||||
return this.files.size > 0 ? `/p/${this.transcriptId}.jsonl` : null;
|
||||
}
|
||||
read = async (
|
||||
mainPath: string,
|
||||
requests: readonly EditSourceRequest[],
|
||||
budget: number,
|
||||
opts: EditSourceReadOptions = {}
|
||||
): Promise<EditSources | null> => {
|
||||
this.calls.push([...requests]);
|
||||
this.paths.push(mainPath);
|
||||
if (this.files.size === 0) return null;
|
||||
let remaining = budget;
|
||||
const out: EditSources = { transcriptId: this.transcriptId, files: [], metas: {} };
|
||||
for (const [relPath, buf] of this.files) {
|
||||
const req = requests.find((r) => r.relPath === relPath);
|
||||
if (!req && opts.onlyRequested) continue;
|
||||
let start: number;
|
||||
let len: number;
|
||||
if (req?.len !== undefined) {
|
||||
const blockEnd = Math.min(req.offset + req.len, buf.length);
|
||||
len = Math.min(blockEnd - Math.min(req.offset, buf.length), remaining);
|
||||
start = blockEnd - len;
|
||||
} else if (req) {
|
||||
start = Math.min(req.offset, buf.length);
|
||||
len = Math.min(remaining, buf.length - start);
|
||||
} else {
|
||||
len = Math.min(buf.length, remaining);
|
||||
start = buf.length - len;
|
||||
const agent = /^subagents\/agent-(.+)\.jsonl$/.exec(relPath)?.[1];
|
||||
if (agent && this.metas[agent]) out.metas[agent] = this.metas[agent];
|
||||
}
|
||||
remaining -= len;
|
||||
out.files.push({ relPath, size: buf.length, startOffset: start, chunk: buf.subarray(start, start + len) });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
append(relPath: string, text: string): void {
|
||||
this.files.set(relPath, Buffer.concat([this.files.get(relPath) ?? Buffer.alloc(0), Buffer.from(text)]));
|
||||
}
|
||||
}
|
||||
|
||||
function setup(over: Partial<EditHistorySyncDeps> = {}) {
|
||||
const host = new FakeHost();
|
||||
const probes = new Map<string, PathProbe>();
|
||||
const lookups: string[] = [];
|
||||
/** Session ids in the order their stores were loaded. */
|
||||
const loads: string[] = [];
|
||||
const deps: EditHistorySyncDeps = {
|
||||
transcriptPath: async (session) => {
|
||||
lookups.push(session.id);
|
||||
return host.path;
|
||||
},
|
||||
readSources: (path, req, budget, opts) => host.read(path, req, budget, opts),
|
||||
probe: async (paths) => paths.map((p) => probes.get(p) ?? null),
|
||||
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
|
||||
deleteStore: (id) => EditHistoryStore.delete(id, { dir }),
|
||||
sweepStores: async () => 0,
|
||||
...over,
|
||||
};
|
||||
const load = deps.loadStore;
|
||||
deps.loadStore = async (id) => {
|
||||
loads.push(id);
|
||||
const store = await load(id);
|
||||
opened.push(store);
|
||||
return store;
|
||||
};
|
||||
return { host, probes, lookups, loads, deps, sync: new EditHistorySync(deps) };
|
||||
}
|
||||
|
||||
const local: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' };
|
||||
const remote: EditHistorySession = {
|
||||
id: 's2',
|
||||
mode: 'claude',
|
||||
remote: { host: 'huff' } as EditHistorySession['remote'],
|
||||
claudeSessionId: 'conv1',
|
||||
};
|
||||
|
||||
describe('unsupportedReason', () => {
|
||||
it('accepts a local claude session, rejects remote, docker and non-claude CLIs', () => {
|
||||
expect(unsupportedReason(local)).toBeUndefined();
|
||||
expect(unsupportedReason(remote)).toBe('remote');
|
||||
expect(unsupportedReason({ ...local, docker: { image: 'x' } })).toBe('docker');
|
||||
expect(unsupportedReason({ ...local, mode: 'shell' })).toBe('mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditHistorySync', () => {
|
||||
it('reads new edits forward from the stored offset', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
expect(await sync.sync(local)).toEqual({ state: 'ok' });
|
||||
host.append('main', editLine('t2', T0 + 1000, '/w/b.ts'));
|
||||
await sync.sync(local);
|
||||
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t2', 't1']);
|
||||
expect(host.calls[1]).toEqual([
|
||||
{ relPath: 'main', offset: host.files.get('main')!.length - editLine('t2', T0 + 1000, '/w/b.ts').length },
|
||||
]);
|
||||
expect(host.paths).toEqual(['/p/conv1.jsonl', '/p/conv1.jsonl']);
|
||||
});
|
||||
|
||||
it('reads subagent transcripts and records their meta', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
host.append('subagents/agent-a1.jsonl', editLine('t9', T0 + 5, '/w/sub.ts', { isSidechain: true, agentId: 'a1' }));
|
||||
host.metas.a1 = '{"description":"Fix it","agentType":"coding"}';
|
||||
await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.get('t9')?.agentId).toBe('a1');
|
||||
expect(store.getAgents().a1).toEqual({ description: 'Fix it', agentType: 'coding' });
|
||||
});
|
||||
|
||||
it('reads a new conversation (/clear, resume) from its end, keeping the entries', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
host.transcriptId = 'conv2';
|
||||
host.files.set('main', Buffer.from(editLine('n1', T0 + 9, '/w/n.ts')));
|
||||
await sync.sync(local);
|
||||
expect(host.paths.at(-1)).toBe('/p/conv2.jsonl');
|
||||
expect(host.calls.at(-1)).toEqual([]); // no offsets of the old file
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.getTranscriptId()).toBe('conv2');
|
||||
expect(store.list().map((e) => e.id)).toEqual(['n1', 't1']);
|
||||
});
|
||||
|
||||
it('keeps reading in passes when a file grew by more than one budget', async () => {
|
||||
const { host, sync } = setup({ budget: 600 });
|
||||
host.append('main', editLine('t0', T0, '/w/z.ts'));
|
||||
await sync.sync(local);
|
||||
for (let i = 1; i <= 3; i++) host.append('main', editLine(`t${i}`, T0 + i, `/w/${i}.ts`));
|
||||
await sync.sync(local);
|
||||
expect((await sync.getStore('s1')).list()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('turns a Bash image into an entry only when its mtime lies in the call window', async () => {
|
||||
const { host, probes, sync } = setup();
|
||||
host.append('main', bashPair('b1', T0, T0 + 5000, 'render out/new.png old.png ~/x.png'));
|
||||
probes.set('/w/out/new.png', { realPath: '/w/out/new.png', kind: 'file', size: 9, mtimeMs: T0 + 4000 });
|
||||
probes.set('/w/old.png', { realPath: '/w/old.png', kind: 'file', size: 9, mtimeMs: T0 - 60_000 });
|
||||
probes.set('~/x.png', { realPath: '/home/u/x.png', kind: 'file', size: 9, mtimeMs: T0 + 6500 });
|
||||
await sync.sync(local);
|
||||
expect((await sync.getStore('s1')).list().map((e) => [e.path, e.tool, e.kind])).toEqual([
|
||||
['/w/out/new.png', 'bash', 'image'],
|
||||
['/home/u/x.png', 'bash', 'image'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('a removal during a read stops it: no further parsing, no probe of printed paths', async () => {
|
||||
const probe = vi.fn(async (paths: readonly string[]) => paths.map(() => null));
|
||||
let removal: Promise<void> | undefined;
|
||||
const ctx = setup({
|
||||
probe,
|
||||
readSources: async (path, req, budget, opts) => {
|
||||
const res = await ctx.host.read(path, req, budget, opts);
|
||||
removal = ctx.sync.remove('s1'); // the session is deleted while this read is out
|
||||
return res;
|
||||
},
|
||||
});
|
||||
ctx.host.append('main', bashPair('b1', T0, T0 + 5000, 'render out/a.png'));
|
||||
ctx.host.append('subagents/agent-a1.jsonl', bashPair('b2', T0, T0 + 5000, 'render out/b.png'));
|
||||
await ctx.sync.sync(local);
|
||||
await removal;
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the edits when only the image probe fails', async () => {
|
||||
const { host, sync } = setup({
|
||||
probe: async () => {
|
||||
throw new Error('probe failed');
|
||||
},
|
||||
});
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts') + bashPair('b1', T0, T0 + 1, 'x out.png'));
|
||||
expect(await sync.sync(local)).toEqual({ state: 'ok' });
|
||||
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
|
||||
});
|
||||
|
||||
it('reports a session without a transcript yet, then reads it once there is one', async () => {
|
||||
const { host, sync } = setup();
|
||||
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: NO_TRANSCRIPT });
|
||||
expect(host.calls).toEqual([]);
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
expect(await sync.sync(local)).toEqual({ state: 'ok' });
|
||||
expect((await sync.getStore('s1')).list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports a transcript that vanished (watcher path stale) the same way', async () => {
|
||||
const { host, sync } = setup({ transcriptPath: async () => '/p/gone.jsonl' });
|
||||
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: NO_TRANSCRIPT });
|
||||
expect(host.paths).toEqual(['/p/gone.jsonl']);
|
||||
});
|
||||
|
||||
it('reports a read error as unavailable and keeps the stored entries', async () => {
|
||||
const { host, sync, deps } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
deps.readSources = async () => {
|
||||
throw new Error('EACCES: permission denied');
|
||||
};
|
||||
const status = await sync.sync(local);
|
||||
expect(status).toEqual({ state: 'unavailable', reason: 'EACCES: permission denied' });
|
||||
expect((await sync.getStore('s1')).list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('marks remote, docker and non-claude sessions unsupported without looking for a transcript', async () => {
|
||||
const { host, lookups, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
expect(await sync.sync({ ...local, mode: 'shell' })).toEqual({ state: 'unsupported', reason: 'mode' });
|
||||
expect(await sync.sync(remote)).toEqual({ state: 'unsupported', reason: 'remote' });
|
||||
expect(await sync.sync({ ...local, docker: {} })).toEqual({ state: 'unsupported', reason: 'docker' });
|
||||
expect(lookups).toEqual([]);
|
||||
expect(host.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('runs one sync per session at a time and folds occasions into one more run', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const orig = host.read;
|
||||
host.read = async (...a) => {
|
||||
await gate;
|
||||
return orig(...a);
|
||||
};
|
||||
const runs = [sync.sync(local), sync.sync(local), sync.sync(local)];
|
||||
release();
|
||||
await Promise.all(runs);
|
||||
expect(host.calls).toHaveLength(2); // the first run + one folded follow-up
|
||||
});
|
||||
|
||||
it('loads older blocks on request', async () => {
|
||||
const { host, sync } = setup({ budget: 400 });
|
||||
for (let i = 0; i < 4; i++) host.append('main', editLine(`t${i}`, T0 + i, `/w/${i}.ts`));
|
||||
await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
const before = store.list().length;
|
||||
expect(store.backfillTruncated).toBe(true);
|
||||
await sync.sync(local, { older: true });
|
||||
expect(store.list().length).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it('flushAll writes pending saves (shutdown) so a reload keeps the entries', async () => {
|
||||
const { host, sync } = setup({ loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 60_000 }) });
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
await sync.flushAll();
|
||||
const reloaded = await EditHistoryStore.load('s1', { dir });
|
||||
expect(reloaded.list().map((e) => e.id)).toEqual(['t1']);
|
||||
});
|
||||
|
||||
it('remove deletes the stored history', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
await (await sync.getStore('s1')).flush();
|
||||
await sync.remove('s1');
|
||||
expect(() => readFileSync(join(dir, 's1.json'))).toThrow();
|
||||
});
|
||||
|
||||
it('neither a run in flight nor a request during the removal writes the deleted history again', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
let entered!: () => void;
|
||||
const reading = new Promise<void>((r) => (entered = r));
|
||||
const orig = host.read;
|
||||
host.read = async (...a) => {
|
||||
entered();
|
||||
await gate;
|
||||
return orig(...a);
|
||||
};
|
||||
const runs = [sync.sync(local), sync.sync(local)]; // one running, one queued
|
||||
await reading;
|
||||
const removing = sync.remove('s1');
|
||||
// The removal waits for the read under way; a request meanwhile reads nothing.
|
||||
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: REMOVED });
|
||||
release();
|
||||
await Promise.all([...runs, removing]);
|
||||
await new Promise((r) => setTimeout(r, 20)); // past the store's debounce
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
expect(host.calls).toHaveLength(1); // the read under way; the queued run never read
|
||||
});
|
||||
|
||||
it('the same conversation under an unnormalized path reads on from its cursors, nothing else', async () => {
|
||||
const path = '/p/./conv1.jsonl';
|
||||
const { host, sync } = setup({ transcriptPath: async () => path });
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
host.append('main', editLine('t2', T0 + 1, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
expect(host.paths).toEqual([path, path]);
|
||||
expect(host.calls[1]).toEqual([
|
||||
{ relPath: 'main', offset: host.files.get('main')!.length - editLine('t2', T0 + 1, '/w/a.ts').length },
|
||||
]);
|
||||
});
|
||||
|
||||
it('a session that comes back with the same id (reboot restore) reads its history afresh', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(local);
|
||||
// Boot: the unpinned record is pruned as stale; then "restore" brings the session back.
|
||||
await sync.remove('s1');
|
||||
expect(await sync.sync(local)).toEqual({ state: 'ok' });
|
||||
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
|
||||
});
|
||||
|
||||
it('remove deletes a history it has not loaded without loading it', async () => {
|
||||
const { sync, loads } = setup();
|
||||
const other = new EditHistoryStore('s1', { dir });
|
||||
other.addImageEntries([{ toolUseId: 'b1', path: '/w/a.png', ts: T0 }]);
|
||||
await other.flush();
|
||||
await sync.remove('s1');
|
||||
expect(loads).toEqual([]);
|
||||
expect(existsSync(join(dir, 's1.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it(`keeps at most ${MAX_LOADED_STORES} stores loaded: a dropped one is written and loads again whole`, async () => {
|
||||
const { host, sync, loads } = setup({ loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 60_000 }) });
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
const ids = Array.from({ length: MAX_LOADED_STORES + 2 }, (_, i) => `s${i}`);
|
||||
for (const id of ids) await sync.sync({ ...local, id });
|
||||
expect(loads).toEqual(ids);
|
||||
// The two least recently used went: written (no debounced save ran), not kept in memory.
|
||||
await sync.flushAll();
|
||||
expect(existsSync(join(dir, 's0.json'))).toBe(true);
|
||||
expect((await sync.getStore('s0')).list().map((e) => e.id)).toEqual(['t1']);
|
||||
expect(loads.slice(ids.length)).toEqual(['s0']);
|
||||
// The most recently used ones stay loaded.
|
||||
await sync.getStore(ids[ids.length - 1]);
|
||||
expect(loads.slice(ids.length)).toEqual(['s0']);
|
||||
});
|
||||
|
||||
it('a dropped store that never changed writes nothing (no file for a session without edits)', async () => {
|
||||
const { sync } = setup(); // no transcript: nothing to read
|
||||
for (let i = 0; i <= MAX_LOADED_STORES; i++) await sync.sync({ ...local, id: `s${i}` });
|
||||
await sync.flushAll();
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('a store whose run is in flight is not dropped, and loads once', async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const { host, sync, loads } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
const orig = host.read;
|
||||
let first = true;
|
||||
let inRead!: () => void;
|
||||
const reachedRead = new Promise<void>((r) => (inRead = r));
|
||||
host.read = async (...a) => {
|
||||
if (first) {
|
||||
first = false;
|
||||
inRead();
|
||||
await gate;
|
||||
}
|
||||
return orig(...a);
|
||||
};
|
||||
const slow = sync.sync(local);
|
||||
// Only once s1 holds the gate: the sessions load their stores independently, so a later one
|
||||
// could reach the read first, take the gate and hang the loop below before `release()`.
|
||||
await reachedRead;
|
||||
for (let i = 1; i <= MAX_LOADED_STORES + 1; i++) await sync.sync({ ...local, id: `s1-${i}` });
|
||||
release();
|
||||
expect(await slow).toEqual({ state: 'ok' });
|
||||
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
|
||||
expect(loads.filter((id) => id === 's1')).toEqual(['s1']);
|
||||
});
|
||||
|
||||
it('concurrent first callers share one store', async () => {
|
||||
const { sync } = setup();
|
||||
const [a, b] = await Promise.all([sync.getStore('s1'), sync.getStore('s1')]);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('a forward request queued together with an older one still reads forward', async () => {
|
||||
const { host, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const orig = host.read;
|
||||
host.read = async (...a) => {
|
||||
const res = await orig(...a); // the first run's bytes are taken before the append
|
||||
await gate;
|
||||
return res;
|
||||
};
|
||||
const first = sync.sync(local);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
host.append('main', editLine('t2', T0 + 1, '/w/b.ts'));
|
||||
const queued = [sync.sync(local), sync.sync(local, { older: true })];
|
||||
release();
|
||||
await Promise.all([first, ...queued]);
|
||||
expect((await sync.getStore('s1')).get('t2')).toBeDefined();
|
||||
});
|
||||
|
||||
it('load older moves every source back, reading only the blocks it asked for', async () => {
|
||||
const { host, sync } = setup({ budget: 2000 });
|
||||
for (let i = 0; i < 10; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`));
|
||||
for (let i = 0; i < 10; i++) {
|
||||
host.append('subagents/agent-ab.jsonl', editLine(`s${i}`, T0 + 100 + i, `/w/s${i}.ts`, { agentId: 'ab' }));
|
||||
}
|
||||
await sync.sync(local);
|
||||
await sync.sync(local); // the subagent gets its first block once main is up to date
|
||||
const store = await sync.getStore('s1');
|
||||
const floors = () => [store.getCursor('main')!.floor, store.getCursor('subagents/agent-ab.jsonl')!.floor];
|
||||
const [main0, sub0] = floors();
|
||||
expect(main0).toBeGreaterThan(0);
|
||||
expect(sub0).toBeGreaterThan(0);
|
||||
await sync.sync(local, { older: true });
|
||||
const [main1, sub1] = floors();
|
||||
expect(main1).toBeLessThan(main0);
|
||||
expect(sub1).toBeLessThan(sub0);
|
||||
expect(
|
||||
host.calls
|
||||
.at(-1)!
|
||||
.map((r) => r.relPath)
|
||||
.sort()
|
||||
).toEqual(['main', 'subagents/agent-ab.jsonl']);
|
||||
for (let i = 0; i < 20 && store.backfillTruncated; i++) await sync.sync(local, { older: true });
|
||||
expect(store.list()).toHaveLength(20);
|
||||
});
|
||||
|
||||
it('a file the shared budget cut short is read in the next pass, not skipped as one long line', async () => {
|
||||
const budget = 4000;
|
||||
const { host, sync } = setup({ budget });
|
||||
const sub = 'subagents/agent-a1.jsonl';
|
||||
host.append('main', editLine('m0', T0, '/w/m0.ts'));
|
||||
host.append(sub, editLine('s0', T0 + 1, '/w/s0.ts', { agentId: 'a1' }));
|
||||
await sync.sync(local);
|
||||
// main grows by almost a whole budget, leaving the subagent 50 bytes of this pass
|
||||
const filler = JSON.stringify({ type: 'user', message: { content: '' } });
|
||||
host.append(
|
||||
'main',
|
||||
JSON.stringify({ type: 'user', message: { content: 'z'.repeat(budget - 51 - filler.length) } }) + '\n'
|
||||
);
|
||||
host.append(sub, editLine('s1', T0 + 2, '/w/s1.ts', { agentId: 'a1', pad: 'p'.repeat(100) }));
|
||||
host.append(sub, editLine('s2', T0 + 3, '/w/s2.ts', { agentId: 'a1' }));
|
||||
await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
expect(
|
||||
store
|
||||
.list()
|
||||
.map((e) => e.id)
|
||||
.sort()
|
||||
).toEqual(['m0', 's0', 's1', 's2']);
|
||||
expect(store.getCursor(sub)?.midLine).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a first read reaches the subagents in a further pass when main fills the budget', async () => {
|
||||
const { host, sync } = setup({ budget: 3000 });
|
||||
for (let i = 0; i < 20; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`));
|
||||
host.append('subagents/agent-a1.jsonl', editLine('s0', T0 + 100, '/w/s0.ts', { agentId: 'a1' }));
|
||||
await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.get('s0')?.agentId).toBe('a1');
|
||||
expect(store.getCursor('subagents/agent-a1.jsonl')).toBeDefined();
|
||||
});
|
||||
|
||||
it('load older reads a full budget per source: a line longer than the budget split over the sources is kept', async () => {
|
||||
const budget = 2000;
|
||||
const { host, sync } = setup({ budget });
|
||||
host.append('main', editLine('m0', T0, '/w/m0.ts'));
|
||||
host.append('main', editLine('big', T0 + 1, '/w/big.ts', { pad: 'p'.repeat(800) })); // > budget / 3
|
||||
for (let i = 2; i < 12; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`));
|
||||
for (const a of ['a1', 'a2']) {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
host.append(
|
||||
`subagents/agent-${a}.jsonl`,
|
||||
editLine(`${a}-${i}`, T0 + 100 + i, `/w/${a}${i}.ts`, { agentId: a })
|
||||
);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 3; i++) await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.sourcesWithOlder()).toHaveLength(3);
|
||||
for (let i = 0; i < 20 && store.backfillTruncated; i++) await sync.sync(local, { older: true });
|
||||
expect(store.get('big')).toBeDefined();
|
||||
expect(store.list()).toHaveLength(36);
|
||||
});
|
||||
|
||||
it(`load older reads at most ${MAX_OLDER_SOURCES} sources per click, taking turns`, async () => {
|
||||
const { host, sync } = setup({ budget: 600 });
|
||||
const subs = ['a1', 'a2', 'a3', 'a4'].map((a) => `subagents/agent-${a}.jsonl`);
|
||||
for (const relPath of ['main', ...subs]) {
|
||||
for (let i = 0; i < 8; i++) host.append(relPath, editLine(`${relPath}-${i}`, T0 + i, `/w/${i}.ts`));
|
||||
}
|
||||
for (let i = 0; i < 3; i++) await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.sourcesWithOlder()).toHaveLength(5);
|
||||
const olderCall = () => host.calls.at(-1)!.map((r) => r.relPath);
|
||||
await sync.sync(local, { older: true });
|
||||
expect(olderCall()).toEqual(['main', subs[0], subs[1]]);
|
||||
await sync.sync(local, { older: true });
|
||||
expect(olderCall()).toEqual([subs[2], subs[3], 'main']);
|
||||
for (let i = 0; i < 30 && store.backfillTruncated; i++) await sync.sync(local, { older: true });
|
||||
expect(store.list()).toHaveLength(40);
|
||||
});
|
||||
|
||||
it('a subagent transcript that vanished loses its cursor (no endless "Load older"), not its entries', async () => {
|
||||
const { host, sync } = setup({ budget: 600 });
|
||||
const sub = 'subagents/agent-a1.jsonl';
|
||||
host.append('main', editLine('m0', T0, '/w/m0.ts'));
|
||||
for (let i = 0; i < 8; i++) host.append(sub, editLine(`s${i}`, T0 + 10 + i, `/w/s${i}.ts`, { agentId: 'a1' }));
|
||||
for (let i = 0; i < 2; i++) await sync.sync(local);
|
||||
const store = await sync.getStore('s1');
|
||||
expect(store.backfillTruncated).toBe(true);
|
||||
const kept = store.list().length;
|
||||
host.files.delete(sub);
|
||||
await sync.sync(local, { older: true });
|
||||
expect(store.getCursor(sub)).toBeUndefined();
|
||||
expect(store.backfillTruncated).toBe(false);
|
||||
expect(store.list()).toHaveLength(kept);
|
||||
});
|
||||
|
||||
it('sweep keeps the sessions in use here and those that exist', async () => {
|
||||
const asked: Record<string, boolean> = {};
|
||||
const { sync } = setup({
|
||||
sweepStores: async (keep) => {
|
||||
for (const id of ['s1', 'kept', 'gone']) asked[id] = keep(id);
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
await sync.getStore('s1');
|
||||
expect(await sync.sweep((id) => id === 'kept')).toBe(1);
|
||||
expect(asked).toEqual({ s1: true, kept: true, gone: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmImages', () => {
|
||||
it('ignores directories and missing files', () => {
|
||||
const c = [{ toolUseId: 'b', start: T0, end: T0 + 1, paths: ['/d.png', '/m.png'] }];
|
||||
const out = confirmImages(c, (p) =>
|
||||
p === '/d.png' ? { realPath: p, kind: 'directory', size: 0, mtimeMs: T0 } : null
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists a file sent to the user outside any window, once per file version', () => {
|
||||
const png = { realPath: '/w/p.png', kind: 'file' as const, size: 9, mtimeMs: T0 };
|
||||
const sent = { toolUseId: 's', start: T0 + 9000, end: T0 + 9000, paths: ['/w/p.png'], sent: true };
|
||||
expect(confirmImages([sent], () => png)).toEqual([
|
||||
{ toolUseId: 's', path: '/w/p.png', ts: T0 + 9000, tool: 'sent' },
|
||||
]);
|
||||
// The Bash call that wrote it was already listed: same version, not again.
|
||||
expect(
|
||||
confirmImages(
|
||||
[sent],
|
||||
() => png,
|
||||
() => T0 + 500
|
||||
)
|
||||
).toEqual([]);
|
||||
expect(confirmImages([sent], () => null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a file version already recorded: the ls right after the render, a listing after a Write', () => {
|
||||
const png = { realPath: '/w/p.png', kind: 'file' as const, size: 9, mtimeMs: T0 + 900 };
|
||||
const render = { toolUseId: 'r', start: T0, end: T0 + 1000, paths: ['/w/p.png'] };
|
||||
const ls = { toolUseId: 'l', start: T0 + 1500, end: T0 + 1600, paths: ['/w/p.png'] };
|
||||
expect(confirmImages([ls, render], () => png).map((i) => i.toolUseId)).toEqual(['r']);
|
||||
// A Write entry for the same path at T0 + 950 already covers it.
|
||||
expect(
|
||||
confirmImages(
|
||||
[ls],
|
||||
() => png,
|
||||
() => T0 + 950
|
||||
)
|
||||
).toEqual([]);
|
||||
// A later re-render (newer mtime, outside the slack) is a new version.
|
||||
const again = { toolUseId: 'r2', start: T0 + 10_000, end: T0 + 11_000, paths: ['/w/p.png'] };
|
||||
const newer = { ...png, mtimeMs: T0 + 10_500 };
|
||||
expect(
|
||||
confirmImages(
|
||||
[again],
|
||||
() => newer,
|
||||
() => T0 + 1000
|
||||
).map((i) => i.toolUseId)
|
||||
).toEqual(['r2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reads only on request (wiring guard)', () => {
|
||||
const read = (file: string) => readFileSync(join(__dirname, '..', file), 'utf-8');
|
||||
|
||||
it('the server never starts a sync itself: it only hands the reader to the routes', () => {
|
||||
const server = read('src/web/server.ts');
|
||||
const uses = [...server.matchAll(/this\.editHistorySync\b(\.\w+)?/g)].map((m) => m[1] ?? '');
|
||||
expect(uses.sort()).toEqual(['', '.flushAll', '.remove', '.sweep']);
|
||||
expect(server).toMatch(/registerEditHistoryRoutes\(this\.app, ctx, this\.editHistorySync\)/);
|
||||
});
|
||||
|
||||
it('no other module holds the reader, and the edit history modules run no timer or watcher', () => {
|
||||
const root = join(__dirname, '..');
|
||||
const holders = readdirSync(join(root, 'src'), { recursive: true, encoding: 'utf-8' })
|
||||
.filter((f) => /\.(ts|js)$/.test(f))
|
||||
.map((f) => join('src', f).split('\\').join('/'))
|
||||
.filter((f) => /EditHistorySync|editHistorySync/.test(read(f)));
|
||||
expect(holders.sort()).toEqual([
|
||||
'src/web/edit-history-sync.ts',
|
||||
'src/web/routes/edit-history-routes.ts',
|
||||
'src/web/server.ts',
|
||||
]);
|
||||
for (const file of [
|
||||
'src/web/edit-history-sync.ts',
|
||||
'src/edit-history-store.ts',
|
||||
'src/edit-history-local-source.ts',
|
||||
'src/edit-history-parse.ts',
|
||||
'src/web/routes/edit-history-routes.ts',
|
||||
]) {
|
||||
const src = read(file);
|
||||
expect(src, file).not.toMatch(/setInterval|\bwatch\(|chokidar/);
|
||||
}
|
||||
// The store's only timer is its debounced save.
|
||||
expect(read('src/web/edit-history-sync.ts')).not.toMatch(/setTimeout/);
|
||||
});
|
||||
});
|
||||
|
||||
vi.setConfig({ testTimeout: 10_000 });
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* @fileoverview Edit history button and panel, end to end in a real browser: real server, real
|
||||
* Chromium. The edits API is mocked with `page.route` (the UI is built against its contract); the
|
||||
* thumbnails (workspace `file-raw`, the attachment route outside it) and the transcript SSE
|
||||
* events the open panel follows go through the real server.
|
||||
*/
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page, type Route } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
/** A 2×2 red PNG, so the thumbnail has real pixels to decode. */
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGP4z8AARAwQCgAf7gP9i18U1AAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
ts: number;
|
||||
path: string;
|
||||
relPath: string;
|
||||
kind: 'code' | 'image';
|
||||
tool: string;
|
||||
created: boolean;
|
||||
added: number;
|
||||
removed: number;
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
describe('Edit history panel in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
let root: string;
|
||||
/** A directory outside the session's workspace, for the attachment-route thumbnail. */
|
||||
let ext: string;
|
||||
let sessionA: string;
|
||||
let sessionB: string;
|
||||
const settingsPutStatuses: number[] = [];
|
||||
/** Bodies of POST /api/sessions/:id/attachments (the file preview registering an external image). */
|
||||
const attachmentPosts: unknown[] = [];
|
||||
/** Every request to the mocked API: `METHOD url`. */
|
||||
const calls: string[] = [];
|
||||
/** The mocked server state per session id. */
|
||||
const state = new Map<string, Record<string, unknown>>();
|
||||
let diffs: Record<string, { diff: string; truncated: boolean }> = {};
|
||||
let olderEntries: Entry[] = [];
|
||||
|
||||
const entry = (over: Partial<Entry> & { id: string; relPath: string }): Entry => ({
|
||||
ts: Date.now(),
|
||||
path: join(root, over.relPath),
|
||||
kind: 'code',
|
||||
tool: 'Edit',
|
||||
created: false,
|
||||
added: 1,
|
||||
removed: 1,
|
||||
...over,
|
||||
});
|
||||
|
||||
const json = (route: Route, data: unknown) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data }) });
|
||||
|
||||
const buttonVisible = () =>
|
||||
page.evaluate(() => getComputedStyle(document.getElementById('editHistoryBtn')!).display !== 'none');
|
||||
const bodyText = async () => (await page.textContent('#editHistoryBody')) ?? '';
|
||||
const setSetting = async (on: boolean) => {
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
if ((await page.isChecked('#appSettingsShowEditHistory')) !== on)
|
||||
await page.click('label.set-chip:has(#appSettingsShowEditHistory)');
|
||||
await page.evaluate(() => (window as any).app.saveAppSettings());
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => (window as any).app.closeAppSettings());
|
||||
};
|
||||
const createSession = (dir: string) =>
|
||||
page.evaluate(async (workingDir) => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, mode: 'shell' }),
|
||||
});
|
||||
const id = (await res.json()).data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id as string;
|
||||
}, dir);
|
||||
const select = (id: string) =>
|
||||
page.evaluate(async (sid) => {
|
||||
const app = (window as any).app;
|
||||
for (let i = 0; i < 100 && !app.sessions.has(sid); i++) await new Promise((r) => setTimeout(r, 100));
|
||||
await app.selectSession(sid);
|
||||
}, id);
|
||||
const reload = () => page.evaluate(() => (window as any).app.refreshEditHistory());
|
||||
const syncReads = (sid: string) => calls.filter((c) => c === `GET /api/sessions/${sid}/edits?sync=1`).length;
|
||||
const toolEnd = (sessionId: string, toolName: string) =>
|
||||
(server as any).broadcast('transcript:tool_end', { sessionId, toolName, isError: false, timestamp: Date.now() });
|
||||
const rows = () => page.locator('#editHistoryBody .edit-history-entry');
|
||||
|
||||
beforeAll(async () => {
|
||||
root = mkdtempSync(join(homedir(), 'edit-history-ui-'));
|
||||
writeFileSync(join(root, 'shot.png'), PNG);
|
||||
ext = mkdtempSync(join(homedir(), 'edit-history-ext-'));
|
||||
writeFileSync(join(ext, 'render.png'), PNG);
|
||||
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
page.on('response', (r) => {
|
||||
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
|
||||
});
|
||||
page.on('request', (r) => {
|
||||
if (r.method() === 'POST' && /\/api\/sessions\/[^/]+\/attachments$/.test(r.url()))
|
||||
attachmentPosts.push(r.postDataJSON());
|
||||
});
|
||||
await page.route('**/api/sessions/*/edits**', async (route) => {
|
||||
const req = route.request();
|
||||
const url = new URL(req.url());
|
||||
calls.push(`${req.method()} ${url.pathname}${url.search}`);
|
||||
const m = url.pathname.match(/\/api\/sessions\/([^/]+)\/edits(?:\/(.+))?$/)!;
|
||||
const sid = decodeURIComponent(m[1]);
|
||||
const rest = m[2] ? decodeURIComponent(m[2]) : '';
|
||||
const s = state.get(sid) ?? { state: 'unsupported', backfillTruncated: false, agents: {}, entries: [] };
|
||||
if (req.method() === 'POST' && rest === 'older') {
|
||||
const entries = [...(s.entries as Entry[]), ...olderEntries];
|
||||
state.set(sid, { ...s, entries, backfillTruncated: false });
|
||||
return json(route, state.get(sid));
|
||||
}
|
||||
if (req.method() === 'GET' && rest) {
|
||||
const d = diffs[rest];
|
||||
if (!d) return route.fulfill({ status: 404, contentType: 'application/json', body: '{"success":false}' });
|
||||
return json(route, d);
|
||||
}
|
||||
return json(route, s);
|
||||
});
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
sessionA = await createSession(root);
|
||||
sessionB = await createSession(root);
|
||||
await select(sessionA);
|
||||
|
||||
const t0 = Date.now();
|
||||
state.set(sessionA, {
|
||||
state: 'ok',
|
||||
backfillTruncated: true,
|
||||
agents: {
|
||||
agent1: { description: 'Refactor the parser', agentType: 'general-purpose', worktreeBranch: 'wt/parser' },
|
||||
},
|
||||
entries: [
|
||||
entry({ id: 'e4', ts: t0, relPath: 'src/a.ts', added: 3, removed: 1 }),
|
||||
entry({ id: 'e3', ts: t0 - 1000, relPath: 'shot.png', kind: 'image', tool: 'bash', created: true }),
|
||||
entry({
|
||||
id: 'e2',
|
||||
ts: t0 - 2000,
|
||||
relPath: 'src/b.ts',
|
||||
created: true,
|
||||
tool: 'Write',
|
||||
added: 10,
|
||||
removed: 0,
|
||||
agentId: 'agent1',
|
||||
}),
|
||||
entry({ id: 'e1', ts: t0 - 3000, relPath: 'src/a.ts', added: 2, removed: 2 }),
|
||||
],
|
||||
});
|
||||
diffs = {
|
||||
e4: { diff: '@@ -1,3 +1,3 @@\n context\n-old line\n+new line\n+another\n', truncated: true },
|
||||
e1: { diff: '@@ -5 +5 @@\n-x\n+y\n', truncated: false },
|
||||
};
|
||||
olderEntries = [entry({ id: 'e0', ts: t0 - 86_400_000, relPath: 'src/old.ts', added: 7, removed: 0 })];
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
rmSync(ext, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
it('is off by default: no button, and nothing asks the edits API', async () => {
|
||||
await page.waitForTimeout(1000);
|
||||
expect(await buttonVisible()).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('turning it on through Settings shows the button and saves cleanly (the key never reaches the strict PUT)', async () => {
|
||||
await setSetting(true);
|
||||
expect(settingsPutStatuses.length).toBeGreaterThan(0);
|
||||
expect(settingsPutStatuses.every((s) => s === 200)).toBe(true);
|
||||
expect(await buttonVisible()).toBe(true);
|
||||
// In the header next to Attachments, where the panel opens; no longer in the bottom bar.
|
||||
expect(await page.evaluate(() => !!document.querySelector('#headerRight #editHistoryBtn'))).toBe(true);
|
||||
expect(await page.evaluate(() => !!document.querySelector('.toolbar #editHistoryBtn'))).toBe(false);
|
||||
expect(calls).toEqual([]); // no poll: nothing is read until the panel opens
|
||||
});
|
||||
|
||||
it('opens a panel listing the entries newest first, with the subagent chip and an image thumbnail', async () => {
|
||||
await page.click('#editHistoryBtn');
|
||||
await page.waitForSelector('#editHistoryPanel.visible');
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-entry');
|
||||
expect(calls[0]).toBe(`GET /api/sessions/${sessionA}/edits?sync=1`);
|
||||
expect(await page.getAttribute('#editHistoryBtn', 'aria-expanded')).toBe('true');
|
||||
expect(await rows().count()).toBe(4);
|
||||
const paths = await page.locator('#editHistoryBody .edit-history-path').allTextContents();
|
||||
expect(paths).toEqual(['src/a.ts', 'shot.png', 'src/b.ts', 'src/a.ts']);
|
||||
expect(await rows().nth(0).locator('.edit-history-badge').textContent()).toBe('edit');
|
||||
expect(await rows().nth(2).locator('.edit-history-badge').textContent()).toBe('new');
|
||||
expect(await rows().nth(0).locator('.edit-history-stats').textContent()).toBe('+3−1');
|
||||
// The image row: a symbol instead of a word, no +/− counts.
|
||||
expect(await rows().nth(1).locator('.edit-history-badge--image svg').count()).toBe(1);
|
||||
expect(await rows().nth(1).locator('.edit-history-stats').count()).toBe(0);
|
||||
// The subagent chip: its description, and type + branch on hover.
|
||||
const chip = rows().nth(2).locator('.edit-history-agent');
|
||||
expect(await chip.textContent()).toBe('Refactor the parser');
|
||||
expect(await chip.getAttribute('title')).toBe('general-purpose · wt/parser');
|
||||
expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(1);
|
||||
// The thumbnail is served by the real file-raw route and decodes.
|
||||
const thumb = page.locator('#editHistoryBody img.edit-history-thumb');
|
||||
expect(await thumb.count()).toBe(1);
|
||||
expect(await thumb.getAttribute('src')).toBe(
|
||||
`/api/sessions/${sessionA}/file-raw?path=${encodeURIComponent(join(root, 'shot.png'))}`
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => (document.querySelector('img.edit-history-thumb') as HTMLImageElement)?.naturalWidth === 2
|
||||
);
|
||||
expect(await page.textContent('#editHistoryCount')).toBe('4');
|
||||
});
|
||||
|
||||
it('a code entry opens its diff inline with the Git panel line classes, and closes again', async () => {
|
||||
await rows().nth(0).locator('.edit-history-spacer').click();
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-diff pre.git-diff');
|
||||
expect(calls).toContain(`GET /api/sessions/${sessionA}/edits/e4`);
|
||||
const lines = await page.$$eval('#editHistoryBody pre.git-diff .git-diff-line', (els) =>
|
||||
els.map((e) => [e.className.replace('git-diff-line', '').trim(), e.textContent])
|
||||
);
|
||||
expect(lines).toEqual([
|
||||
['git-diff-line--hunk', '@@ -1,3 +1,3 @@\n'],
|
||||
['', ' context\n'],
|
||||
['git-diff-line--del', '-old line\n'],
|
||||
['git-diff-line--add', '+new line\n'],
|
||||
['git-diff-line--add', '+another\n'],
|
||||
]);
|
||||
expect(await page.textContent('#editHistoryBody .edit-history-diff')).toContain('Diff cut short');
|
||||
expect(await rows().nth(0).getAttribute('aria-expanded')).toBe('true');
|
||||
// A re-read (what an SSE update does) keeps the open diff without fetching it again.
|
||||
const before = calls.filter((c) => c.endsWith('/edits/e4')).length;
|
||||
await reload();
|
||||
expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(1);
|
||||
expect(calls.filter((c) => c.endsWith('/edits/e4')).length).toBe(before);
|
||||
await rows().nth(0).locator('.edit-history-spacer').click();
|
||||
expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(0);
|
||||
});
|
||||
|
||||
it('a path and an image open the shared file preview with the absolute path', async () => {
|
||||
await page.evaluate(() => {
|
||||
const w = window as any;
|
||||
w.__previewed = [];
|
||||
w.__openFilePreview = w.app.openFilePreview;
|
||||
w.app.openFilePreview = (p: string, sid: string) => w.__previewed.push([p, sid]);
|
||||
});
|
||||
await rows().nth(2).locator('.edit-history-path').click();
|
||||
await page.locator('#editHistoryBody img.edit-history-thumb').click();
|
||||
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([
|
||||
[join(root, 'src/b.ts'), sessionA],
|
||||
[join(root, 'shot.png'), sessionA],
|
||||
]);
|
||||
// Clicking the path never toggles the row's diff.
|
||||
expect(await page.locator('#editHistoryBody .edit-history-diff').count()).toBe(0);
|
||||
await page.evaluate(() => {
|
||||
const w = window as any;
|
||||
w.app.openFilePreview = w.__openFilePreview;
|
||||
});
|
||||
});
|
||||
|
||||
it('"Main agent only" hides the subagent edits; "Group by file" puts edits under their file', async () => {
|
||||
await page.check('#editHistoryMainOnlyToggle');
|
||||
expect(await rows().count()).toBe(3);
|
||||
expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(0);
|
||||
expect(await bodyText()).not.toContain('src/b.ts');
|
||||
await page.uncheck('#editHistoryMainOnlyToggle');
|
||||
expect(await rows().count()).toBe(4);
|
||||
|
||||
await page.check('#editHistoryGroupToggle');
|
||||
const groups = page.locator('#editHistoryBody .edit-history-group');
|
||||
expect(await groups.count()).toBe(3);
|
||||
expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-path').textContent()).toBe('src/a.ts');
|
||||
expect(await groups.nth(0).locator('.edit-history-entry').count()).toBe(2);
|
||||
expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-stats').textContent()).toBe('+5−3');
|
||||
await groups.nth(0).locator('.edit-history-entry').nth(1).locator('.edit-history-spacer').click();
|
||||
await page.waitForSelector('#editHistoryBody .git-diff-line--add');
|
||||
expect(await page.textContent('#editHistoryBody .git-diff-line--add')).toBe('+y\n');
|
||||
await page.uncheck('#editHistoryGroupToggle');
|
||||
});
|
||||
|
||||
it('"Load older" asks the server with a POST and shows what it returns', async () => {
|
||||
const older = page.locator('#editHistoryBody button.edit-history-older');
|
||||
expect(await older.count()).toBe(1);
|
||||
await older.click();
|
||||
await page.waitForFunction(() =>
|
||||
/src\/old\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
|
||||
);
|
||||
expect(calls).toContain(`POST /api/sessions/${sessionA}/edits/older`);
|
||||
expect(await rows().count()).toBe(5);
|
||||
expect(await page.locator('#editHistoryBody button.edit-history-older').count()).toBe(0);
|
||||
});
|
||||
|
||||
it('while open it follows the session: an edit-capable tool end or a turn end re-reads (debounced), other tools, sessions and keep-alive turns do not', async () => {
|
||||
const before = syncReads(sessionA);
|
||||
const s = state.get(sessionA)!;
|
||||
state.set(sessionA, {
|
||||
...s,
|
||||
entries: [entry({ id: 'e5', relPath: 'src/fresh.ts', added: 4, removed: 0 }), ...(s.entries as Entry[])],
|
||||
});
|
||||
toolEnd(sessionA, 'Read');
|
||||
toolEnd(sessionA, 'Grep');
|
||||
toolEnd('someone-else', 'Edit');
|
||||
await page.waitForTimeout(1000);
|
||||
expect(syncReads(sessionA)).toBe(before);
|
||||
toolEnd(sessionA, 'Edit');
|
||||
toolEnd(sessionA, 'Bash');
|
||||
await page.waitForFunction(() =>
|
||||
/src\/fresh\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
|
||||
);
|
||||
await page.waitForTimeout(800);
|
||||
expect(syncReads(sessionA)).toBe(before + 1);
|
||||
// The end of a turn re-reads too (a parallel tool call reports one tool end only, a background
|
||||
// subagent writes later): `session:idle`, since claude's transcript has no `result` line.
|
||||
// Not another session's, and not the end of Codeman's own keep-alive turn (it edits nothing).
|
||||
(server as any).broadcast('session:idle', { id: 'someone-else' });
|
||||
(server as any).broadcast('session:idle', { id: sessionA, keepAlive: true });
|
||||
await page.waitForTimeout(1200);
|
||||
expect(syncReads(sessionA)).toBe(before + 1);
|
||||
(server as any).broadcast('session:idle', { id: sessionA });
|
||||
await page.waitForTimeout(1200);
|
||||
expect(syncReads(sessionA)).toBe(before + 2);
|
||||
});
|
||||
|
||||
it('while the panel is closed, transcript events read nothing; opening reads once', async () => {
|
||||
await page.click('#editHistoryBtn');
|
||||
await page.waitForSelector('#editHistoryPanel:not(.visible)', { state: 'attached' });
|
||||
const before = syncReads(sessionA);
|
||||
toolEnd(sessionA, 'Edit');
|
||||
(server as any).broadcast('session:idle', { id: sessionA });
|
||||
await page.waitForTimeout(1200);
|
||||
expect(syncReads(sessionA)).toBe(before);
|
||||
expect(await page.$('#editHistoryUnread')).toBeNull(); // no unread badge: closed means idle
|
||||
await page.click('#editHistoryBtn');
|
||||
await page.waitForSelector('#editHistoryPanel.visible');
|
||||
await page.waitForFunction(() => document.querySelectorAll('#editHistoryBody .edit-history-entry').length > 0);
|
||||
expect(syncReads(sessionA)).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('an image outside the workspace registers nothing until clicked, then previews through the attachment route quietly', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
const outside = join(ext, 'render.png');
|
||||
state.set(sessionA, {
|
||||
...s,
|
||||
entries: [
|
||||
entry({ id: 'ext', path: outside, relPath: outside, kind: 'image', tool: 'bash', added: 0, removed: 0 }),
|
||||
...(s.entries as Entry[]),
|
||||
],
|
||||
});
|
||||
await reload();
|
||||
const row = page.locator('#editHistoryBody .edit-history-entry', { hasText: 'render.png' });
|
||||
await row.waitFor();
|
||||
// No thumbnail for it (that would mint an attachment id per render), and no registration.
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
[...document.querySelectorAll('#editHistoryBody img.edit-history-thumb')].map((i) => i.getAttribute('src'))
|
||||
)
|
||||
).not.toContainEqual(expect.stringContaining('render.png'));
|
||||
await reload();
|
||||
expect(attachmentPosts).toEqual([]);
|
||||
// A click opens the shared preview, which registers the file once, without a notification.
|
||||
await row.click();
|
||||
await page.waitForSelector('#filePreviewOverlay.visible');
|
||||
expect(attachmentPosts).toEqual([{ path: outside, notify: false }]);
|
||||
await page.evaluate(() => (window as any).app.closeFilePreview());
|
||||
state.set(sessionA, s);
|
||||
});
|
||||
|
||||
it('a long path outside the workspace keeps its file name readable, the folder is cut at the start', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
const long =
|
||||
'/tmp/claude-1000/-mnt-caches-ComfyUI/07e259af-d4d2-4ac3-8a7e-95972e3547df/scratchpad/icons/bremse_alt1_pedal.png';
|
||||
state.set(sessionA, {
|
||||
...s,
|
||||
entries: [
|
||||
entry({ id: 'long', relPath: long, kind: 'image', tool: 'sent', added: 0, removed: 0 }),
|
||||
...(s.entries as Entry[]),
|
||||
],
|
||||
});
|
||||
await reload();
|
||||
const row = rows().nth(0);
|
||||
await row.waitFor();
|
||||
expect(await row.locator('.edit-history-path').textContent()).toBe(long);
|
||||
const name = row.locator('.edit-history-path-name');
|
||||
expect(await name.textContent()).toBe('bremse_alt1_pedal.png');
|
||||
// The name is shown whole; only the folder part is shortened.
|
||||
expect(await name.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
|
||||
expect(await row.locator('.edit-history-path-dir').evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(true);
|
||||
state.set(sessionA, s);
|
||||
});
|
||||
|
||||
it('renders a hostile path as text, never as markup', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
const evil = '<script>window.__pwned=1</script><img src=x onerror="window.__pwned=2">.ts';
|
||||
state.set(sessionA, {
|
||||
...s,
|
||||
agents: { agent1: { description: '<b>bold</b>', agentType: 'x', worktreeBranch: '' } },
|
||||
entries: [entry({ id: 'evil', relPath: evil, agentId: 'agent1' }), ...(s.entries as Entry[])],
|
||||
});
|
||||
await reload();
|
||||
await page.waitForFunction(() => /__pwned/.test(document.getElementById('editHistoryBody')!.textContent ?? ''));
|
||||
expect(await rows().nth(0).locator('.edit-history-path').textContent()).toBe(evil);
|
||||
expect(await rows().nth(0).locator('.edit-history-agent').textContent()).toBe('<b>bold</b>');
|
||||
expect(await page.$('#editHistoryBody script')).toBeNull();
|
||||
expect(await page.$('#editHistoryBody b')).toBeNull();
|
||||
expect(
|
||||
await page.$$eval(
|
||||
'#editHistoryBody img',
|
||||
(els) => els.filter((e) => !e.classList.contains('edit-history-thumb')).length
|
||||
)
|
||||
).toBe(0);
|
||||
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('unavailable: the saved entries stay, with the reason on top', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
state.set(sessionA, { ...s, state: 'unavailable', reason: 'EACCES: permission denied' });
|
||||
await reload();
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-note--warn');
|
||||
expect(await page.textContent('#editHistoryBody .edit-history-note--warn')).toBe(
|
||||
'Showing saved entries only: EACCES: permission denied'
|
||||
);
|
||||
expect(await rows().count()).toBeGreaterThan(0);
|
||||
state.set(sessionA, { ...s, state: 'ok' });
|
||||
});
|
||||
|
||||
it('no transcript yet: says it comes with the next prompt, and names CLAUDE_CONFIG_DIR', async () => {
|
||||
const s = state.get(sessionA)!;
|
||||
state.set(sessionA, { ...s, state: 'unavailable', reason: 'no-transcript', entries: [] });
|
||||
await reload();
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-note--warn');
|
||||
const note = (await page.textContent('#editHistoryBody .edit-history-note--warn')) ?? '';
|
||||
expect(note).toContain('No transcript found for this session yet');
|
||||
expect(note).toContain('CLAUDE_CONFIG_DIR');
|
||||
state.set(sessionA, s);
|
||||
await reload();
|
||||
});
|
||||
|
||||
it('follows the active session, and says when a session is not a Claude session', async () => {
|
||||
await select(sessionB);
|
||||
await page.waitForFunction(() =>
|
||||
/only available for local Claude sessions/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
|
||||
);
|
||||
expect(calls).toContain(`GET /api/sessions/${sessionB}/edits?sync=1`);
|
||||
expect(await rows().count()).toBe(0);
|
||||
await select(sessionA);
|
||||
await page.waitForSelector('#editHistoryBody .edit-history-entry');
|
||||
});
|
||||
|
||||
it('drags by the header', async () => {
|
||||
const before = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left);
|
||||
const box = (await page.locator('.edit-history-header').boundingBox())!;
|
||||
await page.mouse.move(box.x + 40, box.y + 10);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x - 140, box.y + 70, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
const after = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left);
|
||||
expect(after).toBeLessThan(before - 100);
|
||||
});
|
||||
|
||||
it('turning the setting off hides the button and closes the panel; SSE then reads nothing', async () => {
|
||||
await setSetting(false);
|
||||
expect(await buttonVisible()).toBe(false);
|
||||
expect(await page.isVisible('#editHistoryPanel')).toBe(false);
|
||||
const before = calls.length;
|
||||
toolEnd(sessionA, 'Edit');
|
||||
await page.waitForTimeout(1200);
|
||||
expect(calls.length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Verify parser installation through the real server, including API aliases and base paths. */
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
describe('empty POST: production server wiring', () => {
|
||||
let server: WebServer;
|
||||
let app: FastifyInstance;
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true, '127.0.0.1', undefined, false, '/codeman');
|
||||
await server.start();
|
||||
app = (server as unknown as { app: FastifyInstance }).app;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
});
|
||||
|
||||
it('accepts empty chunked POSTs to a real API through each supported prefix', async () => {
|
||||
for (const prefix of ['/api', '/api/v1', '/codeman/api', '/codeman/api/v1']) {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `${prefix}/approvals/session/nonexistent/viewed`,
|
||||
headers: { 'transfer-encoding': 'chunked' },
|
||||
});
|
||||
// The real handler ran and found no session, instead of the parser returning 415.
|
||||
expect(res.statusCode, res.body).toBe(404);
|
||||
expect(res.json().errorCode).toBe('NOT_FOUND');
|
||||
}
|
||||
});
|
||||
|
||||
it('still rejects a nonempty untyped POST before reaching the same handler', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/approvals/session/nonexistent/viewed',
|
||||
headers: { 'transfer-encoding': 'chunked' },
|
||||
payload: '{}',
|
||||
});
|
||||
expect(res.statusCode).toBe(415);
|
||||
});
|
||||
|
||||
it('preserves the API 404 envelope for an unknown empty chunked POST', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/nonexistent',
|
||||
headers: { 'transfer-encoding': 'chunked' },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().errorCode).toBe('NOT_FOUND');
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ const SURFACES = [
|
||||
{ attr: 'win', array: 'WIN_ANIM_STYLES', selector: '.subagent-window.win-enter' },
|
||||
{ attr: 'line', array: 'LINE_ANIM_STYLES', selector: '.connection-line.line-enter' },
|
||||
{ attr: 'term', array: 'TERM_ANIM_STYLES', selector: '.terminal-container.term-enter' },
|
||||
{ attr: 'tile', array: 'TILE_ANIM_STYLES', selector: '.tile.tile--entering' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,11 @@ const SURFACES = [
|
||||
*/
|
||||
const CSS_LESS_STYLES = new Set(['off', 'fly']);
|
||||
|
||||
/** `fly` is CSS-less on the window surface only: a tile's `fly` is keyframes like any other. */
|
||||
function isCssLess(attr: string, key: string): boolean {
|
||||
return key === 'off' || (attr === 'win' && CSS_LESS_STYLES.has(key));
|
||||
}
|
||||
|
||||
function styleKeys(arrayName: string): string[] {
|
||||
const start = animSource.indexOf(`const ${arrayName} = [`);
|
||||
expect(start, `${arrayName} not found`).toBeGreaterThan(-1);
|
||||
@@ -47,12 +53,21 @@ function styleKeys(arrayName: string): string[] {
|
||||
return [...body.matchAll(/\{ key: '([^']+)'/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
function themes(): { key: string; tab: string; win: string; line: string; term: string }[] {
|
||||
type Theme = { key: string; tab: string; win: string; line: string; term: string; tile: string };
|
||||
|
||||
function themes(): Theme[] {
|
||||
const start = animSource.indexOf('const ANIM_THEMES = [');
|
||||
const body = animSource.slice(start, animSource.indexOf('];', start));
|
||||
return [
|
||||
...body.matchAll(/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)' \}/g),
|
||||
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5] }));
|
||||
const parsed = [
|
||||
...body.matchAll(
|
||||
/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)', tile: '([^']+)' \}/g
|
||||
),
|
||||
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5], tile: m[6] }));
|
||||
// Every theme entry must parse: a theme missing a surface (or a regex that
|
||||
// drifted from the source) would otherwise pass the checks below vacuously.
|
||||
expect(parsed.length, 'a theme entry did not parse').toBe((body.match(/\{ key: '/g) || []).length);
|
||||
expect(parsed.length).toBeGreaterThan(0);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +97,7 @@ describe('entrance animation styles', () => {
|
||||
describe(`${surface.attr} surface`, () => {
|
||||
it('backs every style with a rule that names a keyframe block that exists', () => {
|
||||
for (const key of styleKeys(surface.array)) {
|
||||
if (CSS_LESS_STYLES.has(key)) {
|
||||
if (isCssLess(surface.attr, key)) {
|
||||
expect(stylesSource).not.toContain(`html[data-${surface.attr}-anim="${key}"]`);
|
||||
continue;
|
||||
}
|
||||
@@ -99,8 +114,16 @@ describe('entrance animation styles', () => {
|
||||
});
|
||||
}
|
||||
|
||||
it('ships the blur style on all four surfaces', () => {
|
||||
for (const surface of SURFACES) expect(styleKeys(surface.array)).toContain('blur');
|
||||
/**
|
||||
* Tiles are the exception: six frames animate at once, so their styles stay
|
||||
* off `filter`, and a tile's blur is its SCREEN beat (the pane's `blur`
|
||||
* style on .tile-body, one tile at a time), which the Soft focus theme uses.
|
||||
*/
|
||||
it('ships the blur style on the four single-element surfaces', () => {
|
||||
for (const surface of SURFACES) {
|
||||
if (surface.attr === 'tile') expect(styleKeys(surface.array)).not.toContain('blur');
|
||||
else expect(styleKeys(surface.array)).toContain('blur');
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every theme an <option> and only styles that exist', () => {
|
||||
@@ -166,9 +189,103 @@ describe('entrance animation styles', () => {
|
||||
expect(blur).not.toBeNull();
|
||||
expect(blur.match(/var\(--line-glow\)/g)?.length).toBe(2);
|
||||
// The 100% frame deliberately omits opacity so the endpoint comes from the
|
||||
// element's own resting value: 0.9 on a subagent line, 0.72 on a lineage
|
||||
// line, 0.95 on a working one. Pinning a number here snaps three of them.
|
||||
// element's own resting value: 0.9 on a subagent line, 1 on a lineage
|
||||
// line (its family group is translucent), 0.5 on a proxied one. Pinning a
|
||||
// number here snaps them.
|
||||
expect(blur).toMatch(/100%\s*\{\s*filter:[^}]*\}/);
|
||||
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tile grid entrance styles', () => {
|
||||
/** Keyframes a `html[data-tile-anim=...]` rule names, on the tile or on its ::before wash. */
|
||||
const tileNames = (key: string, scope: RegExp) =>
|
||||
[...stylesSource.matchAll(new RegExp(`html\\[data-tile-anim="${key}"\\]([^{]*)\\{([^}]*)\\}`, 'g'))]
|
||||
.filter((rule) => scope.test(rule[1]))
|
||||
.flatMap((rule) =>
|
||||
[...rule[2].matchAll(/animation(?:-name)?:\s*([\w-]+)/g)].map((m) => ({
|
||||
name: m[1],
|
||||
onPseudo: rule[1].includes('::before'),
|
||||
}))
|
||||
);
|
||||
|
||||
/**
|
||||
* ⚠ The FitAddon rule, for six frames at once: transform and opacity only.
|
||||
* A tile fits once at its final size (#464); a box-model property here would
|
||||
* resize its PTY mid-animation, and a filter on six live terminals at once
|
||||
* is the frame-time cost the pane's `blur` takes for one.
|
||||
*/
|
||||
it('animates only transform and opacity on a tile frame, entering and leaving', () => {
|
||||
for (const key of styleKeys('TILE_ANIM_STYLES')) {
|
||||
if (key === 'off') continue;
|
||||
const names = [...tileNames(key, /tile--entering/), ...tileNames(key, /tile--leaving/)];
|
||||
expect(names.length, `no keyframes for tile/${key}`).toBeGreaterThan(0);
|
||||
for (const { name, onPseudo } of names) {
|
||||
const body = keyframeBody(name);
|
||||
expect(body, `@keyframes ${name} missing`).not.toBeNull();
|
||||
if (onPseudo) continue; // a wash over the tile, no layout of its own
|
||||
for (const [, prop] of (body as string).matchAll(/(?:\{|;)\s*([a-z-]+):/g)) {
|
||||
expect(['opacity', 'transform'], `@keyframes ${name} animates ${prop} on a tile`).toContain(prop);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The mount clears `.tile--entering` on the tile's own `tile-enter*`
|
||||
* animationend, and the still copy goes on its last tile's `tile-leave*`:
|
||||
* a keyframe named otherwise would leave the class on (or the copy up)
|
||||
* until a backstop timer.
|
||||
*/
|
||||
it('names every frame keyframe for the events tile-grid.js listens for', () => {
|
||||
for (const key of styleKeys('TILE_ANIM_STYLES')) {
|
||||
for (const { name, onPseudo } of tileNames(key, /tile--entering/)) {
|
||||
if (!onPseudo) expect(name, `tile/${key} entering`).toMatch(/^tile-enter/);
|
||||
}
|
||||
for (const { name, onPseudo } of tileNames(key, /tile--leaving/)) {
|
||||
if (!onPseudo) expect(name, `tile/${key} leaving`).toMatch(/^tile-leave/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every exit the module times a leaving rule, and keeps `settle` on the grid default', () => {
|
||||
const exits = animSource.slice(animSource.indexOf('const TILE_EXIT_MS = {'));
|
||||
const timed = [...exits.slice(0, exits.indexOf('};')).matchAll(/(\w+): \d+/g)].map((m) => m[1]);
|
||||
expect(timed.length).toBeGreaterThan(0);
|
||||
for (const key of timed) {
|
||||
expect(styleKeys('TILE_ANIM_STYLES')).toContain(key);
|
||||
expect(tileNames(key, /tile--leaving/).length, `no leaving rule for tile/${key}`).toBeGreaterThan(0);
|
||||
}
|
||||
expect(timed).not.toContain('settle');
|
||||
expect(animSource).toContain("const TILE_ANIM_DEFAULT = 'settle';");
|
||||
// The legacy theme (the default) leaves the grid's own motion untouched.
|
||||
expect(themes().find((t) => t.key === 'legacy')?.tile).toBe('settle');
|
||||
});
|
||||
|
||||
/**
|
||||
* App Settings → Appearance → Tile Animations: one option per style, wired
|
||||
* by id (entrance-animations.js _syncEntranceAnimSetting), with the grid's
|
||||
* own `settle` first and named as the off default.
|
||||
*/
|
||||
it('lists every tile style in the Tile Animations setting, off by default', () => {
|
||||
const start = indexSource.indexOf('<select id="appSettingsTileAnim"');
|
||||
expect(start, 'the Tile Animations select is missing').toBeGreaterThan(-1);
|
||||
const select = indexSource.slice(start, indexSource.indexOf('</select>', start));
|
||||
const values = [...select.matchAll(/<option value="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect([...values].sort()).toEqual([...styleKeys('TILE_ANIM_STYLES')].sort());
|
||||
expect(values[0]).toBe('settle');
|
||||
expect(select).toContain('<option value="settle">Off (default)</option>');
|
||||
expect(animSource).toContain("document.getElementById('appSettingsTileAnim')");
|
||||
// Nothing new for an install that never picks it: no saved key means settle.
|
||||
expect(animSource).toMatch(/pick\('tileanim', TILE_ANIM_STYLES, ANIM_KEYS\.tile, TILE_ANIM_DEFAULT\)/);
|
||||
});
|
||||
|
||||
/** A tile's screen plays the pane's style: every term rule also reaches .tile-body. */
|
||||
it('plays every terminal pane style on a tile screen too', () => {
|
||||
for (const key of styleKeys('TERM_ANIM_STYLES')) {
|
||||
if (CSS_LESS_STYLES.has(key)) continue;
|
||||
// The body itself, not only its ::before wash.
|
||||
expect(stylesSource).toMatch(new RegExp(`html\\[data-term-anim="${key}"\\] \\.tile-body\\.term-enter \\{`));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,17 @@ describe('envOverrides exact-key allowlist', () => {
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects the shared GitHub token names (Copilot reads COPILOT_GITHUB_TOKEN, in its own prefix)', () => {
|
||||
for (const key of ['GH_TOKEN', 'GITHUB_TOKEN']) {
|
||||
for (const mode of ['claude', 'shell', 'codex', 'copilot'] as const) {
|
||||
expect(() => CreateSessionSchema.parse({ workingDir: '/tmp', mode, envOverrides: { [key]: 'x' } })).toThrow();
|
||||
}
|
||||
}
|
||||
expect(() =>
|
||||
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'copilot', envOverrides: { COPILOT_GITHUB_TOKEN: 'x' } })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('still blocks security-sensitive keys', () => {
|
||||
for (const key of ['PATH', 'LD_PRELOAD', 'NODE_OPTIONS', 'CODEMAN_MUX_NAME']) {
|
||||
expect(() =>
|
||||
|
||||
Loaded 100 of 858 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user