Merge remote-tracking branch 'origin/master' into relprep/pr-677

# Conflicts:
#	src/tmux-manager.ts
This commit is contained in:
Codeman maintainer committed 2026-10-24 13:54:44 +02:00
commit effb1ef7a5
185 files changed
+13452 -644

No files matched your search

+105
View File
@@ -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);
});
});
+4 -1
View File
@@ -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 });
+85
View File
@@ -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');
});
});
+181
View File
@@ -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, '\\.')}['"]?: '`));
}
});
});
+334
View File
@@ -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();
});
});
+12 -3
View File
@@ -486,15 +486,15 @@ describe('capability shapes', () => {
});
it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => {
// claude forwards from 2.1.187 (fullscreen renderer only); copilot forwards at any version while
// it tracks the mouse. codex ignores SGR wheel reports, so every other entry stays 'never'.
// Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot
// forward without a version gate. Unmeasured entries stay 'never'.
const mode = (id: string) => STOCK_CLIS.find((e) => (e.id as string) === id)?.capabilities.wheelForward;
expect(mode('copilot')).toEqual({ mode: 'always' });
expect(mode('claude')).toEqual({ mode: 'version-gated', minVersion: '2.1.187' });
const forwarding = STOCK_CLIS.filter((e) => e.capabilities.wheelForward.mode !== 'never').map(
(e) => e.id as string
);
expect(forwarding.sort()).toEqual(['claude', 'copilot']);
expect(forwarding.sort()).toEqual(['claude', 'codex', 'copilot']);
expect(CliEntrySchema.safeParse(baseEntry('copilot')).success).toBe(true);
});
@@ -505,3 +505,12 @@ describe('capability shapes', () => {
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode']);
});
});
describe('observed mouse-tracking wheel policy', () => {
it('advertises and validates tracked Codex forwarding without a fictitious minimum version', () => {
const codex = STOCK_CLIS.find((e) => e.id === 'codex')!;
expect(codex.capabilities.wheelForward).toEqual({ mode: 'always' });
expect(CliEntrySchema.safeParse(codex).success).toBe(true);
expect(codex.capabilities.transcriptPageKeys).toBe(true);
});
});
+24
View File
@@ -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', () => {
+82
View File
@@ -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();
}
});
});
+59
View File
@@ -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
});
});
+188
View File
@@ -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,
});
});
});
+686
View File
@@ -0,0 +1,686 @@
/**
* @fileoverview Config backups (src/config-backup.ts): what is tracked, when a snapshot is made,
* how retention prunes, and that restore verifies before it writes. Pure fs over temp dirs;
* port: N/A.
*/
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
symlinkSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
CONFIG_BACKUP_FILES,
ConfigBackupCorruptError,
ConfigBackupNotFoundError,
ConfigBackupPartialRestoreError,
createConfigBackup,
defaultConfigBackupDir,
diffConfigBackups,
listConfigBackups,
normalizeBackupDir,
pruneConfigBackups,
readConfigBackupSettings,
resolveConfigBackupDir,
restoreConfigBackup,
configBackupOrigin,
latestBackupRetention,
sweepConfigBackupTemps,
} from '../src/config-backup.js';
let root: string;
let data: string;
let backups: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'cfg-backup-'));
data = join(root, 'data');
backups = join(root, 'backups');
mkdirSync(data);
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
const put = (name: string, text: string) => writeFileSync(join(data, name), text);
const DAY = 86_400_000;
const T0 = Date.UTC(2026, 9, 1, 12, 0, 0);
describe('settings', () => {
it('defaults to ON, 20 copies, 30 days, default folder', () => {
expect(readConfigBackupSettings({})).toEqual({ enabled: true, dir: null, keepCount: 20, keepDays: 30 });
expect(readConfigBackupSettings(null).enabled).toBe(true);
});
it('only an explicit false turns it off, and numbers are clamped to their ranges', () => {
expect(readConfigBackupSettings({ configBackupEnabled: false }).enabled).toBe(false);
const s = readConfigBackupSettings({ configBackupKeepCount: 9999, configBackupKeepDays: -3 });
expect(s.keepCount).toBe(500);
expect(s.keepDays).toBe(0);
expect(readConfigBackupSettings({ configBackupKeepCount: 0 }).keepCount).toBe(2);
expect(readConfigBackupSettings({ configBackupKeepCount: 'abc' }).keepCount).toBe(20);
expect(readConfigBackupSettings({ configBackupKeepCount: 7.9 }).keepCount).toBe(7);
});
it('expands ~ and refuses anything that is not an absolute path', () => {
expect(normalizeBackupDir('~/cfg', '/home/u')).toBe('/home/u/cfg');
expect(normalizeBackupDir('/mnt/backups/codeman', '/home/u')).toBe('/mnt/backups/codeman');
for (const bad of ['', ' ', 'relative/dir', './here', undefined, 42, '/x\0y']) {
expect(normalizeBackupDir(bad as never, '/home/u'), String(bad)).toBeNull();
}
});
it('uses <data dir>/backups/config by default and never the data dir itself', () => {
expect(resolveConfigBackupDir(readConfigBackupSettings({}), '/d')).toBe(defaultConfigBackupDir('/d'));
expect(resolveConfigBackupDir(readConfigBackupSettings({ configBackupDir: '/elsewhere' }), '/d')).toBe(
'/elsewhere'
);
expect(resolveConfigBackupDir(readConfigBackupSettings({ configBackupDir: '/d' }), '/d')).toBe(
defaultConfigBackupDir('/d')
);
});
});
describe('createConfigBackup', () => {
it('snapshots only the tracked files that exist, with a manifest', () => {
put('settings.json', '{"a":1}');
put('clis.json', '{"clis":{}}');
put('state.json', '{"runtime":true}'); // not config: must not be copied
put('hook-secret', 'x'); // regenerated: must not be copied
const r = createConfigBackup({
dataDir: data,
backupDir: backups,
reason: 'startup',
appVersion: '1.2.3',
now: T0,
});
expect(r.status).toBe('created');
const [b] = listConfigBackups(backups);
expect(b.files.map((f) => f.name).sort()).toEqual(['clis.json', 'settings.json']);
expect(b.reason).toBe('startup');
expect(b.appVersion).toBe('1.2.3');
expect(b.createdAt).toBe(T0);
expect(existsSync(join(backups, b.id, 'state.json'))).toBe(false);
expect(readFileSync(join(backups, b.id, 'settings.json'), 'utf8')).toBe('{"a":1}');
});
it('tracks a fixed allowlist', () => {
expect([...CONFIG_BACKUP_FILES]).toContain('settings.json');
expect([...CONFIG_BACKUP_FILES]).not.toContain('state.json');
expect([...CONFIG_BACKUP_FILES]).not.toContain('hook-secret');
});
it('makes no second snapshot while nothing changed, and one when something does', () => {
put('settings.json', '{"a":1}');
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
const again = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 });
expect(again).toEqual({ status: 'unchanged', id: (first as { id: string }).id });
expect(listConfigBackups(backups)).toHaveLength(1);
put('settings.json', '{"a":2}');
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
'created'
);
expect(listConfigBackups(backups)).toHaveLength(2);
});
it('does not count a self-refreshed discovery timestamp as a change', () => {
const hosts = (stamp: string, model = 'm1') =>
JSON.stringify({ hosts: [{ id: 'h', models: [{ id: model }], lastDiscoveredAt: stamp }] });
put('custom-model-hosts.json', hosts('2026-10-12T08:44:22.026Z'));
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
// The 5-minute discovery poll rewrites only the timestamp: still the same state.
put('custom-model-hosts.json', hosts('2026-10-12T08:49:22.025Z'));
const again = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 });
expect(again).toEqual({ status: 'unchanged', id: (first as { id: string }).id });
expect(listConfigBackups(backups)).toHaveLength(1);
// A real edit next to a refreshed timestamp is still a change, stored exactly as written.
const edited = hosts('2026-10-12T08:54:22.025Z', 'm2');
put('custom-model-hosts.json', edited);
const third = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 });
expect(third.status).toBe('created');
expect(readFileSync(join(backups, (third as { id: string }).id, 'custom-model-hosts.json'), 'utf8')).toBe(edited);
});
it.each([
[
'webviews.json',
(n: number) =>
JSON.stringify([
{
id: 'w1',
name: 'Grafana',
url: 'http://localhost:3001',
embedMode: 'proxy',
trusted: false,
createdAt: 1,
lastOpenedAt: n,
},
]),
'name',
],
[
'docker-cases.json',
(n: number) => JSON.stringify([{ name: 'app', hostId: 'h', lastClaudeSessionId: `c${n}` }]),
'hostId',
],
['users.json', (n: number) => JSON.stringify({ users: [{ name: 'a', role: 'admin', lastLoginAt: n }] }), 'role'],
['settings.json', (n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}` }), 'displayName'],
[
'intents.json',
(n: number) => JSON.stringify({ profiles: [{ key: 'k', goals: 'g', updatedAt: n, recentPrompts: [`p${n}`] }] }),
'goals',
],
])('does not count activity in %s as a change, but does count an edit next to it', (name, make, editKey) => {
put(name, make(1));
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
put(name, make(2));
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 })).toEqual({
status: 'unchanged',
id: (first as { id: string }).id,
});
put(name, make(3).replace(new RegExp(`"${editKey}":"[^"]*"`), `"${editKey}":"edited"`));
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
'created'
);
});
it('compares a tracked file that is not valid JSON by its bytes', () => {
put('custom-model-hosts.json', 'not json lastDiscoveredAt 1');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
put('custom-model-hosts.json', 'not json lastDiscoveredAt 2');
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }).status).toBe(
'created'
);
});
it('force makes a snapshot even when unchanged, and two in one second get distinct ids', () => {
put('settings.json', '{}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'a', now: T0 });
const forced = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'manual', force: true, now: T0 });
expect(forced.status).toBe('created');
const ids = listConfigBackups(backups).map((b) => b.id);
expect(new Set(ids).size).toBe(2);
});
it('reports empty, creating nothing, when no tracked file exists', () => {
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x' })).toEqual({ status: 'empty' });
expect(existsSync(backups)).toBe(false);
});
it('keeps snapshots private: 0700 folder and 0600 files', () => {
put('settings.json', '{}');
put('.env', 'CODEMAN_PASSWORD=secret');
const r = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x', now: T0 });
const id = (r as { id: string }).id;
expect(statSync(join(backups, id)).mode & 0o777).toBe(0o700);
expect(statSync(join(backups, id, '.env')).mode & 0o777).toBe(0o600);
expect(statSync(join(backups, id, 'manifest.json')).mode & 0o777).toBe(0o600);
});
it('leaves no temp folder behind', () => {
put('settings.json', '{}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x', now: T0 });
const stray = readdirSync(backups).filter((n) => n.startsWith('.tmp-'));
expect(stray).toEqual([]);
});
});
describe('retention', () => {
const makeN = (n: number) => {
for (let i = 0; i < n; i++) {
put('settings.json', JSON.stringify({ i }));
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + i * DAY });
}
};
it('keeps the newest keepCount', () => {
makeN(6);
const removed = pruneConfigBackups(backups, { keepCount: 3, keepDays: 0, now: T0 + 6 * DAY });
expect(removed).toHaveLength(3);
const left = listConfigBackups(backups);
expect(left).toHaveLength(3);
expect(left.map((b) => b.createdAt)).toEqual([T0 + 5 * DAY, T0 + 4 * DAY, T0 + 3 * DAY]);
});
it('drops anything older than keepDays, but never the newest', () => {
makeN(4); // T0 .. T0+3d
// At T0+100d everything is older than 30 days: the newest still survives.
const removed = pruneConfigBackups(backups, { keepCount: 50, keepDays: 30, now: T0 + 100 * DAY });
expect(removed).toHaveLength(3);
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([T0 + 3 * DAY]);
});
it('applies both limits together and keepDays 0 means no age limit', () => {
makeN(5);
expect(pruneConfigBackups(backups, { keepCount: 50, keepDays: 0, now: T0 + 999 * DAY })).toEqual([]);
const removed = pruneConfigBackups(backups, { keepCount: 4, keepDays: 3, now: T0 + 5 * DAY });
// Newest first: T0+4d, +3d, +2d, +1d, T0. A snapshot ages from its successor: T0+1d was live
// until T0+2d, which sits exactly on the cutoff (kept); only T0 is over the count.
expect(removed.length).toBe(1);
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([
T0 + 4 * DAY,
T0 + 3 * DAY,
T0 + 2 * DAY,
T0 + DAY,
]);
});
it('keeps the last good copy when the first change comes after a quiet month', () => {
put('settings.json', '{"theme":"good"}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'startup', now: T0 });
put('settings.json', '{}'); // the bad write, 40 days later
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 40 * DAY });
pruneConfigBackups(backups, { keepCount: 20, keepDays: 30, now: T0 + 40 * DAY });
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([T0 + 40 * DAY, T0]);
});
it('never deletes a folder it did not make', () => {
makeN(3);
mkdirSync(join(backups, 'photos'));
writeFileSync(join(backups, 'photos', 'a.jpg'), 'x');
mkdirSync(join(backups, 'cfg-20200101-000000')); // right name, no manifest: not ours
writeFileSync(join(backups, 'notes.txt'), 'keep');
pruneConfigBackups(backups, { keepCount: 1, keepDays: 1, now: T0 + 500 * DAY });
expect(existsSync(join(backups, 'photos', 'a.jpg'))).toBe(true);
expect(existsSync(join(backups, 'cfg-20200101-000000'))).toBe(true);
expect(existsSync(join(backups, 'notes.txt'))).toBe(true);
expect(listConfigBackups(backups)).toHaveLength(1);
});
it('does nothing for a folder that does not exist', () => {
expect(pruneConfigBackups(join(root, 'nope'), { keepCount: 1, keepDays: 1 })).toEqual([]);
});
});
describe('restoreConfigBackup', () => {
it('puts the files back, atomically, after taking a pre-restore snapshot', () => {
put('settings.json', '{"good":true}');
put('clis.json', '{"clis":{"grok":{"enabled":true}}}');
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
put('settings.json', '{"polluted":true}');
rmSync(join(data, 'clis.json'));
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id, now: T0 + 1000 });
expect(r.restored.sort()).toEqual(['clis.json', 'settings.json']);
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"good":true}');
expect(readFileSync(join(data, 'clis.json'), 'utf8')).toBe('{"clis":{"grok":{"enabled":true}}}');
expect(statSync(join(data, 'settings.json')).mode & 0o777).toBe(0o600);
// The state it replaced is recoverable.
expect(r.safetySnapshot).toBeTruthy();
const safety = listConfigBackups(backups).find((b) => b.id === r.safetySnapshot)!;
expect(safety.reason).toBe('pre-restore');
expect(readFileSync(join(backups, safety.id, 'settings.json'), 'utf8')).toBe('{"polluted":true}');
});
it('refuses a snapshot whose file no longer matches its checksum, changing nothing', () => {
put('settings.json', '{"good":true}');
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
chmodSync(join(backups, made.id, 'settings.json'), 0o600);
writeFileSync(join(backups, made.id, 'settings.json'), '{"tampered":true}');
put('settings.json', '{"live":true}');
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id })).toThrow(
ConfigBackupCorruptError
);
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"live":true}');
expect(listConfigBackups(backups)).toHaveLength(1); // no pre-restore snapshot for a refused restore
});
it('rejects unknown ids and anything that is not a snapshot name (no path traversal)', () => {
put('settings.json', '{}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
for (const id of ['cfg-19990101-000000', '../data', '..', 'photos', 'cfg-20261001-120000/../../x']) {
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id }), id).toThrow(
ConfigBackupNotFoundError
);
}
});
it('reuses the newest backup as the undo copy when the live files already equal it', () => {
put('settings.json', '{"a":1}');
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
put('settings.json', '{"a":2}');
const newest = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + DAY }) as {
id: string;
};
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id: first.id, now: T0 + 2 * DAY });
expect(r.safetySnapshot).toBe(newest.id);
expect(listConfigBackups(backups).map((b) => b.id)).toEqual([newest.id, first.id]); // no duplicate
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"a":1}');
});
it('a file it cannot write does not stop the others, and the error names both lists', () => {
put('settings.json', '{"good":true}');
put('clis.json', '{"clis":{}}');
put('webhook.json', '{"enabled":false}');
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
put('settings.json', '{"polluted":true}');
put('webhook.json', '{"enabled":true}');
rmSync(join(data, 'clis.json'));
mkdirSync(join(data, 'clis.json')); // between the other two in the tracked list
let caught: unknown;
try {
restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id, now: T0 + 1000 });
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(ConfigBackupPartialRestoreError);
const err = caught as ConfigBackupPartialRestoreError;
expect(err.restored).toEqual(['settings.json', 'webhook.json']);
expect(err.failed.map((f) => f.name)).toEqual(['clis.json']);
expect(err.safetySnapshot).toBeTruthy();
expect(readFileSync(join(data, 'webhook.json'), 'utf8')).toBe('{"enabled":false}');
expect(readdirSync(data).filter((n) => n.includes('.restore-'))).toEqual([]); // no temp left behind
});
it('only ever writes allowlisted names, even if a manifest lists others', () => {
put('settings.json', '{"a":1}');
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
const mpath = join(backups, made.id, 'manifest.json');
const m = JSON.parse(readFileSync(mpath, 'utf8'));
m.files.push({ name: '../escape.json', bytes: 1, sha256: 'x' }, { name: 'state.json', bytes: 1, sha256: 'x' });
writeFileSync(mpath, JSON.stringify(m));
restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id });
expect(existsSync(join(root, 'escape.json'))).toBe(false);
expect(existsSync(join(data, 'state.json'))).toBe(false);
});
});
describe('diffConfigBackups', () => {
const snap = (now: number) => {
const r = createConfigBackup({ dataDir: data, backupDir: backups, reason: 't', force: true, now });
if (r.status !== 'created') throw new Error('expected a snapshot');
return r.id;
};
const diff = (from: string, to: string) => diffConfigBackups({ dataDir: data, backupDir: backups, from, to });
const file = (d: ReturnType<typeof diff>, name: string) => d.files.find((f) => f.name === name)!;
it('lists key-level changes between two backups', () => {
put('settings.json', JSON.stringify({ displayName: 'a', theme: { mode: 'dark' }, tags: ['x'] }));
const a = snap(T0);
put('settings.json', JSON.stringify({ displayName: 'b', theme: { mode: 'dark' }, tags: ['x', 'y'], extra: 1 }));
const b = snap(T0 + 1000);
const f = file(diff(a, b), 'settings.json');
expect(f.status).toBe('changed');
expect(f.changes).toEqual([
{ path: 'displayName', kind: 'changed', before: '"a"', after: '"b"' },
{ path: 'extra', kind: 'added', after: '1' },
{ path: 'tags[1]', kind: 'added', after: '"y"' },
]);
});
it('compares a backup with the running config, and reports files that appeared or vanished', () => {
put('settings.json', '{"a":1}');
put('webhook.json', '{"url":"u"}');
const a = snap(T0);
put('settings.json', '{"a":2}');
rmSync(join(data, 'webhook.json'));
put('intents.json', '{"i":true}');
const d = diff(a, 'current');
expect(d.changed).toBe(3);
expect(file(d, 'settings.json').changes).toEqual([{ path: 'a', kind: 'changed', before: '1', after: '2' }]);
expect(file(d, 'webhook.json').status).toBe('removed');
expect(file(d, 'intents.json').status).toBe('added');
expect(diff(a, a).changed).toBe(0);
});
it('never returns a webhook URL, captured prompts, or URL userinfo and query', () => {
put('webhook.json', JSON.stringify({ url: 'https://hooks.slack.com/services/T0/B0/oldsecret' }));
put('intents.json', JSON.stringify({ profiles: [{ key: 'k', prompts: ['my password is pw1'] }] }));
put('settings.json', JSON.stringify({ baseUrl: 'https://user:pw@host/p?token=t1#f' }));
const a = snap(T0);
put('webhook.json', JSON.stringify({ url: 'https://hooks.slack.com/services/T0/B0/newsecret' }));
put('intents.json', JSON.stringify({ profiles: [{ key: 'k', prompts: ['my password is pw2'] }] }));
put('settings.json', JSON.stringify({ baseUrl: 'https://user:pw@host/p?token=t2#f' }));
const d = diff(a, 'current');
const json = JSON.stringify(d);
for (const s of ['secret', 'pw1', 'pw2', 'user:pw', 'token=', 'hooks.slack']) expect(json).not.toContain(s);
expect(file(d, 'webhook.json').changes.every((c) => c.redacted)).toBe(true);
expect(file(d, 'intents.json').changes.every((c) => c.redacted)).toBe(true);
});
it('never returns a credential value, only that it differs', () => {
put('.env', 'SECRET_ONE=hunter2\nOTHER=abc\n');
put('users.json', JSON.stringify({ admin: { name: 'root' } }));
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', apiKey: 'sk-old', url: 'http://x' }]));
const a = snap(T0);
put('.env', 'SECRET_ONE=hunter3\nOTHER=abd\n');
put('users.json', JSON.stringify({ admin: { name: 'rooted' } }));
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', apiKey: 'sk-new', url: 'http://y' }]));
const d = diff(a, 'current');
const json = JSON.stringify(d);
for (const secret of ['hunter', 'abc', 'abd', 'root', 'sk-old', 'sk-new']) expect(json).not.toContain(secret);
expect(file(d, '.env').changes.every((c) => c.redacted)).toBe(true);
expect(file(d, 'custom-model-hosts.json').changes).toEqual([
{ path: '[0].apiKey', kind: 'changed', redacted: true },
{ path: '[0].url', kind: 'changed', before: '"http://x"', after: '"http://y"' },
]);
});
it('does not count a self-refreshed discovery timestamp as a difference', () => {
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', lastDiscoveredAt: 1 }]));
const a = snap(T0);
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', lastDiscoveredAt: 2 }]));
const d = diff(a, 'current');
expect(file(d, 'custom-model-hosts.json').status).toBe('unchanged');
expect(d.changed).toBe(0);
});
it('reports a file that is not JSON as differing, without its content', () => {
put('settings.json', 'not json at all');
const a = snap(T0);
put('settings.json', 'still not json');
const f = file(diff(a, 'current'), 'settings.json');
expect(f).toMatchObject({ status: 'changed', detail: 'bytes', changes: [] });
});
it('caps the changes listed per file', () => {
put('settings.json', '{}');
const a = snap(T0);
put('settings.json', JSON.stringify(Object.fromEntries(Array.from({ length: 250 }, (_, i) => [`k${i}`, i]))));
const f = file(diff(a, 'current'), 'settings.json');
expect(f.changes).toHaveLength(200);
expect(f.truncated).toBe(true);
});
it('refuses unknown ids, path traversal and a tampered snapshot', () => {
put('settings.json', '{"a":1}');
const a = snap(T0);
expect(() => diff('cfg-20200101-000000', 'current')).toThrow(ConfigBackupNotFoundError);
expect(() => diff('../etc', 'current')).toThrow(ConfigBackupNotFoundError);
writeFileSync(join(backups, a, 'settings.json'), '{"a":99}');
expect(() => diff(a, 'current')).toThrow(ConfigBackupCorruptError);
});
});
describe('createConfigBackup: a snapshot dated ahead of the clock', () => {
it('is not written again on every pass once the clock is corrected', () => {
put('settings.json', '{"v":0}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
put('settings.json', '{"v":1}');
const ahead = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2 * 3_600_000 });
expect(ahead.status).toBe('created');
put('settings.json', '{"v":2}'); // the clock is back at T0 + 1 minute
const corrected = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 60_000 });
expect(corrected.status).toBe('created');
const [newest, second] = listConfigBackups(backups);
expect(newest.id).toBe((corrected as { id: string }).id); // dated no earlier than the one before it
expect(newest.createdAt).toBeGreaterThan(second.createdAt);
for (let i = 0; i < 24; i++) {
const r = createConfigBackup({
dataDir: data,
backupDir: backups,
reason: 'auto',
now: T0 + 120_000 + i * 300_000,
});
expect(r.status, `pass ${i}`).toBe('unchanged');
}
expect(listConfigBackups(backups)).toHaveLength(3);
});
});
describe('instances sharing one backup folder', () => {
it('keeps to its own snapshots: no churn, no prune, no restore of the other', () => {
const other = join(root, 'other-data');
mkdirSync(other, { recursive: true });
put('settings.json', '{"a":1}');
writeFileSync(join(other, 'settings.json'), '{"b":2}');
const mine = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
const theirs = createConfigBackup({ dataDir: other, backupDir: backups, reason: 'auto', now: T0 + 1000 });
expect(theirs.status).toBe('created');
// Each side's change check compares with its own newest snapshot, so a second tick is a no-op.
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
'unchanged'
);
expect(createConfigBackup({ dataDir: other, backupDir: backups, reason: 'auto', now: T0 + 3000 }).status).toBe(
'unchanged'
);
expect(listConfigBackups(backups, configBackupOrigin(data))).toHaveLength(1);
expect(
pruneConfigBackups(backups, { keepCount: 1, keepDays: 1, now: T0 + 999 * DAY, origin: configBackupOrigin(data) })
).toEqual([]);
expect(listConfigBackups(backups)).toHaveLength(2);
const theirId = (theirs as { id: string }).id;
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: theirId })).toThrow(
ConfigBackupNotFoundError
);
expect((mine as { id: string }).id).not.toBe(theirId);
});
it('a restore reports tracked files it left alone, and sweeps only old temp folders', () => {
put('settings.json', '{"a":1}');
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
put('webhook.json', '{"url":"u"}');
expect(restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id }).untouched).toEqual(['webhook.json']);
const stale = join(backups, '.tmp-cfg-20200101-000000-99999');
mkdirSync(stale);
sweepConfigBackupTemps(backups, Date.now() + 2 * 3_600_000);
expect(existsSync(stale)).toBe(false);
});
});
describe('restore trusts only what this module wrote', () => {
const made = () =>
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
it('refuses a symlinked snapshot file and a snapshot file with group or world bits', () => {
put('settings.json', '{"a":1}');
put('clis.json', '{"clis":{}}');
const { id } = made();
const file = join(backups, id, 'clis.json');
chmodSync(file, 0o644);
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(ConfigBackupCorruptError);
chmodSync(file, 0o600);
const real = join(root, 'elsewhere.json');
writeFileSync(real, '{"clis":{}}');
rmSync(file);
symlinkSync(real, file);
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(/symlink/);
// nothing was written and no pre-restore copy was taken for a refused restore
expect(listConfigBackups(backups)).toHaveLength(1);
});
it('skips the group/world bit test on a filesystem that does not keep modes, but never the symlink test', () => {
put('settings.json', '{"a":1}');
put('clis.json', '{"clis":{}}');
const { id } = made();
const file = join(backups, id, 'clis.json');
chmodSync(file, 0o644); // what vfat, CIFS and WSL /mnt/d report for every file
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id, keepsModes: false })).not.toThrow();
const real = join(root, 'elsewhere.json');
writeFileSync(real, '{"clis":{}}');
rmSync(file);
symlinkSync(real, file);
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id, keepsModes: false })).toThrow(/symlink/);
});
it('the mode probe never writes through a planted symlink or trips over a planted directory', () => {
put('settings.json', '{"a":1}');
const { id } = made();
const target = join(root, 'victim.txt');
writeFileSync(target, 'precious', { mode: 0o644 });
symlinkSync(target, join(backups, `.mode-probe-${process.pid}`));
mkdirSync(join(backups, '.mode-probe-0'));
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).not.toThrow();
expect(() => diffConfigBackups({ dataDir: data, backupDir: backups, from: id, to: 'current' })).not.toThrow();
expect(readFileSync(target, 'utf8')).toBe('precious');
expect(statSync(target).mode & 0o777).toBe(0o644);
});
it('compare applies the same check to a snapshot file', () => {
put('settings.json', '{"a":1}');
const { id } = made();
chmodSync(join(backups, id, 'settings.json'), 0o644);
expect(() => diffConfigBackups({ dataDir: data, backupDir: backups, from: id, to: 'current' })).toThrow(
ConfigBackupCorruptError
);
});
it('refuses a snapshot folder that others can write to', () => {
put('settings.json', '{"a":1}');
const { id } = made();
chmodSync(join(backups, id), 0o777);
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(ConfigBackupCorruptError);
chmodSync(join(backups, id), 0o700);
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).not.toThrow();
});
it('keeps the backup retention when the live settings.json was reset and has none of the four keys', () => {
put('settings.json', JSON.stringify({ displayName: 'old', configBackupKeepCount: 100, configBackupKeepDays: 0 }));
const { id } = made();
put('settings.json', '{}');
restoreConfigBackup({ dataDir: data, backupDir: backups, id });
expect(JSON.parse(readFileSync(join(data, 'settings.json'), 'utf8'))).toEqual({
displayName: 'old',
configBackupKeepCount: 100,
configBackupKeepDays: 0,
});
});
it('latestBackupRetention skips a newer snapshot that recorded none, and a pre-restore copy records it', () => {
put('settings.json', '{"a":1}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0, keepCount: 100, keepDays: 0 });
put('settings.json', '{"a":2}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }); // an older build
const origin = configBackupOrigin(data);
expect(latestBackupRetention(backups, origin)).toEqual({ keepCount: 100, keepDays: 0 });
const id = listConfigBackups(backups, origin).at(-1)!.id;
// A change since the newest backup, so the restore makes a pre-restore copy rather than reusing it.
put('settings.json', '{"a":3}');
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id, now: T0 + 2000 });
expect(r.safetySnapshot).toBeTruthy();
const pre = listConfigBackups(backups, origin).find((b) => b.id === r.safetySnapshot)!;
expect(pre.reason).toBe('pre-restore');
expect(JSON.parse(readFileSync(join(backups, pre.id, 'manifest.json'), 'utf8'))).toMatchObject({
keepCount: 100,
keepDays: 0,
});
});
it('keeps the current folder and retention when settings.json is restored', () => {
put('settings.json', JSON.stringify({ displayName: 'old', configBackupDir: '/old', configBackupKeepCount: 5 }));
const { id } = made();
put('settings.json', JSON.stringify({ displayName: 'new', configBackupDir: '/new', configBackupKeepDays: 7 }));
restoreConfigBackup({ dataDir: data, backupDir: backups, id });
const after = JSON.parse(readFileSync(join(data, 'settings.json'), 'utf8'));
// the folder and days follow the live file; the live file has no keep count (reset), so the backup's 5 stays
expect(after).toEqual({
displayName: 'old',
configBackupDir: '/new',
configBackupKeepCount: 5,
configBackupKeepDays: 7,
});
});
});
+95
View File
@@ -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);
});
});
+278
View File
@@ -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);
});
});
+195 -5
View File
@@ -15,7 +15,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getDataDir } from '../src/config/instance.js';
import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
import { refreshAllCustomModelHosts } from '../src/web/routes/custom-model-routes.js';
import { clearPropsProbeCache, refreshAllCustomModelHosts } from '../src/web/routes/custom-model-routes.js';
import { webviewFetch } from '../src/web/webview-egress.js';
vi.mock('../src/web/webview-egress.js', async () => {
@@ -31,6 +31,7 @@ function host(overrides: Partial<CustomModelHost> & Pick<CustomModelHost, 'id' |
beforeEach(() => {
fetchMock.mockReset();
clearPropsProbeCache();
});
describe('refreshAllCustomModelHosts (the periodic re-discovery sweep)', () => {
@@ -211,8 +212,9 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama
it('prefers the REAL configured context size parsed from /running’s launch command over /props’s unreliable n_ctx', async () => {
// Confirmed live: llama-swap launched a model with --fit-ctx 16384 (the real, working
// limit — the actual server then refused a request over it), but /props reported
// n_ctx: 154112 for the same model, well over what it would really accept. /props must
// never be reached at all once the /running command parse already answered it.
// n_ctx: 154112 for the same model, well over what it would really accept. The context
// must therefore keep coming from the /running command; /props is now also probed, but
// only for `total_slots`, which the command does not state.
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async (url: URL) => {
@@ -235,7 +237,9 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama
{ status: 200 }
);
}
if (url.pathname === '/props') throw new Error('must never be reached — the cmd parse already answered it');
if (url.pathname === '/props') {
return new Response(JSON.stringify({ n_ctx: 154112, total_slots: 4 }), { status: 200 });
}
throw new Error(`unexpected request: ${url.href}`);
});
@@ -243,6 +247,8 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelContextLengths).toEqual({ 'qwen3.8-27b': 16384 });
expect(updated.modelSlotCounts).toEqual({ 'qwen3.8-27b': 4 });
// /props's 154112 never becomes the context, and nothing else stores it
});
it('falls back to /props when /running has no cmd, or the cmd states no recognizable context flag', async () => {
@@ -283,13 +289,197 @@ 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 });
});
});
+67 -1
View File
@@ -1281,7 +1281,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 +1289,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 +1384,49 @@ describe('Custom Model Endpoint Profiles: _confirmContextWarning (in-app modal,
expect(win.document.getElementById('customModelContextWarningModal')!.classList.contains('active')).toBe(false);
});
it('explains shared parallel slots, and the --parallel 1 fix, when the context is enough only as a whole', async () => {
const { win, app } = bootApp({});
void app._confirmContextWarning('qwen3.8-27b-ud-q4_k_xl', 131072, 40000, 4);
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
expect(message).toContain('qwen3.8-27b-ud-q4_k_xl');
expect(message).toContain('4 parallel');
expect(message).toContain('131,072');
expect(message).toContain('32,768'); // per slot
expect(message).toContain('--parallel 1');
expect(message).toContain('Context size has been exceeded');
// this is not the "first message fails outright" case, so it must not claim that
expect(message).not.toMatch(/first message will fail/i);
expect(win.document.getElementById('customModelContextWarningModal')!.classList.contains('active')).toBe(true);
app._resolveContextWarningConfirm(false);
});
it('says the context is split across the slots, about N each, from the discovered context alone', async () => {
const { win, app } = bootApp({});
void app._confirmContextWarning('qwen3', 65536, 40000, 4);
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
expect(message).toContain('65,536 tokens of context are split across 4 parallel server slots, about 16,384 each');
app._resolveContextWarningConfirm(false);
});
it('keeps the too-small-context message for a small model and adds the slot advice when slots are known', async () => {
const { win, app } = bootApp({});
void app._confirmContextWarning('qwen3', 16384, 40000, 4);
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
expect(message).toContain('16,384');
expect(message).toMatch(/first message will fail/i);
expect(message).toContain('4 parallel slots');
expect(message).toContain('--parallel 1');
app._resolveContextWarningConfirm(false);
});
it('does not mention slots when there is one (or none known)', async () => {
const { win, app } = bootApp({});
void app._confirmContextWarning('qwen3', 16384, 40000, 1);
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
expect(message).not.toMatch(/parallel/i);
app._resolveContextWarningConfirm(false);
});
it('describes an unknown context length without printing a bogus number', async () => {
const { win, app } = bootApp({});
void app._confirmContextWarning('qwen3', undefined, 40000);
+80
View File
@@ -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);
});
});
+388
View File
@@ -0,0 +1,388 @@
/**
* @fileoverview File Viewer window: back/forward between previews, expand, resize.
*
* Links between documents open the next file in the same overlay with no way back.
* Pinned here:
* 1. jumping between files fills a back/forward list and the buttons reveal,
* 2. back/forward reopen the right file WITHOUT recording a new entry,
* 3. opening a file mid-list drops the forward entries,
* 4. reopening the file already showing (MD/Page toggles) adds nothing,
* 5. closing the preview clears the list and hides the buttons,
* 6. unsaved edits ask before leaving,
* 7. Expand toggles the window class and the pref, and is re-applied on open,
* 8. the markup carries the buttons and the resize grip,
* 9. stepping onto a folder-grid page moves the grid's own index with it,
* 10. Alt+←/→ steps, ignores other chords and text fields, and is swallowed at the ends,
* 11. the corner grip doubles the pointer delta and clamps the size,
* 12. phones ignore Expand and the grip (mobile.css).
*
* Loaded via `vm` against a stub app, same harness style as file-preview-detach.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 indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
function btn() {
const attrs: Record<string, string> = {};
return {
hidden: true,
disabled: true,
title: '',
setAttribute: (k: string, v: string) => (attrs[k] = v),
attrs,
};
}
function loadApp(confirmResult = true) {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, unknown>;
const store: Record<string, string> = {};
const context = vm.createContext({
CodemanApp,
console: { ...console, warn: vi.fn(), error: vi.fn() },
localStorage: {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => (store[k] = v),
removeItem: () => {},
},
escapeHtml: (s: string) => String(s),
CodemanBase: { base: '', url: (p: string) => p },
rendersAsPage: () => false,
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn(), open: vi.fn(), innerWidth: 1000, innerHeight: 800 },
setTimeout,
clearTimeout,
confirm: vi.fn(() => confirmResult),
fetch: () => {
throw new Error('fetch not stubbed');
},
});
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
const winClasses = new Set<string>();
const windowEl = {
classList: {
toggle: (c: string, on: boolean) => (on ? winClasses.add(c) : winClasses.delete(c)),
contains: (c: string) => winClasses.has(c),
},
};
const overlayClasses = new Set<string>(['visible']);
const overlay = {
classList: {
add: (c: string) => overlayClasses.add(c),
remove: (c: string) => overlayClasses.delete(c),
contains: (c: string) => overlayClasses.has(c),
},
querySelector: () => windowEl,
};
const back = btn();
const fwd = btn();
const expand = btn();
const elements: Record<string, unknown> = {
filePreviewBody: { innerHTML: '', querySelectorAll: () => [], querySelector: () => null },
filePreviewOverlay: overlay,
filePreviewTitle: { textContent: '' },
filePreviewFooter: { textContent: '' },
filePreviewDetachBtn: { hidden: true },
filePreviewBackBtn: back,
filePreviewForwardBtn: fwd,
filePreviewExpandBtn: expand,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const app = new CodemanApp() as Record<string, any>;
app.$ = (id: string) => elements[id] ?? null;
app._resetFilePreviewEdit = () => {};
app._isExternalPreviewPath = () => false;
app.showToast = vi.fn();
app.filePreviewContent = '';
const confirmFn = (context as { confirm: ReturnType<typeof vi.fn> }).confirm;
const keydown = () => {
const calls = (context as { document: { addEventListener: ReturnType<typeof vi.fn> } }).document.addEventListener
.mock.calls;
return calls.find((c: unknown[]) => c[0] === 'keydown')?.[1] as (ev: unknown) => void;
};
return { app, overlay, back, fwd, expand, winClasses, store, confirmFn, elements, overlayClasses, keydown };
}
describe('file viewer back/forward', () => {
it('stays hidden for one file and reveals once a second one is opened', async () => {
const { app, back, fwd } = loadApp();
await app.openFilePreview('/ws/a.pdf', 's1');
expect(back.hidden).toBe(true);
await app.openFilePreview('/ws/b.pdf', 's1');
expect(back.hidden).toBe(false);
expect(back.disabled).toBe(false);
expect(fwd.hidden).toBe(false);
expect(fwd.disabled).toBe(true);
});
it('steps back and forward without recording the step as a new entry', async () => {
const { app, back, fwd } = loadApp();
await app.openFilePreview('/ws/a.pdf', 's1');
await app.openFilePreview('/ws/b.pdf', 's1');
await app.openFilePreview('/ws/c.pdf', 's1');
await app.stepFilePreviewHistory(-1);
expect(app.filePreviewHistory.entries.map((e: { filePath: string }) => e.filePath)).toEqual([
'/ws/a.pdf',
'/ws/b.pdf',
'/ws/c.pdf',
]);
expect(app.filePreviewHistory.index).toBe(1);
expect(app.filePreviewDetachUrl).toContain(encodeURIComponent('/ws/b.pdf'));
expect(fwd.disabled).toBe(false);
await app.stepFilePreviewHistory(-1);
expect(back.disabled).toBe(true);
await app.stepFilePreviewHistory(-1); // at the start: a no-op
expect(app.filePreviewHistory.index).toBe(0);
await app.stepFilePreviewHistory(1);
expect(app.filePreviewHistory.index).toBe(1);
});
it('drops the forward entries when a new file is opened mid-list', async () => {
const { app } = loadApp();
await app.openFilePreview('/ws/a.pdf', 's1');
await app.openFilePreview('/ws/b.pdf', 's1');
await app.stepFilePreviewHistory(-1);
await app.openFilePreview('/ws/c.pdf', 's1');
expect(app.filePreviewHistory.entries.map((e: { filePath: string }) => e.filePath)).toEqual([
'/ws/a.pdf',
'/ws/c.pdf',
]);
expect(app.filePreviewHistory.index).toBe(1);
});
it('does not add an entry when the file already showing is reopened', async () => {
const { app } = loadApp();
await app.openFilePreview('/ws/a.pdf', 's1');
await app.openFilePreview('/ws/a.pdf', 's1');
expect(app.filePreviewHistory.entries).toHaveLength(1);
});
it('clears the list and hides the buttons on close', async () => {
const { app, back, fwd } = loadApp();
await app.openFilePreview('/ws/a.pdf', 's1');
await app.openFilePreview('/ws/b.pdf', 's1');
app.closeFilePreview();
expect(app.filePreviewHistory).toBeNull();
expect(back.hidden).toBe(true);
expect(fwd.hidden).toBe(true);
});
it('asks before leaving unsaved edits and stays put when declined', async () => {
const { app, confirmFn } = loadApp(false);
await app.openFilePreview('/ws/a.pdf', 's1');
await app.openFilePreview('/ws/b.pdf', 's1');
app.filePreviewEdit = { dirty: true };
await app.stepFilePreviewHistory(-1);
expect(confirmFn).toHaveBeenCalled();
expect(app.filePreviewHistory.index).toBe(1);
});
});
describe('file viewer expand', () => {
it('toggles the window class, the pref and the button state', () => {
const { app, winClasses, store, expand } = loadApp();
// As index.html ships it.
expand.title = 'Expand';
expand.attrs['aria-label'] = 'Expand';
app.toggleFilePreviewExpanded();
expect(winClasses.has('expanded')).toBe(true);
expect(store['codeman:filePreviewExpanded']).toBe('1');
expect(expand.attrs['aria-pressed']).toBe('true');
// aria-pressed carries the state; the label stays fixed, like the MD/Lines/Wrap toggles.
expect(expand.title).toBe('Expand');
expect(expand.attrs['aria-label']).toBe('Expand');
app.toggleFilePreviewExpanded();
expect(winClasses.has('expanded')).toBe(false);
expect(expand.attrs['aria-pressed']).toBe('false');
expect(expand.title).toBe('Expand');
});
it('re-applies a remembered expanded state when a preview opens', async () => {
const { app, winClasses, store } = loadApp();
store['codeman:filePreviewExpanded'] = '1';
await app.openFilePreview('/ws/a.pdf', 's1');
expect(winClasses.has('expanded')).toBe(true);
});
});
describe('file viewer window markup', () => {
it('carries the back, forward and expand buttons and the resize grip', () => {
for (const id of ['filePreviewBackBtn', 'filePreviewForwardBtn', 'filePreviewExpandBtn', 'filePreviewResize']) {
expect(indexHtml).toContain(`id="${id}"`);
}
expect(indexHtml).toContain('app.stepFilePreviewHistory(-1)');
expect(indexHtml).toContain('app.stepFilePreviewHistory(1)');
});
});
describe('file viewer folder paging in the history', () => {
it('moves the grid index with a back step, so the next page continues from there', async () => {
const { app } = loadApp();
const names = ['a', 'b', 'c', 'd'].map((n) => ({ name: `${n}.pdf`, size: 1 }));
app.folderGrid = {
folderId: 'f1',
path: '/ws',
sessionId: 's1',
files: names,
folders: [],
index: null,
lastIndex: null,
};
app._openFolderGridFile(0);
app._openFolderGridFile(1);
app._openFolderGridFile(2);
await app.stepFilePreviewHistory(-1);
expect(app.folderGrid.index).toBe(1);
expect(app.folderGrid.lastIndex).toBe(1);
app.stepFolderPreview(1);
const paths = app.filePreviewHistory.entries.map((e: { filePath: string }) => e.filePath);
expect(paths).toEqual(['/ws/a.pdf', '/ws/b.pdf', '/ws/c.pdf']);
expect(app.folderGrid.index).toBe(2);
});
it('falls back to a plain open when the entry is from another folder', async () => {
const { app } = loadApp();
app.folderGrid = {
folderId: 'f1',
path: '/ws',
sessionId: 's1',
files: [{ name: 'a.pdf', size: 1 }],
folders: [],
index: 0,
lastIndex: 0,
};
await app.openFilePreview('/other/x.pdf', 's1', null, { folderFile: { folderId: 'f2', name: 'x.pdf' } });
await app.openFilePreview('/ws/y.pdf', 's1');
await app.stepFilePreviewHistory(-1);
expect(app.filePreviewHistory.index).toBe(0);
expect(app.filePreviewHistory.entries).toHaveLength(2);
});
});
describe('file viewer Alt+arrow chord', () => {
const key = (over: Record<string, unknown> = {}) => ({
altKey: true,
ctrlKey: false,
metaKey: false,
shiftKey: false,
key: 'ArrowLeft',
target: { tagName: 'DIV' },
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...over,
});
async function setup() {
const h = loadApp();
await h.app.openFilePreview('/ws/a.pdf', 's1');
await h.app.openFilePreview('/ws/b.pdf', 's1');
h.app._bindFilePreviewChrome();
const step = vi.spyOn(h.app, 'stepFilePreviewHistory');
return { ...h, step, fire: h.keydown() };
}
it('steps back and forward and swallows the chord', async () => {
const { fire, step } = await setup();
const ev = key();
fire(ev);
expect(step).toHaveBeenCalledWith(-1);
expect(ev.preventDefault).toHaveBeenCalled();
expect(ev.stopPropagation).toHaveBeenCalled();
});
it('swallows the chord at the ends of the list without stepping', async () => {
const { fire, step } = await setup();
const fwd = key({ key: 'ArrowRight' }); // already at the newest entry
fire(fwd);
expect(step).not.toHaveBeenCalled();
expect(fwd.preventDefault).toHaveBeenCalled();
expect(fwd.stopPropagation).toHaveBeenCalled();
});
it.each([
['a plain arrow', { altKey: false }],
['Shift+Alt', { shiftKey: true }],
['Ctrl+Alt', { ctrlKey: true }],
['another key', { key: 'ArrowUp' }],
['a textarea target', { target: { tagName: 'TEXTAREA', classList: { contains: () => false } } }],
])('leaves %s alone', async (_name, over) => {
const { fire, step } = await setup();
const ev = key(over);
fire(ev);
expect(step).not.toHaveBeenCalled();
expect(ev.preventDefault).not.toHaveBeenCalled();
});
it('leaves the chord alone while the overlay is hidden', async () => {
const { fire, step, overlayClasses } = await setup();
overlayClasses.delete('visible');
const ev = key();
fire(ev);
expect(step).not.toHaveBeenCalled();
expect(ev.preventDefault).not.toHaveBeenCalled();
});
});
describe('file viewer resize grip', () => {
function drag(to: [number, number], button = 0) {
const { app, elements } = loadApp();
const listeners: Record<string, (e: unknown) => void> = {};
const win = { offsetWidth: 400, offsetHeight: 300, style: {} as Record<string, string> };
elements.filePreviewResize = {
parentElement: win,
setPointerCapture: () => {},
addEventListener: (t: string, fn: (e: unknown) => void) => (listeners[t] = fn),
removeEventListener: () => {},
};
app._bindFilePreviewResize();
listeners.pointerdown({ preventDefault: () => {}, clientX: 100, clientY: 100, pointerId: 1, button });
if (!listeners.pointermove) return win.style; // a refused pointerdown binds no move handler
listeners.pointermove({ clientX: 100 + to[0], clientY: 100 + to[1] });
return win.style;
}
it('ignores a pointerdown that is not the primary button', () => {
expect(drag([100, 50], 2)).toEqual({});
});
it('doubles the pointer delta, because the window is centred', () => {
expect(drag([100, 50])).toMatchObject({ width: '600px', height: '400px' });
});
it('clamps to the minimum and to 96% x 94% of the viewport', () => {
expect(drag([-500, -500])).toMatchObject({ width: '320px', height: '240px' });
expect(drag([900, 900])).toMatchObject({ width: '960px', height: '752px' });
});
});
describe('file viewer on phones', () => {
it('overrides Expand and any dragged size, and hides both controls, under 600px', () => {
const css = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8');
const phone = css.slice(css.indexOf('@media (max-width: 599px)'));
expect(phone).toMatch(
/\.file-preview-window,\s*\.file-preview-window\.expanded\s*\{[^}]*width: 100vw !important;[^}]*height: var\(--app-height, 100vh\) !important;/
);
expect(phone).toMatch(/#filePreviewExpandBtn,\s*\.file-preview-resize\s*\{\s*display: none;/);
});
});
+1
View File
@@ -51,6 +51,7 @@ describe('help modal shortcuts', () => {
expect(helpModal).not.toMatch(/Ctrl<\/kbd>\s*\+\s*<kbd>\/<\/kbd>.*?Show Shortcuts/i);
expectShortcut(helpModal, ['Ctrl', 'Shift', 'V'], 'Voice Input');
expectShortcut(helpModal, ['Escape'], 'Close Panels');
expectShortcut(helpModal, ['Alt/Option', 'ArrowLeft'], 'File Viewer Back / Forward');
});
it('documents the tile grid chords', () => {
+126
View File
@@ -0,0 +1,126 @@
// Port: none (handlers sliced out of the shipped frontend files and run against stubs).
//
// The prompt-cache keep-alive turn (prompt-cache-keepwarm.ts) is Codeman's, not the user's, so
// on the page it must spend nothing: an idle alert still pending stays (only a human spends
// one), no "Response Complete" is raised, and the stuck-detection timer neither restarts nor
// re-arms ("Session Idle 10+ minutes" would otherwise follow every nudge). The server marks the
// turn's `hook:stop` with `acknowledgedReason` and its `session:working`/`session:idle` with
// `keepAlive`; these tests run the real handlers on those payloads.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const SESSION = 'keep-alive-session';
const THRESHOLD = 600_000;
/** The body of a method as shipped, compiled with `data` as its one parameter. */
function method(file: string, signature: string): (this: unknown, data: Record<string, unknown>) => void {
const source = readFileSync(resolve(PUBLIC, file), 'utf8');
const start = source.indexOf(` ${signature} {`);
expect(start).toBeGreaterThan(-1);
const bodyStart = source.indexOf('{', start) + 1;
const end = source.indexOf('\n }', bodyStart);
return new Function('data', source.slice(bodyStart, end)) as (data: Record<string, unknown>) => void;
}
function makeApp() {
const notifications: string[] = [];
const app = {
sessions: new Map([[SESSION, { status: 'idle' }]]),
activeSessionId: 'another-session',
respawnStatus: {},
notificationManager: { preferences: { stuckThresholdMs: THRESHOLD } },
idleTimers: new Map<string, unknown>(),
pendingHooks: new Map<string, Set<string>>(),
tabAlerts: new Map<string, string>(),
notifications,
renderSessionTabs() {},
sendPendingCtrlL() {},
_updateLocalEchoState() {},
_notifySession: (_id: string, _level: string, kind: string) => notifications.push(kind),
clearPendingHooks(sessionId: string) {
app.pendingHooks.delete(sessionId);
app.tabAlerts.delete(sessionId);
},
onIdle: method('app.js', '_onSessionIdle(data)'),
onWorking: method('app.js', '_onSessionWorking(data)'),
onStop: method('settings-ui.js', '_onHookStop(data)'),
};
return app;
}
describe('the keep-alive turn spends nothing on the page', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('keeps the stuck timer of the last real turn: not cleared, not re-armed', () => {
const app = makeApp();
app.onIdle({ id: SESSION });
const realTimer = app.idleTimers.get(SESSION);
expect(realTimer).toBeDefined();
vi.advanceTimersByTime(THRESHOLD / 2);
app.onWorking({ id: SESSION, keepAlive: true });
expect(app.idleTimers.get(SESSION)).toBe(realTimer);
app.onIdle({ id: SESSION, keepAlive: true });
expect(app.idleTimers.get(SESSION)).toBe(realTimer);
// Fires once, on the real turn's schedule, and nothing follows the keep-alive.
vi.advanceTimersByTime(THRESHOLD / 2);
expect(app.notifications).toEqual(['session-stuck']);
vi.advanceTimersByTime(THRESHOLD * 2);
expect(app.notifications).toEqual(['session-stuck']);
});
it('a real turn still restarts the stuck timer', () => {
const app = makeApp();
app.onIdle({ id: SESSION });
app.onWorking({ id: SESSION });
expect(app.idleTimers.has(SESSION)).toBe(false);
app.onIdle({ id: SESSION });
expect(app.idleTimers.has(SESSION)).toBe(true);
});
it("its stop leaves a pending idle alert and raises no 'Response Complete'", () => {
const app = makeApp();
app.pendingHooks.set(SESSION, new Set(['idle_prompt']));
app.tabAlerts.set(SESSION, 'idle');
app.onStop({ sessionId: SESSION, acknowledgedReason: 'cache keep-alive' });
expect([...(app.pendingHooks.get(SESSION) ?? [])]).toEqual(['idle_prompt']);
expect(app.tabAlerts.get(SESSION)).toBe('idle');
// _onSessionWorking deletes the tab alert only with no pending hook, so it stays too.
app.onWorking({ id: SESSION, keepAlive: true });
expect(app.tabAlerts.get(SESSION)).toBe('idle');
expect(app.notifications).toEqual([]);
});
it('a real stop still clears and notifies', () => {
const app = makeApp();
app.pendingHooks.set(SESSION, new Set(['idle_prompt']));
app.onStop({ sessionId: SESSION });
expect(app.pendingHooks.has(SESSION)).toBe(false);
expect(app.notifications).toEqual(['hook-stop']);
});
});
/**
* `promptCacheKeepWarmMaxHours` is clamped to the settings schema's range (0.5 to 24) before the
* save: the schema is strict, so one value outside it would reject every other change in the body.
*/
describe('the keep-warm cap is clamped before the save', () => {
it.each([
['0.25', 0.5],
['30', 24],
['', 2],
['abc', 2],
['3.5', 3.5],
])('%j saves as %d', (input, expected) => {
const source = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8');
const match = source.match(/promptCacheKeepWarmMaxHours:\s*\n?((?:\s*\/\/.*\n)*)\s*([^\n]+),\n/);
expect(match).not.toBeNull();
const document = { getElementById: () => ({ value: input }) };
const value = new Function('document', `return ${match![2]};`)(document);
expect(value).toBe(expected);
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* @fileoverview The prompt-cache keep-warm rows (Session Options → Respawn switch, App Settings
* → Automation default and cap) in Chinese (zh-CN): each label and description runs through
* the real translator over the real index.html in JSDOM and reads in Chinese with no English
* left beyond the product names, and every new key, the toggle toasts included, is 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');
type Api = { configure(o: Record<string, string>): void; start(): void };
const IDS = ['modalKeepWarmEnabled', 'appSettingsPromptCacheKeepWarm', 'appSettingsPromptCacheKeepWarmMaxHours'];
/** What may stay Latin: the product names. */
const leftover = (text: string) =>
text.replace(/\b(Claude Code|Claude|Codeman|Ralph)\b/g, '').match(/[A-Za-z]+/g) ?? [];
function rowStrings(doc: Document, id: string): { 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 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 keep-warm rows in zh-CN', () => {
it.each(IDS)('translates the %s row, with no English left', (id) => {
const english = rowStrings(source.window.document, id);
const chinese = rowStrings(dom.window.document, id);
expect(english.label.length).toBeGreaterThan(0);
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, the toasts included', () => {
const texts = IDS.flatMap((id) => Object.values(rowStrings(source.window.document, id)));
texts.push('hours', 'Prompt-cache keep-warm enabled', 'Prompt-cache keep-warm disabled');
for (const text of texts) {
const quoted = I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2;
const bare = /^\w+$/.test(text) ? I18N.split(`\n ${text}: `).length - 1 : 0;
expect(quoted + bare, text).toBe(1);
}
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* @fileoverview The launch half of the prompt-cache keep-warm (Discussion #554): which launch
* carries `idleCompaction: false` (decideIdleCompactionOptOut, tmux-manager.ts), that every
* create and relaunch path decides it and records it on the mux record, that the session hands
* its choice to both, that a restart keeps the choice and the real-prompt clock, that the app
* settings read defaults to OFF and a 2 h cap, and that Read My Mind's capture runs through the
* filter that drops the nudge. The argv shape itself is pinned in cli-registry-spawn-golden.
*
* Port: N/A (pure functions and source slices; no server).
*/
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { describe, it, expect, afterEach } from 'vitest';
import { decideIdleCompactionOptOut } from '../src/tmux-manager.js';
import { readKeepWarmSettings, readKeepWarmSettingsOrThrow } from '../src/hooks-config.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
import { DEFAULT_KEEP_WARM_MAX_HOURS } from '../src/prompt-cache-keepwarm.js';
const src = (file: string) => readFileSync(resolve(import.meta.dirname, '../src', file), 'utf8');
/** The body of `signature` up to the next class member, so a pin cannot pass on a sibling. */
function methodSlice(source: string, signature: string): string {
const start = source.indexOf(signature);
expect(start, `${signature} not found`).toBeGreaterThan(-1);
const next = source
.slice(start + signature.length)
.search(/\n {2}(?:(?:private|public|protected|static|async|get|set) )*\w+\(/);
return source.slice(start, next === -1 ? undefined : start + signature.length + next);
}
describe('decideIdleCompactionOptOut', () => {
const on = {
mode: 'claude' as const,
statusLine: 'injected' as const,
keepWarm: true,
keepWarmDefault: false,
spawnedByAgent: false,
claudeCliVersion: '2.1.290',
};
it('adds the key when keep-warm is on, the exporter is injected and Claude Code knows the setting', () => {
expect(decideIdleCompactionOptOut(on)).toBe(true);
expect(decideIdleCompactionOptOut({ ...on, claudeCliVersion: '2.1.300' })).toBe(true);
expect(decideIdleCompactionOptOut({ ...on, keepWarm: undefined, keepWarmDefault: true })).toBe(true);
});
it.each<[string, Partial<typeof on> & Record<string, unknown>]>([
['the version is unknown (fail-closed, like --name)', { claudeCliVersion: null }],
['2.1.289, which idle-compacts but has no idleCompaction setting', { claudeCliVersion: '2.1.289' }],
['keep-warm is off for the session', { keepWarm: false, keepWarmDefault: true }],
['keep-warm is left to an app default that is off', { keepWarm: undefined }],
[
'an agent-spawned session on the app default',
{ keepWarm: undefined, keepWarmDefault: true, spawnedByAgent: true },
],
['Plan Usage was off at launch (keep-warm cannot run)', { statusLine: 'collection-off' }],
["the workspace's own statusLine (keep-warm cannot run)", { statusLine: 'workspace-statusline' }],
['a remote or Docker launch', { statusLine: 'remote' }],
['a CLI with no exporter at all', { statusLine: undefined }],
['a CLI that declares no such gate', { mode: 'codex' }],
])('leaves it off when %s', (_label, over) => {
expect(decideIdleCompactionOptOut({ ...on, ...over } as Parameters<typeof decideIdleCompactionOptOut>[0])).toBe(
false
);
});
});
describe('every launch decides the opt-out and records it', () => {
const tmux = src('tmux-manager.ts');
it.each(['async createSession(', 'async respawnPane('])('%s resolves it, renders it and records it', (sig) => {
const body = methodSlice(tmux, sig);
expect(body).toContain(
'const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);'
);
expect(body).toMatch(/statusLineCommand,\n\s+idleCompactionOff,\n\s+sessionName: cliName,/);
});
it('createSession records it on the new mux record, next to statusLine', () => {
expect(methodSlice(tmux, 'async createSession(')).toMatch(/name,\n\s+statusLine,\n\s+idleCompactionOff,\n\s+};/);
});
it('respawnPane re-records it on the surviving mux record', () => {
expect(methodSlice(tmux, 'async respawnPane(')).toContain('session.idleCompactionOff = idleCompactionOff;');
});
it('the slice stops at the next member, private async ones included', () => {
expect(methodSlice(tmux, 'async createSession(')).not.toContain('async respawnPane(');
expect(methodSlice(tmux, 'private async _resolveStatusLine(')).not.toContain('_resolveIdleCompactionOptOut(\n');
});
it('the session hands its choice to the create AND the relaunch option bags', () => {
const session = src('session.ts');
const handoff = /keepWarm: this\._keepWarmEnabled,\n\s+spawnedByAgent: !!this\._parentSessionId,/;
expect(session.match(new RegExp(handoff, 'g'))).toHaveLength(2);
expect(methodSlice(session, 'private _buildRespawnPaneOptions(')).toMatch(handoff);
expect(methodSlice(session, 'async startInteractive(')).toMatch(handoff);
});
});
describe('restart keeps the choice and the real-prompt clock', () => {
it('both constructors that rebuild a session from state.json pass them', () => {
for (const file of ['web/server.ts', 'web/routes/reboot-restore-routes.ts']) {
const source = src(file);
expect(source, file).toMatch(/lastRealPromptAt: saved(State\?)?\.lastRealPromptAt,/);
expect(source, file).toMatch(/keepWarmEnabled: saved(State\?)?\.keepWarmEnabled,/);
}
});
});
describe('the pane read behind the last two guards', () => {
it('reads the usage-limit message off the same frame and counts only a reset still ahead', () => {
const port = src('web/server.ts');
const at = port.indexOf('readPane: (view) => {');
expect(at).toBeGreaterThan(-1);
const body = port.slice(at, port.indexOf('publish: (id, status) => {', at));
expect(body).toContain('this.mux.capturePaneText?.(muxName, undefined, { styles: true })');
expect(body).toContain('const limit = detectUsageLimitPause(plainFrame(text));');
expect(body).toContain('limitShown: limit !== null && limit.resetAt > Date.now(),');
expect(body).toContain('composerEmpty: composerIsEmpty(text,');
});
});
describe('Read My Mind capture', () => {
it('records a transcript prompt only through the filter that drops the nudge', () => {
expect(methodSlice(src('web/server.ts'), 'private async captureIntentPrompt(')).toContain(
'intentStore.recordPrompt('
);
const record = methodSlice(src('intent-store.ts'), 'recordPrompt(');
expect(record.indexOf('if (!isCapturablePrompt(rawText)) return false;')).toBeGreaterThan(-1);
expect(src('intent-store.ts')).toContain('if (text.trim() === KEEP_WARM_NUDGE) return false;');
});
});
describe('readKeepWarmSettings', () => {
afterEach(async () => {
await rm(SETTINGS_PATH, { force: true });
});
it('reads the temp HOME the suite set up, never a real data dir', () => {
expect(SETTINGS_PATH.startsWith(join(tmpdir(), 'codeman-vitest-'))).toBe(true);
});
it('defaults to off with a 2 h cap', async () => {
expect(DEFAULT_KEEP_WARM_MAX_HOURS).toBe(2);
expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: false, maxHours: 2 });
});
it('reads the default and the cap, and ignores a cap that is not a positive number', async () => {
await mkdir(dirname(SETTINGS_PATH), { recursive: true });
await writeFile(SETTINGS_PATH, JSON.stringify({ promptCacheKeepWarm: true, promptCacheKeepWarmMaxHours: 3.5 }));
expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: true, maxHours: 3.5 });
await writeFile(SETTINGS_PATH, JSON.stringify({ promptCacheKeepWarm: 'yes', promptCacheKeepWarmMaxHours: -1 }));
expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: false, maxHours: 2 });
});
/**
* The service keeps what it reads until the next keep-warm settings change, so its reader must
* never hand back a fallback for a file it could not read: settings.json is rewritten in place
* by several routes, and defaults kept from a mid-write read would hold keep-warm off.
*/
it('the service reader rejects an unreadable file instead of falling back, and the server wires it', async () => {
expect(await readKeepWarmSettingsOrThrow()).toEqual({ keepWarmDefault: false, maxHours: 2 });
await mkdir(dirname(SETTINGS_PATH), { recursive: true });
await writeFile(SETTINGS_PATH, JSON.stringify({ promptCacheKeepWarm: true, promptCacheKeepWarmMaxHours: 3.5 }));
expect(await readKeepWarmSettingsOrThrow()).toEqual({ keepWarmDefault: true, maxHours: 3.5 });
await writeFile(SETTINGS_PATH, '{"promptCacheKeepWarm": tr');
await expect(readKeepWarmSettingsOrThrow()).rejects.toThrow();
expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: false, maxHours: 2 });
expect(src('web/server.ts')).toContain('readSettings: readKeepWarmSettingsOrThrow,');
});
});
+1 -1
View File
@@ -130,7 +130,7 @@ The unified `showKeyboard(page, height, options?)` tries Layer 1 → 2 → 3 aut
| **CDP `Input.dispatchTouchEvent`** | Chromium | `true` | Default for swipe/tap tests |
| **Synthetic `TouchEvent`** | Cross-engine | `false` | WebKit fallback, still triggers handlers |
Swipe simulation includes intermediate move points and timing to satisfy `SwipeHandler` thresholds (≥80px distance, ≤300ms duration, ≤100px vertical drift).
Swipe simulation includes intermediate move points and timing to satisfy `SwipeHandler` thresholds (≥80px distance, ≤500ms duration, ≤50px vertical drift).
### Visual Regression
+3 -3
View File
@@ -68,11 +68,11 @@ export const KEYBOARD = {
CONFIRM_TIMEOUT: 2000,
} as const;
// Swipe constants (match app.js SwipeHandler)
// Swipe constants (match SwipeHandler in mobile-handlers.js)
export const SWIPE = {
MIN_DISTANCE: 80,
MAX_TIME: 300,
MAX_VERTICAL_DRIFT: 100,
MAX_TIME: 500,
MAX_VERTICAL_DRIFT: 50,
} as const;
// Subagent window constants
+2 -2
View File
@@ -420,11 +420,11 @@ describe('Tab Navigation', () => {
expect(log).toEqual([]);
});
it('slow swipe (>300ms) does not trigger navigation', async () => {
it('slow swipe (past the time limit) does not trigger navigation', async () => {
await clearSwipeLog();
await installSwipeSpy();
// Duration exceeds MAX_TIME threshold
await swipeViaCDP(page, 'left', { distance: SWIPE.MIN_DISTANCE + 20, duration: 400 });
await swipeViaCDP(page, 'left', { distance: SWIPE.MIN_DISTANCE + 20, duration: SWIPE.MAX_TIME + 150 });
await page.waitForTimeout(200);
const log = await getSwipeLog();
expect(log).toEqual([]);
+9 -1
View File
@@ -7,5 +7,13 @@
export { MockSession, createMockSession, terminalOutputs } from './mock-session.js';
export { MockStateStore } from './mock-state-store.js';
export { waitForEvent, createDeferred, safeRmHomeTree, isUnderTestHome } from './test-helpers.js';
export {
waitForEvent,
createDeferred,
safeRmHomeTree,
isUnderTestHome,
isSandboxedHome,
assertSandboxedHome,
TEST_HOME_PREFIX,
} from './test-helpers.js';
export { createMockRouteContext, type MockRouteContext } from './mock-route-context.js';
+9
View File
@@ -56,6 +56,12 @@ export function createMockRouteContext(options?: {
runSessionDeletion: vi.fn(async (_removed, action) => action()),
runStaleSessionCleanup: vi.fn(async (_activeIds, action) => action(new Set())),
reconcileAfterRestoration: vi.fn(async () => {}),
caseDefaultGroup: vi.fn(async () => ({ groupId: null, groups: [] })),
setCaseDefaultGroup: vi.fn(async (_owner: string, _caseName: string, groupId: string | null) => ({
groupId,
groups: [],
})),
forgetCaseDefaultGroup: vi.fn(async () => {}),
},
cleanupSession: vi.fn(async () => {}),
setupSessionListeners: vi.fn(async () => {}),
@@ -83,6 +89,9 @@ export function createMockRouteContext(options?: {
restoreRespawnController: vi.fn(),
saveRespawnConfig: vi.fn(),
// -- KeepWarmPort --
keepWarm: { schedule: vi.fn(), rescheduleAll: vi.fn(), remove: vi.fn() },
// -- ConfigPort --
store: {
getConfig: vi.fn(() => ({ ralphEnabled: false, maxConcurrentSessions: 5 })),
+26 -3
View File
@@ -4,7 +4,13 @@
*/
import { EventEmitter } from 'node:events';
import { vi } from 'vitest';
import type { PromptCacheStatus, SessionAttachmentHistoryItem, SessionStatus, SessionRemote } from '../../src/types.js';
import type {
KeepWarmStatus,
PromptCacheStatus,
SessionAttachmentHistoryItem,
SessionStatus,
SessionRemote,
} from '../../src/types.js';
/**
* Enhanced mock session for testing RespawnController.
@@ -48,6 +54,21 @@ export class MockSession extends EventEmitter {
this.promptCache = status;
return changed;
}
/** Mirrors the prompt-cache keep-warm surface on Session (prompt-cache-keepwarm.ts). */
keepAliveTurnActive = false;
endKeepAliveTurn(): void {
this.keepAliveTurnActive = false;
}
keepWarmEnabled: boolean | undefined = undefined;
setKeepWarmEnabled(enabled: boolean | undefined): void {
this.keepWarmEnabled = enabled;
}
keepWarm: KeepWarmStatus | null = null;
setKeepWarmStatus(status: KeepWarmStatus): boolean {
const changed = JSON.stringify(status) !== JSON.stringify(this.keepWarm);
this.keepWarm = status;
return changed;
}
private _activeChildProcesses: { pid: number; command: string }[] = [];
ralphTracker: null = null;
writeBuffer: string[] = [];
@@ -76,8 +97,10 @@ export class MockSession extends EventEmitter {
this.claudeSessionId = newId;
}
/** Mirrors Session.markPromptSubmitted. */
markPromptSubmitted(): void {
/** Mirrors Session.markPromptSubmitted; records the hook's prompt text it was handed. */
submittedPrompts: Array<string | undefined> = [];
markPromptSubmitted(prompt?: string): void {
this.submittedPrompts.push(prompt);
this.lastSubmitAt = Date.now();
}
+56 -24
View File
@@ -3,7 +3,7 @@
*/
import { rmSync } from 'node:fs';
import { resolve } from 'node:path';
import { basename, resolve } from 'node:path';
/** Wait for an EventEmitter to emit a specific event, with timeout */
export function waitForEvent(
@@ -39,37 +39,69 @@ export function createDeferred<T = void>(): {
}
/**
* Delete a directory tree, but ONLY when it lives inside the test HOME.
*
* SAFETY (2026-08-29): `test/setup.ts` redirects `process.env.HOME` to a
* throwaway dir and `os.homedir()` follows it, so a `rmSync(CASES_DIR,
* recursive)` normally lands inside the fixture. This gate is defense in depth
* for the day that stops being true (a test that runs outside setup.ts, an
* env override that anchors a path elsewhere): it refuses to delete anything
* not under the redirected `process.env.HOME`, so the failure mode is a
* leftover temp dir rather than a deleted PRODUCTION `~/codeman-cases`.
* Lexical `resolve()` is used because the leaf often does not exist and
* `realpathSync` would throw.
* Prefix of the throwaway HOME `test/setup.ts` creates (`mkdtempSync(join(tmpdir(), 'codeman-vitest-'))`).
* Pinned by test/test-env-isolation.test.ts, so renaming one without the other fails there.
*/
export function safeRmHomeTree(path: string): void {
export const TEST_HOME_PREFIX = 'codeman-vitest-';
/**
* True only when `process.env.HOME` is the throwaway directory `test/setup.ts` made.
*
* ⚠️ This is what the helpers below hang on, and it exists because "is the path under HOME"
* is a vacuous question when setup never ran: a bare `vitest` invocation has no setupFiles,
* HOME is the developer's REAL home, and every real file is then "under the test HOME". That is
* how a bare run on 2026-10-10 deleted the real linked-cases registry through a guard that was
* written to prevent exactly that. The temp directory's name is the only thing a real home
* cannot have.
*/
export function isSandboxedHome(): boolean {
const home = process.env.HOME;
if (!home) return;
const target = resolve(path);
const root = resolve(home);
if (target === root || target.startsWith(root + '/')) {
rmSync(target, { recursive: true, force: true });
return !!home && basename(resolve(home)).startsWith(TEST_HOME_PREFIX);
}
/**
* Throw unless the suite is running under `test/setup.ts`'s temp HOME. The shared route harness
* (`createRouteTestHarness`) calls this, so a route test built on it refuses to start against a real
* `~/.codeman`. A route test that builds its own app from `createMockRouteContext` is not covered.
*/
export function assertSandboxedHome(what = 'this test'): void {
if (!isSandboxedHome()) {
throw new Error(
`${what} reads and writes Codeman's data files and must run under test/setup.ts's temp HOME ` +
`(HOME=${process.env.HOME ?? '(unset)'}). Run it with \`npm test\` or ` +
`\`npx vitest run --config config/vitest.ci.config.ts <file>\`, never with a bare vitest.`
);
}
}
/**
* True when `path` resolves strictly inside `process.env.HOME` (or to it).
* Same rationale as `safeRmHomeTree`; use for guarded non-recursive deletes
* (single files like `linked-cases.json`) so they can never touch prod state.
* Delete a directory tree, but ONLY when it lives inside the test HOME.
*
* SAFETY (2026-08-29, tightened 2026-10-12): `test/setup.ts` redirects `process.env.HOME` to a
* throwaway dir and `os.homedir()` follows it, so a `rmSync(CASES_DIR, recursive)` normally
* lands inside the fixture. This gate is defense in depth for the day that stops being true
* (a test that runs outside setup.ts, an env override that anchors a path elsewhere): it
* refuses to delete anything unless HOME IS the throwaway dir ({@link isSandboxedHome}) and the
* target is under it, so the failure mode is a leftover temp dir rather than a deleted
* PRODUCTION `~/codeman-cases`. The first version only checked "under HOME", which a run
* without setup.ts satisfied for the user's real home.
* Lexical `resolve()` is used because the leaf often does not exist and
* `realpathSync` would throw.
*/
export function safeRmHomeTree(path: string): void {
if (!isUnderTestHome(path)) return;
rmSync(resolve(path), { recursive: true, force: true });
}
/**
* True when `path` resolves strictly inside the throwaway test HOME (or to it), and false
* whenever HOME is anything else. Same rationale as `safeRmHomeTree`; use for guarded
* non-recursive deletes (single files like `linked-cases.json`) so they can never touch
* prod state.
*/
export function isUnderTestHome(path: string): boolean {
const home = process.env.HOME;
if (!home) return false;
if (!isSandboxedHome()) return false;
const root = resolve(process.env.HOME as string);
const target = resolve(path);
const root = resolve(home);
return target === root || target.startsWith(root + '/');
}
+40
View File
@@ -139,4 +139,44 @@ describe('header plan usage chip', () => {
expect(chip.innerHTML).toContain('12%');
expect(chip.title).toContain('Codex plan usage');
});
it('shows Copilot as a monthly window with request counts in the tooltip', () => {
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
app.updatePlanUsageChip({
fiveHour: { usedPercentage: 10, resetAt: 1000 },
sevenDay: { usedPercentage: 20, resetAt: 2000 },
copilot: { monthly: { usedPercentage: 24.3, resetAt: 3000, used: 1215, limit: 5000 } },
});
expect(chip.innerHTML).toContain('class="pu-provider">Copilot</span>');
expect(chip.innerHTML).toContain('mo');
expect(chip.innerHTML).toContain('24%');
expect(chip.title).toContain('Copilot plan usage');
expect(chip.title).toContain('1215 of 5000 requests');
// Only Copilot's own row carries a monthly window.
const claudeRow = chip.innerHTML.slice(0, chip.innerHTML.indexOf('Copilot'));
expect(claudeRow).not.toContain('>mo<');
});
it('shows a lone Copilot row without a provider label or a Claude placeholder', () => {
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
app.updatePlanUsageChip({ copilot: { monthly: { usedPercentage: 80, resetAt: 3000 } } });
expect(chip.innerHTML).toContain('80%');
expect(chip.innerHTML).not.toContain('pu-provider');
expect(chip.innerHTML).not.toContain('pu-win-idle');
});
it('renders no Copilot row when the provider reports nothing', () => {
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
app.updatePlanUsageChip({ sevenDay: { usedPercentage: 30, resetAt: 2000 }, copilot: null });
expect(chip.innerHTML).not.toContain('Copilot');
});
});
+13
View File
@@ -16,3 +16,16 @@ it('preserves the Codex row when a later Claude status-line sample arrives', ()
codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } },
});
});
it('preserves the Copilot row when a later Claude status-line sample arrives', () => {
const module = latestModule as Record<string, unknown>;
const setCopilot = module.setLatestCopilotPlanUsage as (value: unknown) => unknown;
const setClaude = module.setLatestPlanUsage as (value: Record<string, unknown>) => unknown;
setCopilot({ monthly: { usedPercentage: 12, resetAt: 5000, used: 36, limit: 300 } });
expect(setClaude({ sessionId: 's2', fiveHour: { usedPercentage: 50, resetAt: 2000 } })).toMatchObject({
sessionId: 's2',
fiveHour: { usedPercentage: 50, resetAt: 2000 },
copilot: { monthly: { usedPercentage: 12, resetAt: 5000, used: 36, limit: 300 } },
});
});
+890
View File
@@ -0,0 +1,890 @@
/**
* Prompt-cache keep-warm (src/prompt-cache-keepwarm.ts): the rulebook as a table, the
* effective opt-in shared with the launch, the composer-empty read it depends on, the two
* submit clocks on a real Session, and the service's timer and nudge path with a fake
* session and fake ports. Fake timers in the service block; nothing here touches tmux.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
decideKeepWarm,
keepWarmOn,
PromptCacheKeepWarmService,
KEEP_WARM_NUDGE,
KEEP_WARM_LEAD_MS,
KEEP_WARM_RETRY_MS,
KEEP_WARM_LAST_CHANCE_MS,
KEEP_WARM_MIN_TOKENS,
KEEP_WARM_RECENT_INPUT_MS,
KEEP_WARM_TURN_WINDOW_MS,
type KeepWarmFacts,
type KeepWarmPorts,
type KeepWarmSessionView,
} from '../src/prompt-cache-keepwarm.js';
import { composerIsEmpty, plainFrame } from '../src/session-submit-verifier.js';
import { detectUsageLimitPause } from '../src/usage-limit-patterns.js';
import { isCapturablePrompt } from '../src/intent-store.js';
import { Session } from '../src/session.js';
import type { KeepWarmStatus, PromptCacheStatus } from '../src/types.js';
const NOW = Date.UTC(2026, 9, 6, 22, 0, 0);
const HOUR = 3_600_000;
function warmCache(expiresAt: number, extra: Partial<PromptCacheStatus> = {}): PromptCacheStatus {
return { warm: true, ttl: '1h', expiresAt, recacheTokensIfCold: 110_000, misses: 0, ...extra };
}
/** Everything green and due: the one shape that nudges. */
function dueFacts(over: Partial<KeepWarmFacts> = {}): KeepWarmFacts {
return {
now: NOW,
choice: true,
appDefault: false,
spawnedByAgent: false,
statusLine: 'injected',
cache: warmCache(NOW + KEEP_WARM_LEAD_MS - 1000),
idle: true,
paneAlive: true,
limitPaused: false,
automationActive: false,
pendingApproval: false,
composerEmpty: true,
lastHumanInputAt: NOW - HOUR,
budgetAnchorAt: NOW - HOUR,
maxKeepWarmMs: 2 * HOUR,
...over,
};
}
describe('decideKeepWarm', () => {
it('nudges only when every guard holds', () => {
expect(decideKeepWarm(dueFacts())).toEqual({ action: 'nudge' });
});
it('nudges on the app default, and for an agent-spawned session switched on for itself', () => {
expect(decideKeepWarm(dueFacts({ choice: undefined, appDefault: true }))).toEqual({ action: 'nudge' });
expect(decideKeepWarm(dueFacts({ choice: true, spawnedByAgent: true }))).toEqual({ action: 'nudge' });
});
it.each<[string, Partial<KeepWarmFacts>, string]>([
['switched off for the session', { choice: false, appDefault: true }, 'off'],
['no choice and the app default off', { choice: undefined }, 'off'],
[
'an agent-spawned session on the app default',
{ choice: undefined, appDefault: true, spawnedByAgent: true },
'agent-spawned',
],
['Plan Usage off at launch', { statusLine: 'collection-off' }, '(collection-off)'],
["the workspace's own statusLine", { statusLine: 'workspace-statusline' }, '(workspace-statusline)'],
['a remote or Docker launch', { statusLine: 'remote' }, '(remote)'],
['a launch nobody recorded (a CLI without the exporter, a direct PTY)', { statusLine: undefined }, '(unknown)'],
['no report yet', { cache: null }, 'no cache report yet'],
['a cold cache', { cache: { warm: false } }, 'cold'],
[
'a context compacted this gap (the shape Session.setPromptCache stamps: no re-cache size)',
{ cache: warmCache(NOW + 60_000, { recacheTokensIfCold: undefined, compactedAt: NOW - 60_000 }) },
'compacted',
],
['warm but no expiry to schedule on', { cache: { warm: true, recacheTokensIfCold: 110_000 } }, 'cold'],
['the five-minute lifetime', { cache: warmCache(NOW + 4 * 60_000, { ttl: '5m' }) }, '5-minute'],
[
'a small context',
{ cache: warmCache(NOW + 60_000, { recacheTokensIfCold: KEEP_WARM_MIN_TOKENS - 1 }) },
'too small',
],
['a dead pane', { paneAlive: false }, 'no live pane'],
['a usage-limit pause', { limitPaused: true }, 'usage-limit'],
['the cap after the last prompt', { budgetAnchorAt: NOW - 2 * HOUR }, 'window after the last prompt'],
])('stops for %s', (_label, over, reason) => {
const d = decideKeepWarm(dueFacts(over));
expect(d.action).toBe('stop');
expect(d.action === 'stop' && d.reason).toContain(reason);
});
it('waits for the due time, five minutes before the expiry, when it is not due yet', () => {
const expiresAt = NOW + HOUR;
expect(decideKeepWarm(dueFacts({ cache: warmCache(expiresAt) }))).toEqual({
action: 'wait',
until: expiresAt - KEEP_WARM_LEAD_MS,
reason: 'not due yet',
});
});
it('gives up inside the last-chance minute rather than race the expiry', () => {
const d = decideKeepWarm(dueFacts({ cache: warmCache(NOW + KEEP_WARM_LAST_CHANCE_MS - 1) }));
expect(d).toMatchObject({ action: 'stop', reason: expect.stringContaining('too close') });
});
it.each<[string, Partial<KeepWarmFacts>, string]>([
['the session is working', { idle: false }, 'working'],
['a dialog is open', { pendingApproval: true }, 'dialog'],
['the composer holds a draft', { composerEmpty: false }, 'composer holds text'],
// Neither ends with a turn, so a stop here would leave an idle session with no trigger.
['respawn, Ralph or an agent is driving (a long-poll waiter included)', { automationActive: true }, 'driving'],
['one capture of the composer failed', { composerEmpty: null }, 'cannot read the composer'],
['someone typed a moment ago', { lastHumanInputAt: NOW - KEEP_WARM_RECENT_INPUT_MS + 1 }, 'typed'],
])('defers by a minute while %s', (_label, over, reason) => {
const d = decideKeepWarm(dueFacts(over));
expect(d).toMatchObject({
action: 'wait',
until: NOW + KEEP_WARM_RETRY_MS,
reason: expect.stringContaining(reason),
});
});
it('clamps a deferral to the last-chance line so a retry never lands on an expired cache', () => {
const expiresAt = NOW + KEEP_WARM_LAST_CHANCE_MS + 30_000; // due, 90 s left
const d = decideKeepWarm(dueFacts({ cache: warmCache(expiresAt), idle: false }));
expect(d).toMatchObject({ action: 'wait', until: expiresAt - KEEP_WARM_LAST_CHANCE_MS });
});
it('checks the cap before the schedule, so a session past its window never arms a timer', () => {
const d = decideKeepWarm(dueFacts({ cache: warmCache(NOW + HOUR), budgetAnchorAt: NOW - 4 * HOUR }));
expect(d.action).toBe('stop');
});
});
describe('keepWarmOn', () => {
it('is the session choice, else the app default, which an agent-spawned session does not inherit', () => {
expect(keepWarmOn(true, false, false)).toBe(true);
expect(keepWarmOn(false, true, false)).toBe(false);
expect(keepWarmOn(undefined, true, false)).toBe(true);
expect(keepWarmOn(undefined, false, false)).toBe(false);
expect(keepWarmOn(undefined, true, true)).toBe(false);
expect(keepWarmOn(true, false, true)).toBe(true);
});
});
describe('composerIsEmpty', () => {
const glyph = '\u276f';
const rule = '─'.repeat(20);
it('reads the last composer down to its bottom rule: empty, a draft, or nothing to read', () => {
expect(composerIsEmpty(`transcript\n${glyph}\u00a0\n${rule}\n bypass permissions on`, glyph)).toBe(true);
expect(composerIsEmpty(`${glyph} old prompt\n● ok\n${glyph} half a draft\n${rule}`, glyph)).toBe(false);
expect(composerIsEmpty('Accessing workspace:\n Yes, I trust this folder', glyph)).toBeUndefined();
expect(composerIsEmpty(`${glyph} anything\n${rule}`, '')).toBeUndefined();
});
it('sees a draft that opens with a newline, on the continuation lines under a blank glyph line', () => {
expect(composerIsEmpty(`${glyph} \n unfinished draft\n${rule}\n bypass permissions on`, glyph)).toBe(false);
});
it('never takes an indented draft line holding the glyph or a rule for the composer edges', () => {
expect(composerIsEmpty(`${glyph} explain this terminal prompt:\n ${glyph}\n${rule}`, glyph)).toBe(false);
expect(composerIsEmpty(`${glyph} \n ${rule} under the line\n${rule}\n bypass permissions on`, glyph)).toBe(false);
});
it('gives no verdict without the bottom rule that bounds the composer', () => {
expect(composerIsEmpty(`${glyph} \n unfinished draft`, glyph)).toBeUndefined();
});
/**
* `capture-pane -p -e` of one Claude Code 2.1.300 pane, byte for byte apart from the
* statusline row (dropped): the composer with its prompt suggestion, empty, holding a draft,
* holding the same draft with the cursor moved home, and holding a pasted-text chip. The suggestion's first letter sits
* in the reverse-video cursor cell and the rest is faint; a typed draft is neither.
*/
const RULE = `\x1b[38;5;244m${'─'.repeat(120)}`;
const FOOTER = ' \x1b[38;5;220m⏵⏵ auto mode on\x1b[38;5;246m (shift+tab to cycle)\x1b[39m';
const frame = (composer: string) =>
['\x1b[38;5;246m✻ Cooked for 2s · done 6:56 PM\x1b[39m', '', RULE, composer, RULE, FOOTER].join('\n');
const SUGGESTION = '\x1b[39m❯\u00a0\x1b[7mw\x1b[0;2m\x1b[39m\x1b[49mrite tests for add\x1b[0m\x1b[39m\x1b[49m';
const EMPTY = '\x1b[39m❯\u00a0\x1b[7m \x1b[0m\x1b[39m\x1b[49m';
const DRAFT = '\x1b[39m❯\u00a0half a draft\x1b[7m \x1b[0m\x1b[39m\x1b[49m';
const DRAFT_CURSOR_HOME = '\x1b[39m❯\u00a0\x1b[7mh\x1b[0m\x1b[39m\x1b[49malf a draft';
// A 40-line paste collapses to a chip, drawn plain like typed text.
const PASTED_CHIP = '\x1b[39m❯\u00a0[Pasted text #1 +40 lines]\x1b[7m \x1b[0m\x1b[39m\x1b[49m';
it("reads Claude Code's prompt suggestion as empty and a typed draft as text, from a real styled capture", () => {
expect(composerIsEmpty(frame(SUGGESTION), glyph)).toBe(true);
expect(composerIsEmpty(frame(EMPTY), glyph)).toBe(true);
expect(composerIsEmpty(frame(DRAFT), glyph)).toBe(false);
// The cursor cell holds the draft's first letter, but no faint text follows it.
expect(composerIsEmpty(frame(DRAFT_CURSOR_HOME), glyph)).toBe(false);
expect(composerIsEmpty(frame(PASTED_CHIP), glyph)).toBe(false);
});
it('reads a suggestion drawn without a cursor cell as empty, and a plain capture of it as text', () => {
expect(composerIsEmpty(frame('❯\u00a0\x1b[2mwrite tests for add\x1b[0m'), glyph)).toBe(true);
expect(composerIsEmpty(frame('❯\u00a0\x1b[2mwrite \x1b[22mtyped\x1b[0m'), glyph)).toBe(false);
// Without the styles nothing tells the two apart, which is why the pane is read with -e.
expect(composerIsEmpty(frame('❯\u00a0write tests for add'), glyph)).toBe(false);
});
it("reads text styled with tmux's colon sub-parameters, which stripAnsi leaves behind", () => {
const limit = "You've hit your \x1b[4:3mlimit\x1b[0m · resets 1:40pm (UTC)";
expect(detectUsageLimitPause(plainFrame(limit), NOW)).not.toBeNull();
expect(composerIsEmpty(frame('\x1b[4:3m❯\x1b[0m\u00a0'), glyph)).toBe(true);
});
it("never takes an extended colour's arguments for the faint or reverse attributes", () => {
expect(composerIsEmpty(frame('❯\u00a0\x1b[38;2;2;7;2mtyped in truecolour\x1b[0m'), glyph)).toBe(false);
expect(composerIsEmpty(frame('❯\u00a0\x1b[38;5;2mtyped in a 256 colour\x1b[0m'), glyph)).toBe(false);
});
});
describe('Read My Mind ignores the nudge', () => {
it('drops the exact keep-alive line and nothing else', () => {
expect(isCapturablePrompt(KEEP_WARM_NUDGE)).toBe(false);
expect(isCapturablePrompt(`${KEEP_WARM_NUDGE}\n`)).toBe(false);
expect(isCapturablePrompt(`why did "${KEEP_WARM_NUDGE}" show up in my session?`)).toBe(true);
expect(isCapturablePrompt('fix the login bug')).toBe(true);
});
});
describe('the two submit clocks on a Session', () => {
it('a nudge moves lastSubmitAt but not lastRealPromptAt, from the write and from the hook', () => {
vi.useFakeTimers();
try {
vi.setSystemTime(NOW);
const session = new Session({ workingDir: '/tmp' });
session.write('real prompt\r', { fromUser: true });
expect(session.lastRealPromptAt).toBe(NOW);
vi.setSystemTime(NOW + HOUR);
session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS);
session.write(`${KEEP_WARM_NUDGE}\r`);
session.markPromptSubmitted(); // its UserPromptSubmit hook
expect(session.lastSubmitAt).toBe(NOW + HOUR);
expect(session.lastRealPromptAt).toBe(NOW);
// A person's prompt ends the keep-alive window at once and counts again.
vi.setSystemTime(NOW + HOUR + 1000);
session.write('next real prompt\r', { fromUser: true });
expect(session.keepAliveTurnActive).toBe(false);
expect(session.lastRealPromptAt).toBe(NOW + HOUR + 1000);
// A prompt typed in an attached tmux client reaches Codeman only through the hook.
vi.setSystemTime(NOW + 2 * HOUR);
session.markPromptSubmitted();
expect(session.lastRealPromptAt).toBe(NOW + 2 * HOUR);
// ... even inside a keep-alive window: its hook carries the prompt text, and anything but
// the nudge's own ends the window, so it counts and its stop hook alerts normally.
vi.setSystemTime(NOW + 3 * HOUR);
session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS);
session.markPromptSubmitted(KEEP_WARM_NUDGE);
expect(session.keepAliveTurnActive).toBe(true);
expect(session.lastRealPromptAt).toBe(NOW + 2 * HOUR);
vi.setSystemTime(NOW + 3 * HOUR + 5000);
session.markPromptSubmitted('fix the login bug');
expect(session.keepAliveTurnActive).toBe(false);
expect(session.lastRealPromptAt).toBe(NOW + 3 * HOUR + 5000);
} finally {
vi.useRealTimers();
}
});
/**
* The window is a ceiling, not the turn's length: a slow keep-alive turn's idle prompt lands
* about a minute after it ends, so the window closes on that prompt (the route calls
* endKeepAliveTurn) or on any other submit, never by a short clock alone.
*/
it('the keep-alive window survives the nudge and a bare Enter, and ends on any other submit', () => {
const session = new Session({ workingDir: '/tmp' });
session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS);
session.write(`${KEEP_WARM_NUDGE}\r`);
session.write('\r');
expect(session.keepAliveTurnActive).toBe(true);
session.write('/compact\r');
expect(session.keepAliveTurnActive).toBe(false);
session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS);
session.write('typed but not sent');
expect(session.keepAliveTurnActive).toBe(true);
session.endKeepAliveTurn();
expect(session.keepAliveTurnActive).toBe(false);
expect(KEEP_WARM_TURN_WINDOW_MS).toBeGreaterThanOrEqual(10 * 60_000);
});
it('persists the real-prompt clock and the choice, and restores both', () => {
const session = new Session({ workingDir: '/tmp', lastRealPromptAt: NOW, keepWarmEnabled: true });
expect(session.toState()).toMatchObject({ lastRealPromptAt: NOW, keepWarmEnabled: true });
const fresh = new Session({ workingDir: '/tmp' });
expect(fresh.lastRealPromptAt).toBe(0);
expect(fresh.toState().lastRealPromptAt).toBeUndefined();
});
it('seeds the real-prompt clock from lastSubmitAt for a state.json written before it existed', () => {
expect(new Session({ workingDir: '/tmp', lastSubmitAt: NOW - HOUR }).lastRealPromptAt).toBe(NOW - HOUR);
const both = new Session({ workingDir: '/tmp', lastSubmitAt: NOW, lastRealPromptAt: NOW - HOUR });
expect(both.lastRealPromptAt).toBe(NOW - HOUR);
});
});
// ─── Service ─────────────────────────────────────────────────────────────────
class FakeSession implements KeepWarmSessionView {
id = 'sess-1';
createdAt = NOW - HOUR;
pid: number | null = 4242;
paneExit: unknown = undefined;
parentSessionId: string | undefined = undefined;
promptCache: PromptCacheStatus | null = null;
statusLineExporter = 'injected' as const;
idleCompactionOptOut = true;
keepWarmEnabled: boolean | undefined = undefined;
isWorking = false;
isLimitPaused = false;
lastHumanInputAt = NOW - HOUR;
lastRealPromptAt = NOW - HOUR;
idle = true;
writes: string[] = [];
keepAliveWindows: number[] = [];
writeOk = true;
isIdle(): boolean {
return this.idle;
}
beginKeepAliveTurn(windowMs: number): void {
this.keepAliveWindows.push(windowMs);
}
keepAliveWindowsEnded = 0;
endKeepAliveTurn(): void {
this.keepAliveWindowsEnded += 1;
}
async writeViaMux(data: string): Promise<boolean> {
this.writes.push(data);
return this.writeOk;
}
}
function makePorts(session: FakeSession, over: Partial<KeepWarmPorts> = {}) {
const published: KeepWarmStatus[] = [];
const ports: KeepWarmPorts = {
getSession: (id) => (id === session.id ? session : undefined),
sessionIds: () => [session.id],
readSettings: async () => ({ keepWarmDefault: false, maxHours: 2 }),
isAutomationActive: () => false,
hasPendingApproval: () => false,
readPane: () => ({ composerEmpty: true, limitShown: false }),
publish: (_id, status) => {
published.push(status);
},
...over,
};
return { ports, published, last: () => published[published.length - 1] };
}
/** Let the service's awaited settings read and write settle. */
const settle = async () => {
for (let i = 0; i < 5; i++) await Promise.resolve();
};
describe('PromptCacheKeepWarmService', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('arms on the cache report, nudges at the due time, and reports what it did', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + HOUR);
const { ports, last } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
service.schedule(session.id);
await settle();
expect(last()).toMatchObject({
enabled: true,
idleCompactionOff: true,
state: 'armed',
nextCheckAt: NOW + HOUR - KEEP_WARM_LEAD_MS,
nudges: 0,
});
expect(session.writes).toHaveLength(0);
await vi.advanceTimersByTimeAsync(HOUR - KEEP_WARM_LEAD_MS);
await settle();
expect(session.writes).toEqual([`${KEEP_WARM_NUDGE}\r`]);
expect(session.keepAliveWindows).toEqual([KEEP_WARM_TURN_WINDOW_MS]);
expect(last()).toMatchObject({ state: 'nudged', nudges: 1, lastNudgeAt: NOW + HOUR - KEEP_WARM_LEAD_MS });
// The nudge's own report moves the expiry out; the next evaluation arms on it.
session.promptCache = warmCache(NOW + 2 * HOUR - KEEP_WARM_LEAD_MS);
service.schedule(session.id);
await settle();
expect(last()).toMatchObject({ state: 'armed', nudges: 1 });
service.stop();
});
it('reads the composer only when otherwise due, and retries while it holds a draft', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
let draft = true;
const reads: number[] = [];
const { ports, last } = makePorts(session, {
readPane: () => {
reads.push(Date.now());
return { composerEmpty: !draft, limitShown: false };
},
});
const service = new PromptCacheKeepWarmService(ports);
service.schedule(session.id);
await settle();
expect(reads).toHaveLength(1);
expect(last()).toMatchObject({
state: 'waiting',
reason: 'the composer holds text',
nextCheckAt: NOW + KEEP_WARM_RETRY_MS,
});
expect(session.writes).toHaveLength(0);
draft = false;
await vi.advanceTimersByTimeAsync(KEEP_WARM_RETRY_MS);
await settle();
expect(reads).toHaveLength(2);
expect(session.writes).toHaveLength(1);
expect(last()).toMatchObject({ state: 'nudged', nudges: 1 });
service.stop();
});
it('follows the app-wide default when the session has not chosen, and publishes the effective flag', async () => {
const session = new FakeSession();
session.promptCache = warmCache(NOW + HOUR);
const { ports, last } = makePorts(session, { readSettings: async () => ({ keepWarmDefault: true, maxHours: 2 }) });
const service = new PromptCacheKeepWarmService(ports);
service.schedule(session.id);
await settle();
expect(last()).toMatchObject({ enabled: true, state: 'armed' });
session.keepWarmEnabled = false;
service.schedule(session.id);
await settle();
expect(last()).toMatchObject({ enabled: false, state: 'off' });
expect(vi.getTimerCount()).toBe(0);
service.stop();
});
it('applies the cap from settings and never arms past it', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.lastRealPromptAt = NOW - 2.5 * HOUR;
session.promptCache = warmCache(NOW + HOUR);
const { ports, last } = makePorts(session, { readSettings: async () => ({ keepWarmDefault: false, maxHours: 2 }) });
const service = new PromptCacheKeepWarmService(ports);
service.schedule(session.id);
await settle();
expect(last()).toMatchObject({
state: 'stopped',
reason: expect.stringContaining('window'),
budgetEndsAt: NOW - 0.5 * HOUR,
});
expect(vi.getTimerCount()).toBe(0);
service.stop();
});
/**
* A nudge only goes out inside the last five minutes before the expiry, so a retry armed a
* whole lead time later would land past it and stop: the failed write ends the keep-alive
* window (no turn is running) and retries a minute later, never past the last-chance line.
*/
it('keeps a failed write out of the tally, ends the keep-alive window and retries a minute later', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.writeOk = false;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
const { ports, last } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
service.schedule(session.id);
await settle();
expect(session.writes).toHaveLength(1);
expect(session.keepAliveWindowsEnded).toBe(1);
expect(last()).toMatchObject({
state: 'waiting',
nudges: 0,
nextCheckAt: NOW + KEEP_WARM_RETRY_MS,
reason: expect.stringContaining('could not be written'),
});
session.writeOk = true;
await vi.advanceTimersByTimeAsync(KEEP_WARM_RETRY_MS);
await settle();
expect(session.writes).toEqual([`${KEEP_WARM_NUDGE}\r`, `${KEEP_WARM_NUDGE}\r`]);
expect(session.keepAliveWindowsEnded).toBe(1);
expect(last()).toMatchObject({ state: 'nudged', nudges: 1 });
service.stop();
});
it('never arms the retry after a failed write past the last-chance line', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.writeOk = false;
// Due, with only 30 s left before the last-chance line.
session.promptCache = warmCache(NOW + KEEP_WARM_LAST_CHANCE_MS + 30_000);
const { ports, last } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(last()).toMatchObject({ state: 'waiting', nextCheckAt: NOW + 30_000 });
await vi.advanceTimersByTimeAsync(30_000);
await settle();
expect(session.writes).toHaveLength(1);
expect(last()).toMatchObject({ state: 'stopped', reason: 'too close to expiry to land a turn' });
expect(vi.getTimerCount()).toBe(0);
service.stop();
});
it('does not nudge a session whose screen shows a usage limit, with auto-resume off', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
const { ports, last } = makePorts(session, { readPane: () => ({ composerEmpty: true, limitShown: true }) });
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(session.writes).toHaveLength(0);
expect(last()).toMatchObject({ state: 'stopped', reason: 'usage-limit pause' });
service.stop();
});
it('does not nudge an agent that exited in a pane that kept its pid', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.paneExit = { status: 137 };
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
const { ports, last } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(session.writes).toHaveLength(0);
expect(last()).toMatchObject({ state: 'stopped', reason: 'no live pane' });
service.stop();
});
it('never arms a timer past what setTimeout honours, however far off the expiry', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + 30 * 24 * HOUR);
const { ports } = makePorts(session, {
readSettings: async () => ({ keepWarmDefault: false, maxHours: 24 * 60 }),
});
const spy = vi.spyOn(globalThis, 'setTimeout');
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][1]).toBe(2 ** 31 - 1);
spy.mockRestore();
service.stop();
});
it('stop() during the settings read sends nothing, arms nothing and publishes nothing', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const { ports, published } = makePorts(session, {
readSettings: async () => {
await gate;
return { keepWarmDefault: false, maxHours: 2 };
},
});
const service = new PromptCacheKeepWarmService(ports);
const run = service.schedule(session.id);
service.stop();
release();
await run;
expect(session.writes).toHaveLength(0);
expect(published).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
});
it('remove() while the nudge is being written leaves no timer, tally or status behind', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
let release!: (ok: boolean) => void;
session.writeViaMux = async (data: string) => {
session.writes.push(data);
return new Promise<boolean>((r) => (release = r));
};
const { ports, published } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
const run = service.schedule(session.id);
await settle();
expect(session.writes).toHaveLength(1);
service.remove(session.id);
release(true);
await run;
expect(published).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
service.stop();
});
it('a cancelled evaluation finishing late does not cancel the one that replaced it', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + HOUR);
let releaseWrite!: (ok: boolean) => void;
let releaseRead: (() => void) | undefined;
const { ports, published } = makePorts(session, {
readSettings: async () => {
if (releaseRead === undefined) return { keepWarmDefault: false, maxHours: 2 };
await new Promise<void>((r) => (releaseRead = r));
return { keepWarmDefault: false, maxHours: 2 };
},
});
const service = new PromptCacheKeepWarmService(ports);
// First evaluation: due, held in its write.
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
session.writeViaMux = async (data: string) => {
session.writes.push(data);
return new Promise<boolean>((r) => (releaseWrite = r));
};
const first = service.schedule(session.id);
await settle();
service.remove(session.id);
// Second evaluation: not due, held in its settings read.
session.promptCache = warmCache(NOW + HOUR);
releaseRead = () => {};
// rescheduleAll() drops the kept settings, so this evaluation really waits in its read.
service.rescheduleAll();
await settle();
releaseWrite(true);
await first;
releaseRead();
await settle();
expect(published).toEqual([expect.objectContaining({ state: 'armed' })]);
service.stop();
});
/**
* Every statusline report schedules an evaluation, so the settings are read once and kept
* rather than read from disk on each report, the feature off included; the settings PUT calls
* rescheduleAll() when either key changes, which is what makes the next read fresh.
*/
it('reads the settings once, again after rescheduleAll(), and again after a failed read', async () => {
const session = new FakeSession();
session.promptCache = warmCache(NOW + HOUR);
let reads = 0;
let fail = false;
const { ports } = makePorts(session, {
readSettings: async () => {
reads += 1;
if (fail) throw new Error('unreadable');
return { keepWarmDefault: false, maxHours: 2 };
},
});
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
await service.schedule(session.id);
expect(reads).toBe(1);
fail = true;
service.rescheduleAll();
await settle();
expect(reads).toBe(2);
fail = false;
await service.schedule(session.id);
expect(reads).toBe(3);
service.stop();
});
it('publishes no window while off, so a real prompt on a session with keep-warm off broadcasts nothing', async () => {
const session = new FakeSession();
session.keepWarmEnabled = false;
session.promptCache = warmCache(NOW + HOUR);
const { ports, published } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
session.lastRealPromptAt = NOW;
await service.schedule(session.id);
expect(published[0]).toMatchObject({ enabled: false, state: 'off' });
expect(published[0]).not.toHaveProperty('budgetEndsAt');
expect(published[0]).not.toHaveProperty('nextCheckAt');
expect(JSON.stringify(published[1])).toBe(JSON.stringify(published[0]));
service.stop();
});
it('retries a minute later when an evaluation throws, since an idle session has no other trigger', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
let fail = true;
const { ports, last } = makePorts(session, {
readSettings: async () => {
if (fail) throw new Error('settings.json mid-rewrite');
return { keepWarmDefault: false, maxHours: 2 };
},
});
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(session.writes).toHaveLength(0);
expect(vi.getTimerCount()).toBe(1);
fail = false;
await vi.advanceTimersByTimeAsync(KEEP_WARM_RETRY_MS);
await settle();
expect(session.writes).toEqual([`${KEEP_WARM_NUDGE}\r`]);
expect(last()).toMatchObject({ state: 'nudged' });
service.stop();
});
/**
* A settings.json left broken (a hand edit with a trailing comma) fails every read until it is
* fixed. Only a session that could nudge retries on its own, and the failure is logged once
* rather than once a minute per session.
*/
it.each([
{ label: 'keep-warm off', set: (s: FakeSession) => (s.keepWarmEnabled = false) },
{ label: 'no exporter on this launch', set: (s: FakeSession) => (s.statusLineExporter = 'remote' as never) },
{ label: 'no cache report', set: (s: FakeSession) => (s.promptCache = null) },
{ label: 'a cold cache', set: (s: FakeSession) => (s.promptCache = { ...warmCache(NOW + HOUR), warm: false }) },
])('does not retry an unreadable settings file for a session that cannot nudge ($label)', async ({ set }) => {
const session = new FakeSession();
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
set(session);
let reads = 0;
const logged: string[] = [];
const { ports, published } = makePorts(session, {
readSettings: async () => {
reads += 1;
throw new Error('Unexpected token } in JSON at position 812');
},
log: (m) => logged.push(m),
});
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(10 * KEEP_WARM_RETRY_MS);
await settle();
expect(reads).toBe(1);
expect(logged).toEqual([expect.stringContaining('Unexpected token')]);
expect(published).toHaveLength(0);
service.stop();
});
it('retries an unreadable settings file every minute for a session that can nudge, logging it once', async () => {
const session = new FakeSession();
session.promptCache = warmCache(NOW + 30 * 60_000);
let reads = 0;
let fail = true;
const logged: string[] = [];
const { ports, last } = makePorts(session, {
readSettings: async () => {
reads += 1;
if (fail) throw new Error('Unexpected token } in JSON at position 812');
return { keepWarmDefault: true, maxHours: 2 };
},
log: (m) => logged.push(m),
});
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(vi.getTimerCount()).toBe(1);
await vi.advanceTimersByTimeAsync(10 * KEEP_WARM_RETRY_MS);
await settle();
expect(reads).toBe(11);
expect(logged).toEqual([expect.stringContaining('Unexpected token')]);
// Fixed: the next retry reads it and the session is kept warm from there.
fail = false;
await vi.advanceTimersByTimeAsync(KEEP_WARM_RETRY_MS);
await settle();
expect(reads).toBe(12);
expect(last()).toMatchObject({ enabled: true, state: 'armed' });
// Broken again later: logged again, once.
fail = true;
service.rescheduleAll();
await settle();
await vi.advanceTimersByTimeAsync(3 * KEEP_WARM_RETRY_MS);
await settle();
expect(logged).toHaveLength(2);
service.stop();
});
it('runs one follow-up for triggers that land during an evaluation, and resolves after it', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
let releaseWrite!: (ok: boolean) => void;
session.writeViaMux = async (data: string) => {
session.writes.push(data);
return new Promise<boolean>((r) => (releaseWrite = r));
};
const { ports, published } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
const first = service.schedule(session.id);
await settle();
expect(session.writes).toHaveLength(1);
// Switched off while the nudge is being written: the toggle's own evaluation must not be lost.
session.keepWarmEnabled = false;
const toggle = service.schedule(session.id);
const settingsChange = service.schedule(session.id);
expect(settingsChange).toBe(toggle);
releaseWrite(true);
await first;
await toggle;
expect(published.map((s) => s.state)).toEqual(['nudged', 'off']);
service.stop();
});
it('remove() drops a follow-up queued behind an evaluation, and a failed read after it arms nothing', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
let release!: () => void;
let fail = false;
const { ports, published } = makePorts(session, {
readSettings: async () => {
await new Promise<void>((r) => (release = r));
if (fail) throw new Error('settings.json mid-rewrite');
return { keepWarmDefault: false, maxHours: 2 };
},
});
const service = new PromptCacheKeepWarmService(ports);
// The session stays discoverable while it is being deleted, so only remove() can stop these.
const first = service.schedule(session.id);
const followUp = service.schedule(session.id);
service.remove(session.id);
release();
await first;
await followUp;
await settle();
expect(session.writes).toHaveLength(0);
expect(published).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
// rescheduleAll() drops the kept settings, so this read really runs, and fails.
fail = true;
const logged: string[] = [];
ports.log = (m) => logged.push(m);
service.rescheduleAll();
service.remove(session.id);
release();
await settle();
expect(logged).toEqual([expect.stringContaining('mid-rewrite')]);
expect(vi.getTimerCount()).toBe(0);
service.stop();
});
it('remove() forgets the nudge count, so an id seen again starts from zero', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
const { ports, last } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(last()).toMatchObject({ state: 'nudged', nudges: 1 });
service.remove(session.id);
session.promptCache = warmCache(NOW + HOUR);
await service.schedule(session.id);
expect(last()).toMatchObject({ state: 'armed', nudges: 0 });
service.stop();
});
it('remove() drops the timer so a deleted session never gets a nudge', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + HOUR);
const { ports } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
service.schedule(session.id);
await settle();
expect(vi.getTimerCount()).toBe(1);
service.remove(session.id);
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(2 * HOUR);
expect(session.writes).toHaveLength(0);
});
});
+175 -3
View File
@@ -17,14 +17,29 @@ type Cache = {
lastMissCauses?: string[];
compactedAt?: number;
};
type KeepWarm = {
enabled: boolean;
idleCompactionOff?: boolean;
state: string;
nextCheckAt?: number;
reason?: string;
nudges: number;
lastNudgeAt?: number;
budgetEndsAt?: number;
};
type Api = {
format: (c: Cache | null | undefined, now: number, statusLine?: string) => string;
formatKeepWarm: (s: KeepWarm | null | undefined, now: number, statusLine?: string) => string;
keepWarmUnavailable: (statusLine?: string) => boolean;
};
function loadFormat() {
function loadApi(): Api {
const context = vm.createContext({ window: {}, globalThis: {} });
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
vm.runInContext(source, context, { filename: 'constants.js' });
return (context.window as { CodemanPromptCache: { format: (c: Cache | null | undefined, now: number) => string } })
.CodemanPromptCache.format as (c: Cache | null | undefined, now: number, statusLine?: string) => string;
return (context.window as { CodemanPromptCache: Api }).CodemanPromptCache;
}
const loadFormat = () => loadApi().format;
const clock = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const NOW = Date.UTC(2026, 9, 6, 22, 18, 0);
@@ -39,6 +54,8 @@ describe('Prompt cache readout', () => {
it('says why when the launch carries no statusLine exporter, whatever report is stored', () => {
const format = loadFormat();
expect(format(null, NOW, 'collection-off')).toMatch(/^Not collected: Plan Usage was off when this session started/);
// Collection is decided at launch, so the fix is a new session, and the readout says where the switch is.
expect(format(null, NOW, 'collection-off')).toContain('App Settings → Header & Panels, then start a new session');
expect(format({ warm: true, expiresAt: NOW + 60_000 }, NOW, 'collection-off')).toMatch(/^Not collected/);
expect(format(null, NOW, 'workspace-statusline')).toMatch(/settings\.local\.json sets its own statusLine/);
expect(format(null, NOW, 'remote')).toBe('Not collected for remote and Docker sessions.');
@@ -87,3 +104,158 @@ describe('Prompt cache readout', () => {
);
});
});
describe('Keep-warm line on the Status row', () => {
const T = NOW + 55 * 60_000;
const END = NOW + 2 * 3_600_000;
it('says nothing until the service has published, or while switched off', () => {
const { formatKeepWarm } = loadApi();
expect(formatKeepWarm(null, NOW, 'injected')).toBe('');
expect(formatKeepWarm({ enabled: false, state: 'off', nudges: 0 }, NOW, 'injected')).toBe('');
expect(formatKeepWarm({ enabled: false, state: 'off', reason: 'off', nudges: 0 }, NOW, 'injected')).toBe('');
});
it('armed: the due time and when the window closes', () => {
const { formatKeepWarm } = loadApi();
expect(formatKeepWarm({ enabled: true, state: 'armed', nextCheckAt: T, nudges: 0, budgetEndsAt: END }, NOW)).toBe(
`Keep-alive: next ${clock(T)} · stops ${clock(END)}`
);
expect(
formatKeepWarm({ enabled: true, state: 'armed', nextCheckAt: T, nudges: 3, budgetEndsAt: NOW - 1 }, NOW)
).toBe(`Keep-alive: next ${clock(T)} · window closed`);
});
it('waiting and sent carry the reason and the count', () => {
const { formatKeepWarm } = loadApi();
expect(
formatKeepWarm(
{
enabled: true,
state: 'waiting',
reason: 'the composer holds text',
nextCheckAt: T,
nudges: 1,
budgetEndsAt: END,
},
NOW
)
).toBe(`Keep-alive: waiting · the composer holds text · retry ${clock(T)} · stops ${clock(END)}`);
expect(formatKeepWarm({ enabled: true, state: 'nudged', lastNudgeAt: T, nudges: 2, budgetEndsAt: END }, NOW)).toBe(
`Keep-alive: sent ${clock(T)} (2×) · stops ${clock(END)}`
);
});
it('stopped, or off for a reason, names it', () => {
const { formatKeepWarm } = loadApi();
expect(formatKeepWarm({ enabled: true, state: 'stopped', reason: 'cache is cold', nudges: 0 }, NOW)).toBe(
'Keep-alive: stopped · cache is cold'
);
expect(
formatKeepWarm({ enabled: false, state: 'off', reason: 'an agent-spawned session ...', nudges: 0 }, NOW)
).toBe('Keep-alive: off · an agent-spawned session ...');
});
it("adds nothing on a launch without the exporter: the readout's Not collected sentence already says why", () => {
const { formatKeepWarm } = loadApi();
const stopped = { enabled: true, state: 'stopped', reason: 'no cache reports on this launch', nudges: 0 };
for (const statusLine of ['collection-off', 'workspace-statusline', 'remote']) {
expect(formatKeepWarm(stopped, NOW, statusLine)).toBe('');
}
});
it('says idle compaction stays on when the opt-out was not on this launch', () => {
const { formatKeepWarm } = loadApi();
const armed = { enabled: true, state: 'armed', nextCheckAt: T, nudges: 0 };
expect(formatKeepWarm({ ...armed, idleCompactionOff: true }, NOW, 'injected')).toBe(`Keep-alive: next ${clock(T)}`);
expect(formatKeepWarm({ ...armed, idleCompactionOff: false }, NOW, 'injected')).toBe(
`Keep-alive: next ${clock(T)} · idle compaction on until relaunch`
);
});
it('says idle compaction stays off when keep-warm was switched off on a launch that carries the opt-out', () => {
const { formatKeepWarm } = loadApi();
const off = { enabled: false, state: 'off', reason: 'off', nudges: 0 };
expect(formatKeepWarm({ ...off, idleCompactionOff: false }, NOW, 'injected')).toBe('');
expect(formatKeepWarm({ ...off, idleCompactionOff: true }, NOW, 'injected')).toBe(
'Keep-alive: off · idle compaction off until relaunch'
);
});
});
/**
* session-ui.js `updateKeepWarmStatus`, run as shipped against a minimal DOM: the keep-warm line
* on the Status row is hidden when it has nothing to say, and a launch without cache reports
* greys the switch's row and refuses to switch it on, never to switch it off.
*/
describe('Keep-warm switch and its Status line', () => {
/** One Session Options modal: the same elements across every session it is opened on. */
function makeModal() {
const ui = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
const start = ui.indexOf(' updateKeepWarmStatus(sessionId) {');
expect(start).toBeGreaterThan(-1);
const update = new Function(
'document',
'window',
'sessionId',
ui.slice(ui.indexOf('{', start) + 1, ui.indexOf('\n },\n', start))
);
const rowClasses = new Set<string>();
const line = { textContent: '', hidden: true };
const row = {
classList: { toggle: (c: string, on: boolean) => void (on ? rowClasses.add(c) : rowClasses.delete(c)) },
};
const box = { checked: false, disabled: false, closest: () => row };
const elements: Record<string, unknown> = { keepWarmStatus: line, modalKeepWarmEnabled: box };
const document = { getElementById: (id: string) => elements[id] ?? null };
const window = { CodemanPromptCache: loadApi() };
return (statusLine: string, keepWarm: KeepWarm | undefined, checked: boolean) => {
box.checked = checked;
const self = {
editingSessionId: 's1',
sessions: new Map([['s1', { statusLineExporter: statusLine, keepWarm }]]),
};
update.call(self, document, window, 's1');
return { line: { ...line }, greyed: rowClasses.has('set-row-disabled'), box: { ...box } };
};
}
const render = (statusLine: string, keepWarm: KeepWarm | undefined, checked: boolean) =>
makeModal()(statusLine, keepWarm, checked);
it('shows the line whenever it has words, any state, and hides it otherwise', () => {
const waiting = render(
'injected',
{ enabled: true, state: 'waiting', reason: 'the composer holds text', nudges: 0 },
true
);
expect(waiting.line.textContent).toContain('Keep-alive: waiting');
expect(waiting.line.hidden).toBe(false);
expect(waiting.greyed).toBe(false);
expect(waiting.box.disabled).toBe(false);
expect(render('injected', { enabled: false, state: 'off', nudges: 0 }, false).line.hidden).toBe(true);
});
it('greys a launch without cache reports, but lets a switch that is on be turned off', () => {
const off = render('collection-off', { enabled: false, state: 'off', nudges: 0 }, false);
expect(off.line.hidden).toBe(true);
expect(off.greyed).toBe(true);
expect(off.box.disabled).toBe(true);
const on = render('collection-off', { enabled: true, state: 'stopped', nudges: 0 }, true);
expect(on.greyed).toBe(true);
expect(on.box.disabled).toBe(false);
});
it('undoes what the previous session left on the same modal', () => {
const show = makeModal();
expect(show('collection-off', { enabled: false, state: 'off', nudges: 0 }, false).box.disabled).toBe(true);
const next = show('injected', { enabled: false, state: 'off', nudges: 0 }, false);
expect(next.box.disabled).toBe(false);
expect(next.greyed).toBe(false);
expect(show('injected', { enabled: true, state: 'armed', nextCheckAt: NOW, nudges: 0 }, true).line.hidden).toBe(
false
);
const off = show('injected', { enabled: false, state: 'off', nudges: 0 }, false);
expect(off.line.hidden).toBe(true);
expect(off.line.textContent).toBe('');
});
});
+78
View File
@@ -110,6 +110,84 @@ describe('response viewer file-path linkifier', () => {
expect(root.querySelector('a')!.getAttribute('href')).toBe('https://example.com/tmp/shot.png');
});
it('turns a markdown link to a path into a preview link, not a same-origin navigation', () => {
// Codex cites files as `[label](/abs/path)`: left alone, the href resolved
// against Codeman's origin and opened a 404 JSON page in a new tab.
const path = '/home/me/llm-bench/runs/crash-20261017/incident-summary.json';
const root = linkify(
`<p><a href="${path}" target="_blank" rel="noopener noreferrer">Our incident record</a> and ` +
`<a href="src/a%20b.ts:42:7">a b</a>, <a href="docs/#L3">docs</a>, <a href="/tmp/renders">renders</a>, ` +
`<a href="/tmp/release%3A12">release</a>, <a href="/tmp/a.json?raw=true">raw</a>, <a href="/tmp/">tmp</a></p>`
);
const links = paths(root);
expect(links.map((a) => a.getAttribute('data-path'))).toEqual([
path,
'src/a b.ts',
'docs/',
'/tmp/renders',
// An encoded colon is part of the name, never a line suffix.
'/tmp/release:12',
// A query names a view of the file, not the file.
'/tmp/a.json',
'/tmp/',
]);
for (const a of links) {
expect(a.getAttribute('href')).toBe('#');
expect(a.hasAttribute('target')).toBe(false);
expect(a.hasAttribute('rel')).toBe(false);
expect(a.getAttribute('data-linkified')).toBe('1');
}
expect(links[0].textContent).toBe('Our incident record');
// A label, not a path: the second class keeps it in the prose font (styles.css).
expect(links.every((a) => a.classList.contains('rv-path-link'))).toBe(true);
// A trailing slash and a rooted folder path without one both open the folder grid
// (`:` ends a folder match in findFilePathLinks, so `release:12` previews instead).
// findFilePathLinks finds no folder in a bare `/tmp/`, so only the trailing-slash
// check marks that one.
expect(links.map((a) => a.hasAttribute('data-folder'))).toEqual([false, false, true, true, false, false, true]);
});
it('opens a clicked markdown path link in the file preview through the click delegate', () => {
const app = Object.create(CodemanApp.prototype) as {
activeSessionId: string;
openFilePreview: ReturnType<typeof vi.fn>;
openFolderGrid: ReturnType<typeof vi.fn>;
_linkifyFilePaths(root: unknown): void;
_bindResponseViewerInteractions(body: unknown): void;
};
app.activeSessionId = 'sess-1';
app.openFilePreview = vi.fn();
app.openFolderGrid = vi.fn();
const body = document.createElement('div');
body.innerHTML = '<p><a href="/home/me/run/summary.json" target="_blank">Our incident record</a></p>';
app._linkifyFilePaths(body);
app._bindResponseViewerInteractions(body);
const click = new dom.window.MouseEvent('click', { bubbles: true, cancelable: true });
body.querySelector('a')!.dispatchEvent(click);
expect(click.defaultPrevented).toBe(true);
expect(app.openFilePreview).toHaveBeenCalledWith('/home/me/run/summary.json', 'sess-1');
expect(app.openFolderGrid).not.toHaveBeenCalled();
});
it('keeps web, mail, protocol-relative and fragment links as they are', () => {
const hrefs = ['https://example.com/a.json', 'mailto:me@example.com', '//cdn.example.com/x.js', '#section'];
const root = linkify(`<p>${hrefs.map((h) => `<a href="${h}">x</a>`).join(' ')}</p>`);
expect(paths(root)).toHaveLength(0);
expect(Array.from(root.querySelectorAll('a')).map((a) => a.getAttribute('href'))).toEqual(hrefs);
});
it('reads a scheme split by a tab as the URL the browser would open', () => {
// The URL parser drops tabs, so this is http://localhost:5173/a, never a relative file.
const root = linkify('<p><a href="http&#9;://localhost:5173/a">local</a></p>');
expect(paths(root)).toHaveLength(0);
expect(root.querySelector('a')!.getAttribute('href')).toBe('http\t://localhost:5173/a');
});
it('leaves text with no path untouched', () => {
const root = linkify('<p>Ratio 3/4 on 2026/08/16, see the app</p>');
+5 -1
View File
@@ -6,7 +6,7 @@
*/
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { assertSandboxedHome, createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { MAX_ROUTE_PARAM_LENGTH } from '../../src/web/route-helpers.js';
@@ -31,6 +31,10 @@ export async function createRouteTestHarness(
registerFn: (app: FastifyInstance, ctx: any) => void,
ctxOptions?: { sessionId?: string; authUser?: { username: string; role: 'admin' | 'user' } }
): Promise<RouteTestHarness> {
// Route tests write the linked-cases, docker and settings files. Refuse to build a harness against
// a real home (a bare vitest has no setupFiles), instead of finding out from a wiped registry.
assertSandboxedHome('A route test');
const app = Fastify({ logger: false, routerOptions: { maxParamLength: MAX_ROUTE_PARAM_LENGTH } });
// Register cookie plugin — some routes access req.cookies
+2 -2
View File
@@ -24,12 +24,12 @@ import { basename, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { dataPath } from '../../src/config/instance.js';
import { safeRmHomeTree } from '../mocks/index.js';
import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js';
const LINKED = () => dataPath('linked-cases.json');
const CASES_DIR = () => join(homedir(), 'codeman-cases');
/** test/setup.ts's temp HOME (its prefix is pinned by test/test-env-isolation.test.ts). */
const sandboxed = () => basename(homedir()).startsWith('codeman-vitest-');
const sandboxed = () => isSandboxedHome();
let workDir = '';
const work = () => workDir;
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
@@ -0,0 +1,441 @@
/**
* @fileoverview Per-case default session group over HTTP: the case routes that
* read and write it, and the create routes that carry the case into the real
* TabLayoutService so a new session lands in its case's group in the creating
* write (POST /api/sessions, and POST /api/quick-start for local, remote and
* Docker cases).
*
* Uses app.inject(), so no real HTTP port is needed.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { Session } from '../../src/session.js';
import { TabLayoutService } from '../../src/tab-layout-service.js';
import { ownerLayoutKey } from '../../src/tab-layout-persistence.js';
import type { TabLayout } from '../../src/tab-layout.js';
import { CASES_DIR } from '../../src/web/route-helpers.js';
import { getDataDir } from '../../src/config/instance.js';
import { safeRmHomeTree } from '../mocks/index.js';
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
});
const CASE = 'cdgcase';
const DATA_FILES = ['docker-hosts.json', 'docker-cases.json', 'remote-hosts.json', 'remote-cases.json'];
interface World {
layouts: Record<string, TabLayout>;
order: string[];
defaults: Record<string, Record<string, string | null>>;
service: TabLayoutService;
}
/** The real layout service over an in-memory store, wired the way server.ts wires it. */
function attachRealLayout(harness: RouteTestHarness): World {
const world = { layouts: {}, order: [], defaults: {} } as unknown as World;
const caseDefaults = {
getCaseDefaultGroup: (owner: string, name: string) =>
world.defaults[owner] && name in world.defaults[owner] ? world.defaults[owner][name] : undefined,
setCaseDefaultGroup: (owner: string, name: string, id: string | null) => {
world.defaults[owner] = { ...(world.defaults[owner] ?? {}), [name]: id };
},
deleteCaseDefaultGroup: (owner: string, name: string) => {
if (world.defaults[owner]) delete world.defaults[owner][name];
},
};
world.service = new TabLayoutService({
store: {
getTabLayout: (owner: string) => world.layouts[owner] ?? null,
getTabLayouts: () => ({ ...world.layouts }),
getSessions: () => ({}),
getSessionOrder: () => [...world.order],
commitTabLayoutProjection: (
updates: Readonly<Record<string, TabLayout>>,
project: (latest: readonly string[]) => readonly string[]
) => {
world.order = [...project(world.order)];
Object.assign(world.layouts, structuredClone(updates));
return { layouts: structuredClone(updates), sessionOrder: [...world.order] };
},
} as never,
sessions: harness.ctx.sessions as never,
readWebviews: async () => [],
broadcast: () => {},
broadcastSessionOrder: () => {},
caseDefaults,
});
world.service.markRestorationSkipped();
(harness.ctx as unknown as { tabLayouts: TabLayoutService }).tabLayouts = world.service;
harness.ctx.addSession.mockImplementation((session: Session, placement?: object) =>
world.service.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => {
harness.ctx.sessions.set(session.id, session as never);
return () => harness.ctx.sessions.delete(session.id);
})
);
return world;
}
const OWNER = ownerLayoutKey(undefined);
const withGroups = (world: World) => {
world.layouts[OWNER] = {
version: 3,
updatedAt: '2026-10-21T00:00:00.000Z',
groups: [
{ id: 'g-core', name: 'Core', refs: [] },
{ id: 'g-ops', name: 'Ops', refs: [] },
],
ungrouped: [],
};
};
const groupOf = (layout: TabLayout, sessionId: string): string | null =>
layout.groups.find((g) => g.refs.some((r) => r.id === sessionId))?.id ?? null;
const unwrap = (raw: string) => {
const parsed = JSON.parse(raw);
return parsed.data ?? parsed;
};
describe('case default group: create routes', () => {
let harness: RouteTestHarness;
let world: World;
const caseDir = join(CASES_DIR, CASE);
const outside = join(homedir(), 'cdg-not-a-case');
beforeEach(async () => {
for (const name of DATA_FILES) await rm(join(getDataDir(), name), { force: true });
await mkdir(caseDir, { recursive: true });
await mkdir(outside, { recursive: true });
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
harness = await createRouteTestHarness(registerSessionRoutes);
harness.ctx.sessions.clear();
world = attachRealLayout(harness);
withGroups(world);
world.defaults[OWNER] = { [CASE]: 'g-ops' };
});
afterEach(async () => {
await harness.app.close();
for (const name of DATA_FILES) await rm(join(getDataDir(), name), { force: true });
safeRmHomeTree(CASES_DIR);
safeRmHomeTree(outside);
vi.restoreAllMocks();
});
it('POST /api/sessions in a case folder lands in the case default group and returns that layout', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-cdg', mode: 'shell', workingDir: caseDir },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(groupOf(world.layouts[OWNER], data.session.id)).toBe('g-ops');
// The response carries the committed layout so the browser draws the tab in place.
expect(data.tabLayout).toEqual(world.layouts[OWNER]);
});
it('POST /api/sessions in a Claude worktree of the case lands in the case default group too', async () => {
// What the Git worktrees modal sends for Claude; every other CLI goes through quick-start with the case name.
const worktree = join(caseDir, '.claude', 'worktrees', 'feat');
await mkdir(worktree, { recursive: true });
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-cdg-wt', mode: 'shell', workingDir: worktree },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(groupOf(world.layouts[OWNER], data.session.id)).toBe('g-ops');
expect(data.tabLayout).toEqual(world.layouts[OWNER]);
});
it('POST /api/sessions without a workingDir infers no case from the server cwd', async () => {
vi.spyOn(process, 'cwd').mockReturnValue(caseDir); // the server's own cwd happens to be a case folder
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-nowd', mode: 'shell' },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(data.session.workingDir).toBe(caseDir);
expect(Object.keys(data)).toEqual(['session']);
expect(groupOf(world.layouts[OWNER], data.session.id)).toBeNull();
});
it('POST /api/sessions outside any case gets no case default and keeps its response shape', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-out', mode: 'shell', workingDir: outside },
});
const data = unwrap(res.body);
expect(Object.keys(data)).toEqual(['session']);
expect(groupOf(world.layouts[OWNER], data.session.id)).toBeNull();
});
it('POST /api/sessions: an explicit tabGroupId still wins', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'w1-cdg', mode: 'shell', workingDir: caseDir, tabGroupId: 'g-core' },
});
expect(groupOf(world.layouts[OWNER], unwrap(res.body).session.id)).toBe('g-core');
});
it('POST /api/quick-start for a local case lands in the case default group', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: CASE, mode: 'shell' },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(groupOf(world.layouts[OWNER], data.sessionId)).toBe('g-ops');
expect(data.tabLayout).toEqual(world.layouts[OWNER]);
});
it('POST /api/quick-start: explicit tabGroupId wins over the case default', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: CASE, mode: 'shell', tabGroupId: 'g-core' },
});
expect(groupOf(world.layouts[OWNER], unwrap(res.body).sessionId)).toBe('g-core');
});
it('POST /api/quick-start for a remote case lands in the case default group', async () => {
await writeFile(
join(getDataDir(), 'remote-hosts.json'),
JSON.stringify([{ id: 'remote1', label: 'Remote', host: '10.0.0.5', username: 'dev' }])
);
await writeFile(
join(getDataDir(), 'remote-cases.json'),
JSON.stringify([{ name: 'cdgremote', type: 'remote', hostId: 'remote1', remotePath: '/home/dev/work' }])
);
world.defaults[OWNER].cdgremote = 'g-core';
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'cdgremote', mode: 'claude' },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(data.sessionId, res.body).toBeTruthy();
expect(groupOf(world.layouts[OWNER], data.sessionId)).toBe('g-core');
});
it('POST /api/quick-start for a Docker case lands in the case default group', async () => {
await writeFile(
join(getDataDir(), 'docker-hosts.json'),
JSON.stringify([{ id: 'docker1', label: 'Docker', image: 'codeman/agent:base' }])
);
await writeFile(
join(getDataDir(), 'docker-cases.json'),
JSON.stringify([
{ name: 'cdgdocker', type: 'docker', hostId: 'docker1', hostWorkspacePath: caseDir, container: 'codeman-cdg' },
])
);
world.defaults[OWNER].cdgdocker = 'g-core';
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'cdgdocker', mode: 'claude' },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(data.sessionId, res.body).toBeTruthy();
expect(groupOf(world.layouts[OWNER], data.sessionId)).toBe('g-core');
});
it('a deleted default group never fails creation: the session lands ungrouped and the value is cleared', async () => {
world.defaults[OWNER][CASE] = 'g-deleted';
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: CASE, mode: 'shell' },
});
expect(res.statusCode).toBe(200);
const data = unwrap(res.body);
expect(groupOf(world.layouts[OWNER], data.sessionId)).toBeNull();
expect(data.tabLayout).toBeUndefined();
expect(CASE in world.defaults[OWNER]).toBe(false);
});
it('a creation that fails after registration leaves no layout reference', async () => {
vi.mocked(Session.prototype.startShell).mockRejectedValueOnce(new Error('pane failed'));
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: CASE, mode: 'shell' },
});
expect(unwrap(res.body).sessionId).toBeUndefined();
// The route's own error path ran cleanupSession for the registered id.
expect(harness.ctx.cleanupSession).toHaveBeenCalledTimes(1);
});
});
describe('case default group: GET/PUT /api/cases/:name/default-group', () => {
let harness: RouteTestHarness;
let world: World;
const caseDir = join(CASES_DIR, CASE);
beforeEach(async () => {
for (const name of DATA_FILES) await rm(join(getDataDir(), name), { force: true });
await mkdir(caseDir, { recursive: true });
harness = await createRouteTestHarness(registerCaseRoutes);
world = attachRealLayout(harness);
withGroups(world);
});
afterEach(async () => {
await harness.app.close();
delete process.env.CODEMAN_MULTIUSER;
for (const name of DATA_FILES) await rm(join(getDataDir(), name), { force: true });
safeRmHomeTree(CASES_DIR);
vi.restoreAllMocks();
});
const get = (name = CASE) => harness.app.inject({ method: 'GET', url: `/api/cases/${name}/default-group` });
const put = (groupId: unknown, name = CASE) =>
harness.app.inject({ method: 'PUT', url: `/api/cases/${name}/default-group`, payload: { groupId } });
it('lists Ungrouped (null) and every named group of the owner', async () => {
const res = await get();
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({
success: true,
data: {
caseName: CASE,
groupId: null,
groups: [
{ id: 'g-core', name: 'Core' },
{ id: 'g-ops', name: 'Ops' },
],
},
});
});
it('saves a group and reads it back; null is an intentional Ungrouped', async () => {
expect((await put('g-ops')).json().data.groupId).toBe('g-ops');
expect(world.defaults[OWNER][CASE]).toBe('g-ops');
expect((await get()).json().data.groupId).toBe('g-ops');
expect((await put(null)).json().data.groupId).toBeNull();
expect(world.defaults[OWNER][CASE]).toBeNull();
});
it('reads the current group names, so a rename shows through (the id is stored)', async () => {
await put('g-ops');
world.layouts[OWNER].groups[1].name = 'Operations';
const data = (await get()).json().data;
expect(data.groupId).toBe('g-ops');
expect(data.groups[1]).toEqual({ id: 'g-ops', name: 'Operations' });
});
it('a deleted group reads back as Ungrouped, and the read changes nothing', async () => {
world.defaults[OWNER] = { [CASE]: 'g-gone' };
expect((await get()).json().data.groupId).toBeNull();
// GET is read-only; the next session created for the case forgets the stale id.
expect(world.defaults[OWNER][CASE]).toBe('g-gone');
});
it.each([
['an unknown group', 'g-nope', 400],
['an empty string', '', 400],
['a number', 7, 400],
])('refuses %s without changing the stored value', async (_label, groupId, status) => {
await put('g-core');
const res = await put(groupId);
expect(res.statusCode).toBe(status);
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(world.defaults[OWNER][CASE]).toBe('g-core');
});
it('404s a case that does not exist (or is not the caller’s), for both verbs', async () => {
expect((await get('nosuchcase')).statusCode).toBe(404);
expect((await put('g-core', 'nosuchcase')).statusCode).toBe(404);
expect(world.defaults[OWNER]).toBeUndefined();
});
it('accepts a remote case and a Docker case by name', async () => {
await writeFile(
join(getDataDir(), 'remote-hosts.json'),
JSON.stringify([{ id: 'remote1', label: 'Remote', host: '10.0.0.5', username: 'dev' }])
);
await writeFile(
join(getDataDir(), 'remote-cases.json'),
JSON.stringify([{ name: 'cdgremote', type: 'remote', hostId: 'remote1', remotePath: '/home/dev/work' }])
);
await writeFile(
join(getDataDir(), 'docker-cases.json'),
JSON.stringify([{ name: 'cdgdocker', type: 'docker', hostId: 'docker1', hostWorkspacePath: caseDir }])
);
expect((await put('g-core', 'cdgremote')).statusCode).toBe(200);
expect((await put('g-ops', 'cdgdocker')).statusCode).toBe(200);
expect(world.defaults[OWNER]).toMatchObject({ cdgremote: 'g-core', cdgdocker: 'g-ops' });
});
it('DELETE /api/cases/:name forgets the mapping', async () => {
await put('g-ops');
const res = await harness.app.inject({ method: 'DELETE', url: `/api/cases/${CASE}` });
expect(res.statusCode).toBe(200);
expect(CASE in world.defaults[OWNER]).toBe(false);
});
});
describe('case default group: multi-user scoping', () => {
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
vi.restoreAllMocks();
});
it("stores a non-admin's choice under their own owner key and validates against their own groups", async () => {
process.env.CODEMAN_MULTIUSER = '1';
const harness = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
try {
harness.ctx.sessions.clear();
const world = attachRealLayout(harness);
world.layouts.alice = {
version: 1,
updatedAt: '2026-10-21T00:00:00.000Z',
groups: [{ id: 'g-alice', name: 'Alice', refs: [] }],
ungrouped: [],
};
world.layouts.bob = {
version: 1,
updatedAt: '2026-10-21T00:00:00.000Z',
groups: [{ id: 'g-bob', name: 'Bob', refs: [] }],
ungrouped: [],
};
const { resolveCasesDir } = await import('../../src/web/route-helpers.js');
const bobCases = resolveCasesDir({ username: 'bob', role: 'user' } as never);
await mkdir(join(bobCases, 'bobcase'), { recursive: true });
const read = await harness.app.inject({ method: 'GET', url: '/api/cases/bobcase/default-group' });
expect(read.json().data.groups).toEqual([{ id: 'g-bob', name: 'Bob' }]);
const foreign = await harness.app.inject({
method: 'PUT',
url: '/api/cases/bobcase/default-group',
payload: { groupId: 'g-alice' },
});
expect(foreign.statusCode).toBe(400);
const own = await harness.app.inject({
method: 'PUT',
url: '/api/cases/bobcase/default-group',
payload: { groupId: 'g-bob' },
});
expect(own.statusCode).toBe(200);
expect(world.defaults).toEqual({ bob: { bobcase: 'g-bob' } });
safeRmHomeTree(join(bobCases, '..'));
} finally {
await harness.app.close();
}
});
});
@@ -0,0 +1,94 @@
/**
* @fileoverview A backup pass reads the backup folder once: one directory listing and one read per
* manifest, shared by the recorded-retention fallback, the change check and the prune. Each used to
* list the folder itself, which on a network folder meant every manifest read three times every five
* minutes (found in review of #661).
*
* ⚠️ test/setup.ts gives the whole FILE one temp HOME. Port: N/A (no server).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
const rec = vi.hoisted(() => ({ dir: '', calls: [] as string[] }));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const record = <F extends (...a: never[]) => unknown>(name: string, real: F): F =>
((path: unknown, ...rest: unknown[]) => {
if (rec.dir && String(path).startsWith(rec.dir)) rec.calls.push(`${name} ${String(path)}`);
return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest);
}) as unknown as F;
const wrapped = {
...actual,
readdirSync: record('readdirSync', actual.readdirSync),
readFileSync: record('readFileSync', actual.readFileSync),
};
return { ...wrapped, default: wrapped };
});
import { runConfigBackup } from '../../src/web/routes/config-backup-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { getDataDir } from '../../src/config/instance.js';
import { createConfigBackup, defaultConfigBackupDir, listConfigBackups } from '../../src/config-backup.js';
const dir = () => defaultConfigBackupDir(getDataDir());
const DAY = 86_400_000;
const BASE = Date.UTC(2025, 0, 1);
beforeEach(() => {
rec.dir = '';
rec.calls.length = 0;
rmSync(dir(), { recursive: true, force: true });
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
});
afterEach(() => {
rec.dir = '';
rmSync(dir(), { recursive: true, force: true });
rmSync(SETTINGS_PATH, { force: true });
});
describe('one backup pass', () => {
it('lists the folder once and reads each manifest once', async () => {
// Only the OLDEST snapshot recorded a retention, so the fallback has to look through every one.
for (let i = 0; i < 6; i++) {
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: `v${i}` }));
createConfigBackup({
dataDir: getDataDir(),
backupDir: dir(),
reason: 'auto',
now: BASE + i * DAY,
...(i === 0 && { keepCount: 100, keepDays: 0 }),
});
}
// A change (so the pass writes a snapshot and then prunes) and no retention keys (the fallback reads).
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'changed' }));
rec.dir = dir();
const out = await runConfigBackup('auto');
rec.dir = '';
expect(out?.result.status).toBe('created');
expect(out?.removed).toEqual([]); // the recorded 100 / no age limit, not the defaults
expect(rec.calls.filter((c) => c.startsWith('readdirSync'))).toHaveLength(1);
const manifestReads = rec.calls.filter((c) => c.startsWith('readFileSync') && c.endsWith('manifest.json'));
expect(manifestReads).toHaveLength(6);
expect(new Set(manifestReads).size).toBe(6);
expect(listConfigBackups(dir())).toHaveLength(7);
});
it('counts the snapshot it just made when it prunes', async () => {
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 2, configBackupKeepDays: 0, v: 0 }));
for (let i = 0; i < 3; i++) {
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 2, configBackupKeepDays: 0, v: i }));
createConfigBackup({ dataDir: getDataDir(), backupDir: dir(), reason: 'auto', now: BASE + i * DAY });
}
const before = listConfigBackups(dir()).map((b) => b.id);
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 2, configBackupKeepDays: 0, v: 9 }));
const out = await runConfigBackup('auto');
expect(out?.result.status).toBe('created');
const kept = listConfigBackups(dir()).map((b) => b.id);
// keep 2: the new one and the newest before it; the two older ones go
expect(kept).toEqual([(out?.result as { id: string }).id, before[0]]);
expect(out?.removed.sort()).toEqual([before[1], before[2]].sort());
});
});
+330
View File
@@ -0,0 +1,330 @@
/**
* @fileoverview /api/config-backups: list, back up now, restore. Settings are read fresh from
* settings.json, so a folder or retention change applies on the next request.
*
* ⚠️ test/setup.ts gives the whole FILE one temp HOME (and so one data dir). Port: N/A (inject()).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerConfigBackupRoutes, runConfigBackup } from '../../src/web/routes/config-backup-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { getDataDir } from '../../src/config/instance.js';
import { createConfigBackup, defaultConfigBackupDir, listConfigBackups } from '../../src/config-backup.js';
import { intentStore } from '../../src/intent-store.js';
let custom: string;
const settings = (extra: Record<string, unknown> = {}) => {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'before', ...extra }));
};
beforeEach(() => {
custom = mkdtempSync(join(tmpdir(), 'cfg-backup-route-'));
rmSync(defaultConfigBackupDir(getDataDir()), { recursive: true, force: true });
settings();
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(custom, { recursive: true, force: true });
rmSync(SETTINGS_PATH, { force: true });
rmSync(defaultConfigBackupDir(getDataDir()), { recursive: true, force: true });
});
describe('/api/config-backups', () => {
it('lists the effective settings, the default folder and the tracked files', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const res = await app.inject({ method: 'GET', url: '/api/config-backups' });
expect(res.statusCode).toBe(200);
const d = res.json().data;
expect(d.settings).toEqual({ enabled: true, dir: null, keepCount: 20, keepDays: 30 });
expect(d.dir).toBe(defaultConfigBackupDir(getDataDir()));
expect(d.tracked).toContain('settings.json');
expect(d.backups).toEqual([]);
});
it('backs up now into the default folder and lists it', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const made = await app.inject({ method: 'POST', url: '/api/config-backups' });
expect(made.statusCode).toBe(200);
expect(made.json().data.status).toBe('created');
const list = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data;
expect(list.backups).toHaveLength(1);
expect(list.backups[0].reason).toBe('manual');
expect(list.backups[0].files.map((f: { name: string }) => f.name)).toContain('settings.json');
// Names and sizes only, never content.
expect(JSON.stringify(list)).not.toContain('before');
});
it('follows a custom folder and retention from settings on the next request', async () => {
const dir = join(custom, 'snap');
settings({ configBackupDir: dir, configBackupKeepCount: 2, configBackupKeepDays: 0 });
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
for (let i = 0; i < 4; i++) {
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupDir: dir, configBackupKeepCount: 2, i }));
await app.inject({ method: 'POST', url: '/api/config-backups' });
await new Promise((r) => setTimeout(r, 5));
}
const d = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data;
expect(d.dir).toBe(dir);
expect(d.backups).toHaveLength(2);
expect(existsSync(defaultConfigBackupDir(getDataDir()))).toBe(false);
});
it('restores a backup, reports what needs a restart and keeps a pre-restore copy', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
writeFileSync(join(dirname(SETTINGS_PATH), 'clis.json'), '{"schemaVersion":1,"clis":{}}', { mode: 0o600 });
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'polluted' }));
writeFileSync(join(dirname(SETTINGS_PATH), 'clis.json'), '{"clis":{"evil":{}}}');
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
expect(res.statusCode).toBe(200);
const d = res.json().data;
expect(d.restored).toEqual(expect.arrayContaining(['settings.json', 'clis.json']));
expect(d.restartRequired).not.toContain('clis.json'); // reloaded by the restore itself
expect(d.restartRequired).toContain('settings.json'); // its watcher toggles are not re-run by a restore
expect(d.safetySnapshot).toBeTruthy();
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).displayName).toBe('before');
});
it('a restore that fails partway names what it restored, what failed and the pre-restore copy', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const clis = join(dirname(SETTINGS_PATH), 'clis.json');
writeFileSync(clis, '{"schemaVersion":1,"clis":{}}', { mode: 0o600 });
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'polluted' }));
// A file the server cannot replace: settings.json (first in the list) goes back, clis.json cannot.
rmSync(clis);
mkdirSync(clis);
try {
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
expect(res.statusCode).toBe(500);
const body = res.json();
expect(body).toMatchObject({ success: false, errorCode: 'OPERATION_FAILED' });
expect(body.error).toContain('clis.json');
expect(body.data.restored).toContain('settings.json');
expect(body.data.failed.map((f: { name: string }) => f.name)).toEqual(['clis.json']);
expect(body.data.restartRequired).toContain('settings.json');
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).displayName).toBe('before');
// The undo copy exists, holds the state the restore replaced, and is the one named.
const safety = body.data.safetySnapshot;
expect(safety).toBeTruthy();
expect(body.error).toContain(safety);
const listed = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data.backups;
expect(listed.find((b: { id: string }) => b.id === safety)?.reason).toBe('pre-restore');
} finally {
rmSync(clis, { recursive: true, force: true });
}
});
it('a restored intents.json is not reverted by the next captured prompt', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const dir = mkdtempSync(join(tmpdir(), 'cfg-backup-intent-'));
try {
intentStore.recordPrompt(undefined, dir, 's1', 'first prompt about the build pipeline');
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
intentStore.recordPrompt(undefined, dir, 's1', 'second prompt about the build pipeline');
await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
intentStore.recordPrompt(undefined, dir, 's1', 'third prompt about the build pipeline');
expect(intentStore.getProfile(undefined, dir).recentPrompts).toHaveLength(2);
} finally {
intentStore.deleteProfile(undefined, dir);
rmSync(dir, { recursive: true, force: true });
}
});
it('refuses a custom folder whose parent is missing instead of creating it', async () => {
settings({ configBackupDir: join(custom, 'no', 'such', 'dir') });
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const res = await app.inject({ method: 'POST', url: '/api/config-backups' });
expect(res.statusCode).toBe(503);
expect(existsSync(join(custom, 'no'))).toBe(false);
});
it('answers 404 for an unknown backup and 404 for a traversal attempt', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
for (const id of ['cfg-19990101-000000', '..%2F..%2Fetc', 'photos']) {
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
expect(res.statusCode, id).toBe(404);
}
});
it('is admin only in multi-user mode: a non-admin is refused on every verb', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerConfigBackupRoutes, {
authUser: { username: 'bob', role: 'user' },
});
for (const [method, url] of [
['GET', '/api/config-backups'],
['POST', '/api/config-backups'],
['POST', '/api/config-backups/cfg-20261001-120000/restore'],
['GET', '/api/config-backups/diff?from=cfg-20261001-120000'],
] as const) {
expect((await app.inject({ method, url })).statusCode, `${method} ${url}`).toBe(403);
}
});
it('multi-user: a restore is written to the admin audit log', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerConfigBackupRoutes, {
authUser: { username: 'root', role: 'admin' },
});
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
const auditPath = join(getDataDir(), 'admin-audit.jsonl');
const written = () => existsSync(auditPath) && readFileSync(auditPath, 'utf8').includes('config_backup_restore');
for (let i = 0; i < 40 && !written(); i++) await new Promise((r) => setTimeout(r, 25));
const last = JSON.parse(readFileSync(auditPath, 'utf8').trim().split('\n').pop()!);
expect(last).toMatchObject({ admin: 'root', action: 'config_backup_restore', target: id });
expect(last.detail.restored).toContain('settings.json');
rmSync(auditPath, { force: true });
});
it('multi-user: an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerConfigBackupRoutes, {
authUser: { username: 'root', role: 'admin' },
});
expect((await app.inject({ method: 'GET', url: '/api/config-backups' })).json().success).toBe(true);
});
});
describe('GET /api/config-backups/diff', () => {
it('compares a backup with the running config, defaulting `to` to current', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const made = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data;
settings({ displayName: 'after' });
const res = await app.inject({ method: 'GET', url: `/api/config-backups/diff?from=${made.id}` });
expect(res.statusCode).toBe(200);
const d = res.json().data;
expect(d.to).toBe('current');
expect(d.files.find((f: { name: string }) => f.name === 'settings.json').changes).toEqual([
{ path: 'displayName', kind: 'changed', before: '"before"', after: '"after"' },
]);
});
it('400 without `from`, 404 for an unknown backup', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
expect((await app.inject({ method: 'GET', url: '/api/config-backups/diff' })).statusCode).toBe(400);
const missing = await app.inject({ method: 'GET', url: '/api/config-backups/diff?from=cfg-20200101-000000' });
expect(missing.statusCode).toBe(404);
});
});
describe('retention survives an unreadable or reset settings.json', () => {
const DAY = 86_400_000;
const BASE = Date.UTC(2025, 0, 1);
const dir = () => defaultConfigBackupDir(getDataDir());
/** 30 snapshots a day apart, made under a long-history retention (100 copies, no age limit). */
function seed(withKeys = true) {
settings({ configBackupKeepCount: 100, configBackupKeepDays: 0 });
for (let i = 0; i < 30; i++) {
writeFileSync(
SETTINGS_PATH,
JSON.stringify(
withKeys
? { displayName: `v${i}`, configBackupKeepCount: 100, configBackupKeepDays: 0 }
: { displayName: `v${i}` }
)
);
createConfigBackup({
dataDir: getDataDir(),
backupDir: dir(),
reason: 'auto',
now: BASE + i * DAY,
keepCount: 100,
keepDays: 0,
});
}
expect(listConfigBackups(dir())).toHaveLength(30);
}
it('a settings.json that does not parse still gets a snapshot but prunes nothing', async () => {
seed();
writeFileSync(SETTINGS_PATH, '{ "displayName": "half-written", }');
const out = await runConfigBackup('auto');
expect(out?.result.status).toBe('created');
expect(out?.removed).toEqual([]);
expect(listConfigBackups(dir())).toHaveLength(31);
});
it('a reset settings.json prunes with the retention the newest snapshot recorded, not the defaults', async () => {
seed();
writeFileSync(SETTINGS_PATH, '{}');
const out = await runConfigBackup('auto');
expect(out?.result.status).toBe('created');
// aged manifests (a year old) and keepDays 0: the defaults (20 copies, 30 days) would leave 20
expect(listConfigBackups(dir())).toHaveLength(31);
});
it.each([true, false])(
'a restore after a reset keeps the retention (the backup %s the four keys)',
async (withKeys) => {
seed(withKeys);
const oldest = listConfigBackups(dir()).at(-1)!.id;
writeFileSync(SETTINGS_PATH, '{}'); // the reset
expect((await runConfigBackup('auto'))?.removed).toEqual([]);
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${oldest}/restore` });
expect(res.statusCode).toBe(200);
// the restored file carries the retention, or the recorded one still applies via the pre-restore copy
const out = await runConfigBackup('auto');
expect(out?.removed).toEqual([]);
expect(listConfigBackups(dir()).length).toBeGreaterThanOrEqual(32);
const status = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data.settings;
expect(status).toMatchObject({ keepCount: 100, keepDays: 0 }); // what the form shows is what the pass uses
}
);
it("re-entering a custom folder after a reset finds that folder's recorded retention", async () => {
const folder = join(custom, 'kept');
settings({ configBackupDir: folder, configBackupKeepCount: 100, configBackupKeepDays: 0 });
for (let i = 0; i < 30; i++) {
createConfigBackup({
dataDir: getDataDir(),
backupDir: folder,
reason: 'auto',
force: true,
now: BASE + i * DAY,
keepCount: 100,
keepDays: 0,
});
}
writeFileSync(SETTINGS_PATH, '{}'); // the reset drops the folder too
await runConfigBackup('auto'); // goes to the default folder, which holds none of those manifests
// the user enters the folder again and saves; the form no longer sends retention it only displayed
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupDir: folder, configBackupEnabled: true }));
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const reported = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data.settings;
expect(reported).toMatchObject({ keepCount: 100, keepDays: 0 });
const out = await runConfigBackup('auto');
expect(out?.removed).toEqual([]);
expect(listConfigBackups(folder).length).toBeGreaterThanOrEqual(30);
});
it('only the retention key the file lacks falls back to the recorded one', async () => {
seed();
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepDays: 3650 }));
await runConfigBackup('auto');
expect(listConfigBackups(dir())).toHaveLength(31); // keepCount 100 from the manifests, not the default 20
});
it('a settings.json that sets the retention still wins', async () => {
seed();
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 5, configBackupKeepDays: 0 }));
await runConfigBackup('auto');
expect(listConfigBackups(dir())).toHaveLength(5);
});
it('listing never creates config-backup-id', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const idFile = join(getDataDir(), 'config-backup-id');
rmSync(idFile, { force: true });
expect((await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data.backups).toEqual([]);
expect(existsSync(idFile)).toBe(false);
});
});
@@ -0,0 +1,78 @@
/**
* @fileoverview A custom backup folder that stops answering (a network mount) must never be listed or
* read before the bounded probe says it is there: GET, Back up now, diff and restore answer 503 without
* touching it.
* With only `configBackupDir` in settings.json the recorded-retention fallback would otherwise
* `readdirSync` the folder first (found in review of #661).
*
* ⚠️ test/setup.ts gives the whole FILE one temp HOME. Port: N/A (inject()).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
const dead = vi.hoisted(() => ({ dir: '', touched: [] as string[] }));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const record = <F extends (...a: never[]) => unknown>(name: string, real: F): F =>
((path: unknown, ...rest: unknown[]) => {
if (dead.dir && String(path).startsWith(dead.dir)) dead.touched.push(`${name} ${String(path)}`);
return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest);
}) as unknown as F;
const wrapped = {
...actual,
readdirSync: record('readdirSync', actual.readdirSync),
readFileSync: record('readFileSync', actual.readFileSync),
statSync: record('statSync', actual.statSync),
lstatSync: record('lstatSync', actual.lstatSync),
existsSync: record('existsSync', actual.existsSync),
};
return { ...wrapped, default: wrapped };
});
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: async (path: string, options?: { pastCap?: boolean }) =>
dead.dir && path.startsWith(dead.dir) ? 'unknown' : actual.probePathKind(path, options),
};
});
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerConfigBackupRoutes } from '../../src/web/routes/config-backup-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { getDataDir } from '../../src/config/instance.js';
import { configBackupOrigin } from '../../src/config-backup.js';
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'cfg-backup-dead-'));
dead.dir = join(root, 'mount');
dead.touched.length = 0;
configBackupOrigin(getDataDir()); // an instance that already has an id: the fallback has something to look for
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
// Only the folder: no retention keys, the shape that makes the recorded-retention fallback want to read it.
writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupDir: dead.dir }));
});
afterEach(() => {
dead.dir = '';
rmSync(root, { recursive: true, force: true });
rmSync(SETTINGS_PATH, { force: true });
});
describe('a custom backup folder that does not answer', () => {
it.each([
['GET', '/api/config-backups'],
['POST', '/api/config-backups'],
['GET', '/api/config-backups/diff?from=cfg-20261001-120000'],
['POST', '/api/config-backups/cfg-20261001-120000/restore'],
] as const)('%s %s answers 503 without reading the folder', async (method, url) => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const res = await app.inject({ method, url });
expect(res.statusCode).toBe(503);
expect(dead.touched).toEqual([]);
});
});
+149
View File
@@ -17,6 +17,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { approvalInbox } from '../../src/web/approval-inbox.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js';
@@ -98,6 +99,103 @@ describe('hook-event-routes', () => {
);
});
/**
* The prompt-cache keep-warm's own turn (prompt-cache-keepwarm.ts) ends with the same stop
* hook and idle prompt as a real one. While the session marks the turn as its keep-alive,
* both open pre-acknowledged: the broadcast says why, and nothing is pushed.
*/
it('opens the stop and idle prompt of a keep-alive turn pre-acknowledged and pushes nothing', async () => {
harness.ctx._session.keepAliveTurnActive = true;
for (const event of ['stop', 'idle_prompt']) {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event, sessionId: harness.ctx._sessionId, data: null },
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
`hook:${event}`,
expect.objectContaining({ sessionId: harness.ctx._sessionId, acknowledgedReason: 'cache keep-alive' })
);
}
expect(harness.ctx.sendPushNotifications).not.toHaveBeenCalled();
// The inbox item is what a reload seeds tab alerts from, so it must open acknowledged too.
const item = approvalInbox.getForSession(harness.ctx._sessionId);
approvalInbox.resolveForSession(harness.ctx._sessionId, 'dismissed');
expect(item?.acknowledgedReason).toBe('cache keep-alive');
// acknowledgedAt is what approvals-ui.js reads to skip the alert on a reload.
expect(item?.acknowledgedAt).toBe(item?.createdAt);
});
/**
* Only a human spends an idle alert (CLAUDE.md, Approvals Inbox). A keep-alive turn that
* starts while an idle item is pending must leave that item exactly as it found it: its stop
* does not resolve it, and its idle prompt does not replace it, so an unseen alert stays
* unseen (and pushes nothing a second time) and one a person looked at keeps saying so.
*/
it.each([
{ seen: false, label: 'an unseen' },
{ seen: true, label: 'a seen' },
])('leaves $label idle item pending through a keep-alive turn', async ({ seen }) => {
const sessionId = harness.ctx._sessionId;
const hook = (event: string) =>
harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event, sessionId, data: null } });
await hook('idle_prompt');
const before = approvalInbox.getForSession(sessionId);
expect(before?.acknowledgedAt).toBeUndefined();
if (seen) approvalInbox.acknowledge(sessionId);
const acknowledgedAt = before?.acknowledgedAt;
harness.ctx.sendPushNotifications.mockClear();
harness.ctx.broadcast.mockClear();
harness.ctx._session.keepAliveTurnActive = true;
await hook('stop');
await hook('idle_prompt');
const after = approvalInbox.getForSession(sessionId);
approvalInbox.resolveForSession(sessionId, 'dismissed');
expect(after?.id).toBe(before?.id);
expect(after?.acknowledgedAt).toBe(acknowledgedAt);
expect(after?.acknowledgedReason).toBeUndefined();
expect(harness.ctx.sendPushNotifications).not.toHaveBeenCalled();
// The page's _onHookStop and _onHookIdlePrompt return on the reason, leaving the tab alert.
for (const event of ['stop', 'idle_prompt']) {
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
`hook:${event}`,
expect.objectContaining({ acknowledgedReason: 'cache keep-alive' })
);
}
});
it("a keep-alive turn's idle prompt ends the keep-alive window, its stop does not", async () => {
const sessionId = harness.ctx._sessionId;
const hook = (event: string) =>
harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event, sessionId, data: null } });
harness.ctx._session.keepAliveTurnActive = true;
await hook('stop');
expect(harness.ctx._session.keepAliveTurnActive).toBe(true);
await hook('idle_prompt');
approvalInbox.resolveForSession(sessionId, 'dismissed');
expect(harness.ctx._session.keepAliveTurnActive).toBe(false);
});
it("a keep-alive turn's stop still resolves a pending permission dialog", async () => {
const sessionId = harness.ctx._sessionId;
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'permission_prompt', sessionId, data: null },
});
expect(approvalInbox.getForSession(sessionId)?.kind).toBe('permission');
harness.ctx._session.keepAliveTurnActive = true;
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'stop', sessionId, data: null },
});
expect(approvalInbox.getForSession(sessionId)).toBeUndefined();
});
it('sends push notifications for hook events', async () => {
const res = await harness.app.inject({
method: 'POST',
@@ -144,6 +242,27 @@ describe('hook-event-routes', () => {
expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(session);
});
/**
* The hook's prompt text is what tells a person typing in an attached tmux client apart
* from the prompt-cache keep-alive (Session.markPromptSubmitted ends the keep-alive window
* for anything but the nudge), so the route must hand it over, and nothing else.
*/
it("hands the prompt_submitted hook's prompt text to the session", async () => {
const session = harness.ctx._session;
for (const data of [
{ session_id: 'conv-1', prompt: 'fix the login bug' },
{ session_id: 'conv-1', prompt: 42 },
{ session_id: 'conv-1' },
]) {
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'prompt_submitted', sessionId: harness.ctx._sessionId, data },
});
}
expect(session.submittedPrompts).toEqual(['fix the login bug', undefined, undefined]);
});
it('records a /clear successor in the chain and persists it, without duplicating a repeat', async () => {
const session = harness.ctx._session;
const submit = async (conversationId: string) =>
@@ -308,6 +427,36 @@ describe('hook-event-routes', () => {
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object));
});
/**
* The run summary files every `stop` as a warning, and the Away digest lists warnings under
* Needs Attention, so a keep-alive turn's hooks would add a row there (and to the session's
* Summary timeline) for every nudge. They are Codeman's own turn, not the agent's.
*/
it("keeps a keep-alive turn's stop and idle prompt out of the run summary", async () => {
const sessionId = harness.ctx._sessionId;
const mockTracker = { recordHookEvent: vi.fn() };
harness.ctx.runSummaryTrackers.set(sessionId, mockTracker as never);
harness.ctx._session.keepAliveTurnActive = true;
for (const event of ['stop', 'idle_prompt']) {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event, sessionId, data: null },
});
expect(res.statusCode).toBe(200);
}
approvalInbox.resolveForSession(sessionId, 'dismissed');
expect(mockTracker.recordHookEvent).not.toHaveBeenCalled();
// The next real turn is recorded as before.
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'stop', sessionId, data: null },
});
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object));
});
it('starts transcript watcher when transcript_path is provided', async () => {
const res = await harness.app.inject({
method: 'POST',
@@ -316,6 +316,47 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
expect(ctx.sessions.size).toBe(2);
});
describe('parallel slots share the context (the quick-start path has its own slot branch)', () => {
const slotEndpoint = (contextLength: number, slots: number): CustomModelHost => ({
id: 'ep-slots',
label: 'multi-slot box',
baseUrl: 'http://192.168.1.52:8080',
apiKey: 'k',
modelContextLengths: { m: contextLength },
modelSlotCounts: { m: slots },
});
it('warns when the context divided across the slots is below the floor, and reports the slot count', async () => {
await writeCustomModelHosts(getDataDir(), [ENDPOINT, slotEndpoint(131072, 4)]); // ~33k per slot
const res = await quickStart({
caseName: 'cm-slots-warn',
mode: 'claude',
customModel: { endpointId: 'ep-slots', modelId: 'm' },
});
const body = res.json();
expect(body.requiresContextWarning).toBe(true);
expect(body.contextLength).toBe(131072);
expect(body.slotCount).toBe(4);
expect(ctx.sessions.size).toBe(1); // nothing was created
});
it('launches when every slot can hold the floor', async () => {
await writeCustomModelHosts(getDataDir(), [ENDPOINT, slotEndpoint(262144, 4)]); // 65,536 per slot
const res = await quickStart({
caseName: 'cm-slots-ok',
mode: 'claude',
customModel: { endpointId: 'ep-slots', modelId: 'm' },
});
expect(res.statusCode).toBe(200);
expect(res.json().requiresContextWarning).toBeUndefined();
expect(ctx.sessions.size).toBe(2);
});
});
it('does not warn when nothing about context was discovered', async () => {
const res = await quickStart({
caseName: 'cm-no-ctx-data',
+2
View File
@@ -476,6 +476,8 @@ describe('ralph-routes', () => {
payload: { taskDescription: 'test task', completionPhrase: 'DONE', caseName: 'registration-order' },
});
await vi.waitFor(() => expect(harness.ctx.addSession).toHaveBeenCalledTimes(1));
// The case rides the registration, so the session lands in the case's default session group.
expect(harness.ctx.addSession).toHaveBeenCalledWith(expect.anything(), { caseName: 'registration-order' });
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
expect(harness.ctx.setupSessionListeners).not.toHaveBeenCalled();
+72
View File
@@ -449,6 +449,78 @@ describe('POST /api/sessions/:id/custom-model', () => {
expect(session.restartCli).not.toHaveBeenCalled();
});
// llama-server splits its context across --parallel slots, and Claude Code sends several
// requests at once, so a context that clears the floor as a whole can still fail per slot.
describe('parallel slots share the context', () => {
const slotEndpoint = (contextLength: number, slots?: number): CustomModelHost => ({
id: 'ep-slots',
label: 'multi-slot box',
baseUrl: 'http://192.168.1.52:8080',
apiKey: 'k',
modelContextLengths: { m: contextLength },
...(slots === undefined ? {} : { modelSlotCounts: { m: slots } }),
});
async function apply(endpoint: CustomModelHost) {
const { app, ctx } = await setup();
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, endpoint]);
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'claude';
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: endpoint.id, modelId: 'm' },
});
return { body: res.json(), session };
}
it('warns when the context divided across the slots is below the floor, and reports the slot count', async () => {
// the measured case: ~131k shared by 4 slots is ~33k each, under the 40k a request needs
const { body, session } = await apply(slotEndpoint(131072, 4));
expect(body.requiresContextWarning).toBe(true);
expect(body.contextLength).toBe(131072);
expect(body.slotCount).toBe(4);
expect(session.setCustomModel).not.toHaveBeenCalled();
});
it('divides the discovered context across the slots even when it clears the floor as a whole', async () => {
// 65,536 over 4 slots is 16,384 each: nothing the server reports can widen this pool
const { body } = await apply(slotEndpoint(65536, 4));
expect(body.requiresContextWarning).toBe(true);
expect(body.contextLength).toBe(65536);
expect(body.slotCount).toBe(4);
});
it('does not warn once every slot can hold the floor', async () => {
const { body, session } = await apply(slotEndpoint(262144, 4)); // 65,536 each
expect(body.requiresContextWarning).toBeUndefined();
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
});
it('does not warn for a single slot with the same context that failed across four', async () => {
const { body, session } = await apply(slotEndpoint(131072, 1));
expect(body.requiresContextWarning).toBeUndefined();
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
});
it('an unknown slot count is treated as one slot, never guessed', async () => {
const { body } = await apply(slotEndpoint(131072));
expect(body.requiresContextWarning).toBeUndefined();
});
it('omits slotCount from the warning when the context is too small on its own with a single slot', async () => {
const { body } = await apply(slotEndpoint(16384, 1));
expect(body.requiresContextWarning).toBe(true);
expect(body.slotCount).toBeUndefined();
});
});
it('applies once confirmed, skipping the context check the second time', async () => {
const { app, ctx } = await setup();
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, SMALL_CTX_ENDPOINT]);
@@ -0,0 +1,76 @@
/**
* `POST /api/sessions/:id/keep-warm` (session-routes.ts): the per-session opt-in for the
* prompt-cache keep-warm. Stores the choice, persists and broadcasts the session, asks the
* scheduler to re-evaluate, clears the choice on `null`, and refuses a CLI with no cache
* telemetry. app.inject(), no port.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createHarness(): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('POST /api/sessions/:id/keep-warm', () => {
let harness: Harness;
beforeEach(async () => {
harness = await createHarness();
});
afterEach(async () => {
await harness.app.close();
});
const post = (payload: unknown) =>
harness.app.inject({ method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/keep-warm`, payload });
it('stores the choice, persists, broadcasts and re-evaluates', async () => {
const res = await post({ enabled: true });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.keepWarm).toMatchObject({ enabled: true });
expect(harness.ctx._session.keepWarmEnabled).toBe(true);
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
expect(harness.ctx.keepWarm.schedule).toHaveBeenCalledWith(harness.ctx._sessionId);
// The re-evaluation runs first, so the broadcast state and the response carry the new status.
expect(harness.ctx.keepWarm.schedule.mock.invocationCallOrder[0]).toBeLessThan(
harness.ctx.persistSessionState.mock.invocationCallOrder[0]
);
});
it('clears the choice on null so the app-wide default applies again', async () => {
await post({ enabled: false });
const res = await post({ enabled: null });
expect(res.statusCode).toBe(200);
expect(harness.ctx._session.keepWarmEnabled).toBeUndefined();
expect(JSON.parse(res.body).data.keepWarm.enabled).toBeNull();
});
it('rejects a body without enabled', async () => {
expect((await post({})).statusCode).toBe(400);
});
it('refuses a CLI that reports no cache telemetry', async () => {
harness.ctx._session.mode = 'shell';
const res = await post({ enabled: true });
// This harness has no envelope hook, so the refusal shows in the body, not the status.
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(harness.ctx._session.keepWarmEnabled).toBeUndefined();
expect(harness.ctx.keepWarm.schedule).not.toHaveBeenCalled();
});
});
+3 -1
View File
@@ -104,7 +104,9 @@ describe('create routes: tabGroupId', () => {
});
expect(res.statusCode).toBe(200);
const data = body(res.body);
expect(ctx.addSession.mock.calls[0][1]).toEqual({ tabGroupId: 'g1' });
// quick-start always names its case too, for the case's default group (the
// explicit group still wins inside TabLayoutService).
expect(ctx.addSession.mock.calls[0][1]).toEqual({ caseName: 'tabgroupcase', tabGroupId: 'g1' });
expect(data.tabLayout.groups[0].refs).toEqual([{ kind: 'session', id: data.sessionId }]);
const plain = await app.inject({
@@ -48,7 +48,13 @@ const { EXISTING_SETTINGS, subagentWatcher, imageWatcher, workflowRunWatcher } =
};
return {
// Persisted settings.json for these tests: two watchers ON, subagent tracking OFF.
EXISTING_SETTINGS: { subagentTrackingEnabled: false, imageWatcherEnabled: true, showUltracodeAgents: true },
EXISTING_SETTINGS: {
subagentTrackingEnabled: false,
imageWatcherEnabled: true,
showUltracodeAgents: true,
promptCacheKeepWarm: false,
promptCacheKeepWarmMaxHours: 2,
},
subagentWatcher: makeWatcher(false),
imageWatcher: makeWatcher(true),
workflowRunWatcher: makeWatcher(true),
@@ -143,4 +149,22 @@ describe('PUT /api/settings — partial body must not reset service toggles', ()
// showUltracodeAgents is still true in settings.json, so it stays up.
expect(workflowRunWatcher.stop).not.toHaveBeenCalled();
});
/**
* The browser sends both keep-warm keys on every App Settings save, so testing for the keys in
* the body re-read the settings and re-evaluated every session on each save of anything.
*/
it('re-evaluates the prompt-cache keep-warm only when its default or cap changes', async () => {
const put = (payload: Record<string, unknown>) =>
harness.app.inject({ method: 'PUT', url: '/api/settings', payload });
expect((await put({ promptCacheKeepWarm: false, promptCacheKeepWarmMaxHours: 2 })).statusCode).toBe(200);
expect(harness.ctx.keepWarm.rescheduleAll).not.toHaveBeenCalled();
expect((await put({ promptCacheKeepWarm: false, promptCacheKeepWarmMaxHours: 3 })).statusCode).toBe(200);
expect(harness.ctx.keepWarm.rescheduleAll).toHaveBeenCalledTimes(1);
expect((await put({ promptCacheKeepWarm: true })).statusCode).toBe(200);
expect(harness.ctx.keepWarm.rescheduleAll).toHaveBeenCalledTimes(2);
});
});
+32
View File
@@ -6,6 +6,7 @@ import {
detachSessionListeners,
} from '../src/web/session-listener-wiring.js';
import { SseEvent } from '../src/web/sse-events.js';
import { approvalInbox } from '../src/web/approval-inbox.js';
describe('session listener wiring', () => {
it('forwards the attachment request source through registerAttachment', async () => {
@@ -62,6 +63,37 @@ describe('session listener wiring', () => {
expect(session.listenerCount('displayModelChanged')).toBe(0);
});
/**
* A working transition resolves a pending idle item (input arrived), except during Codeman's
* own keep-alive turn: only a human spends an idle alert. Both transitions of that turn are
* flagged `keepAlive`, which is what keeps the page's stuck-detection timer out of it.
*/
it.each([
{ keepAlive: false, resolved: true },
{ keepAlive: true, resolved: false },
])('working and idle with keepAlive=$keepAlive', ({ keepAlive, resolved }) => {
const session = new Session({ id: `wiring-keepalive-${keepAlive}`, workingDir: '/tmp', mode: 'claude' });
const broadcast = vi.fn();
const deps = {
broadcast,
broadcastSessionStateDebounced: vi.fn(),
getRunSummaryTracker: vi.fn(() => undefined),
} as unknown as Parameters<typeof createSessionListeners>[1];
const refs = createSessionListeners(session, deps);
approvalInbox.notePrompt({ sessionId: session.id, sessionName: 'w1', kind: 'idle' });
if (keepAlive) session.beginKeepAliveTurn(60_000);
refs.working();
refs.idle();
const pending = approvalInbox.getForSession(session.id);
approvalInbox.resolveForSession(session.id, 'dismissed');
expect(pending === undefined).toBe(resolved);
const flag = keepAlive ? { keepAlive: true } : {};
expect(broadcast).toHaveBeenCalledWith(SseEvent.SessionWorking, { id: session.id, ...flag });
expect(broadcast).toHaveBeenCalledWith(SseEvent.SessionIdle, { id: session.id, ...flag });
});
/** The listener reads the setting asynchronously; let its promise chain settle. */
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
+167
View File
@@ -0,0 +1,167 @@
// Port: none (pure logic in a vm context: no browser, no server).
//
// The session swipe (SwipeHandler, mobile-handlers.js) shares every gesture with
// the terminal's own touch handler (terminal-ui.js), which runs first. A swipe
// used to switch sessions even when the terminal had taken the same drag as a
// scroll or a pan, and the terminal took a sideways flick as a TAP on the
// session being left. The terminal now publishes the axis it locked
// (`app._terminalGestureAxis`) and the swipe stands aside for 'y' and 'x'.
//
// Lives outside test/mobile/ because that suite is Playwright-driven and not in
// the CI gate (same reasoning as test/viewport-shape-change.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 SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile-handlers.js'), 'utf8');
const TERMINAL_SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
type Touch = { clientX: number; clientY: number };
type Swipe = {
onTouchStart(e: unknown): void;
onTouchMove(e: unknown): void;
onTouchEnd(e: unknown): void;
maxSwipeTime: number;
maxVerticalDrift: number;
startTime: number;
};
function load(appOverrides: Record<string, unknown> = {}) {
const app = {
nextSession: vi.fn(),
prevSession: vi.fn(),
_terminalGestureAxis: null as string | null,
...appOverrides,
};
const context = vm.createContext({
console,
app,
navigator: { userAgent: 'iPhone', maxTouchPoints: 5 },
window: { addEventListener: () => {}, matchMedia: () => ({ matches: true }) },
document: { querySelector: () => null },
setTimeout: () => 0,
clearTimeout: () => {},
});
vm.runInContext(`${SOURCE}\nglobalThis.__swipe = SwipeHandler;`, context);
const swipe = (context as unknown as { __swipe: Swipe }).__swipe;
const target = (ignored = false) => ({
closest: (sel: string) => (ignored && sel !== '.session-sidebar' ? {} : null),
});
// `axis` is what the terminal's touchmove publishes mid-gesture (after the
// touchstart, which resets it); `consumed` a touchend the terminal
// preventDefault'ed.
const gesture = (
from: Touch,
to: Touch,
opts: { ms?: number; ignored?: boolean; via?: Touch; axis?: string | null; consumed?: boolean } = {}
) => {
swipe.onTouchStart({ target: target(opts.ignored), touches: [from] });
if (opts.axis !== undefined) app._terminalGestureAxis = opts.axis;
if (opts.ms) swipe.startTime -= opts.ms;
if (opts.via) swipe.onTouchMove({ touches: [opts.via] });
swipe.onTouchMove({ touches: [to] });
swipe.onTouchEnd({ changedTouches: [to], defaultPrevented: !!opts.consumed });
};
return { app, swipe, gesture };
}
describe('session swipe', () => {
it('switches on a clean horizontal swipe', () => {
const { app, gesture } = load();
gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 405 });
expect(app.nextSession).toHaveBeenCalledTimes(1);
gesture({ clientX: 100, clientY: 400 }, { clientX: 260, clientY: 400 });
expect(app.prevSession).toHaveBeenCalledTimes(1);
});
it('accepts an unhurried swipe up to 500ms, not past it', () => {
const { app, gesture } = load();
gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { ms: 450 });
expect(app.nextSession).toHaveBeenCalledTimes(1);
gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { ms: 600 });
expect(app.nextSession).toHaveBeenCalledTimes(1);
});
it('cancels when the finger drifted vertically mid-gesture, even if it came back', () => {
const { app, gesture } = load();
gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { via: { clientX: 220, clientY: 470 } });
expect(app.nextSession).not.toHaveBeenCalled();
});
it('stands aside when the terminal took the drag as a scroll or a pan', () => {
for (const axis of ['y', 'x']) {
const { app, gesture } = load();
gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { axis });
expect(app.nextSession, axis).not.toHaveBeenCalled();
}
const { app, gesture } = load();
gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { axis: 'swipe' });
expect(app.nextSession).toHaveBeenCalledTimes(1);
});
it('forgets the axis a previous gesture left behind, so a swipe from home works after a terminal scroll', () => {
// The terminal resets the axis only for a touch that starts on it; a swipe
// over the home screen starts elsewhere in `.main`.
const { app, gesture } = load({ _terminalGestureAxis: 'y' });
gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 });
expect(app.nextSession).toHaveBeenCalledTimes(1);
});
it('stands aside when the terminal consumed the lift (a long-press selection gesture)', () => {
// A long-press on blank space sets _touchSelecting, finds no word, and the
// terminal clears the flag synchronously in its own touchend before this
// handler runs; the preventDefault it leaves behind is the signal.
const { app, gesture } = load();
gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { consumed: true });
expect(app.nextSession).not.toHaveBeenCalled();
});
it('never switches during a touch selection or on a surface with its own controls', () => {
for (const flag of ['_touchSelecting', '_touchSelectionActive']) {
const { app, gesture } = load({ [flag]: true });
gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 });
expect(app.nextSession, flag).not.toHaveBeenCalled();
}
const { app, gesture } = load();
gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { ignored: true });
expect(app.nextSession).not.toHaveBeenCalled();
});
it('ignores every lift after a second finger joined, the first finger lifting first included', () => {
// Finger A starts the gesture, B joins (a pinch), A lifts, then B lifts
// 150 px from A's start within the time limit: neither lift is a swipe.
const { app, swipe } = load();
const a = { clientX: 300, clientY: 400 };
const b = { clientX: 100, clientY: 400 };
swipe.onTouchStart({ target: { closest: () => null }, touches: [a] });
swipe.onTouchStart({ target: { closest: () => null }, touches: [a, b] });
swipe.onTouchEnd({ changedTouches: [{ clientX: 200, clientY: 400 }] });
swipe.onTouchEnd({ changedTouches: [{ clientX: 150, clientY: 400 }] });
expect(app.nextSession).not.toHaveBeenCalled();
});
});
describe('terminal touch handler hands sideways drags to the swipe', () => {
it('locks a dominant sideways drag as a swipe unless the terminal can pan', () => {
expect(TERMINAL_SOURCE).toContain("panAxis = travelX > travelY ? (canPanHorizontally() ? 'x' : 'swipe') : 'y';");
expect(TERMINAL_SOURCE).toContain('this._terminalGestureAxis = panAxis;');
// A 'swipe' is neither a scroll nor (didScroll is set) a tap.
expect(TERMINAL_SOURCE).toContain("if (panAxis === 'swipe') {");
});
it("hands a 'swipe' drag back to the scroll once it passes the swipe's own drift limit", () => {
// A thumb arc whose first move leans sideways (+9 px across, +5 px down) locks
// as 'swipe'; SwipeHandler drops it past maxVerticalDrift, so without the
// handback the 300 px of downward travel that follows would do nothing.
const start = TERMINAL_SOURCE.indexOf("if (panAxis === 'swipe') {");
const block = TERMINAL_SOURCE.slice(start, TERMINAL_SOURCE.indexOf("if (panAxis === 'x') {", start));
expect(block).toContain('SwipeHandler.maxVerticalDrift');
expect(block).toContain('if (Math.abs(touchY - touchStartY) <= drift) return;');
expect(block).toContain("panAxis = 'y';");
expect(block).toContain("this._terminalGestureAxis = 'y';");
// The terminal reads the swipe's limit by name; its fallback is the same value.
expect(load().swipe.maxVerticalDrift).toBe(50);
});
});
+19 -1
View File
@@ -121,7 +121,25 @@ afterAll(async () => {
if (originalPlaywrightBrowsersPath === undefined) delete process.env.PLAYWRIGHT_BROWSERS_PATH;
else process.env.PLAYWRIGHT_BROWSERS_PATH = originalPlaywrightBrowsersPath;
rmSync(testHome, { recursive: true, force: true });
// A straggling writer can recreate files between rmSync's readdir and rmdir and fail the whole file
// with ENOTEMPTY after every test has passed. The usual culprit is a real agent CLI on a developer
// machine (the `claude` a test's code path probes or spawns) flushing its own `~/.claude.json` and
// `~/.claude/backups/` into the temp HOME a moment after the test ends; CI has no such CLI. Keep
// removing until it goes quiet, for at most about 5 s, and if it never does, leave the temp dir to
// the `exit` backstop below: a leaked temp folder must not fail a test file that passed.
// ⚠️ The bound is a deadline, not an attempt count: this hook runs under vitest's 10 s
// `hookTimeout`, and each failed attempt costs rmSync's own retry sleeps (50 + 100 + 150 ms) plus
// the wait below, so 20 attempts overran it and failed the file with "Hook timed out".
const deadline = Date.now() + 5_000;
for (;;) {
try {
rmSync(testHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
break;
} catch {
if (Date.now() >= deadline) break;
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
});
// afterAll never fires for a fully-skipped test file (no tests execute), which
+337 -21
View File
@@ -36,7 +36,18 @@ function loadTerminalUiHarness(
// terminal-ui.js hangs CodemanTerminalInput off window; tests read it there.
const windowRef: Record<string, any> = { ...windowGlobals };
// Mutable so a test can move time (gesture gaps); defaults to a frozen clock.
const clock = { now: () => 1_000 };
const timers = new Map<number, { due: number; fn: () => void }>();
let timerId = 0;
const clock = {
now: () => 1_000,
flushTimers: () => {
for (const [id, timer] of timers)
if (timer.due <= clock.now()) {
timers.delete(id);
timer.fn();
}
},
};
const context = vm.createContext({
window: windowRef,
CodemanApp,
@@ -44,7 +55,12 @@ function loadTerminalUiHarness(
_crashDiag: { log: vi.fn() },
performance: clock,
requestAnimationFrame: (_fn: () => void) => 1,
setTimeout: (_fn: () => void) => 1,
setTimeout: (fn: () => void, ms = 0) => {
const id = ++timerId;
timers.set(id, { due: clock.now() + ms, fn });
return id;
},
clearTimeout: (id: number) => timers.delete(id),
Blob: function Blob() {},
URL: { createObjectURL: () => 'blob:yield', revokeObjectURL: () => {} },
Worker: function Worker(this: any) {
@@ -205,26 +221,30 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
app._maybePageCliTranscript({ shiftKey: false }, -1); // first event: one PageUp
app._maybePageCliTranscript({ shiftKey: false }, 1); // reversal inside the gap: accumulates, no page
now += 200;
clock.flushTimers();
app._maybePageCliTranscript({ shiftKey: false }, -1); // a reversal after the gap: a PageUp at once
now += 1_100;
clock.flushTimers();
app._maybePageCliTranscript({ shiftKey: false }, 1); // after a pause: a PageDown at once
app._maybePageCliTranscript({ shiftKey: false }, 0.05); // sub-row jitter in the same gesture: nothing
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~\x1b[5~\x1b[6~' }]);
expect(sent).toEqual([
{ id: 'sess-1', data: '\x1b[5~' },
{ id: 'sess-1', data: '\x1b[6~' },
]);
});
it('lets wheel notches accumulate instead of paging a full screen on each one', () => {
// A 100 px notch is 4 rows. Only a trackpad-sized opening event (under 2 rows)
// pages at once; a notch adds up toward half a screen as it always did, so
// slow notches (each one its own gesture by the 150 ms gap) still page once
// per 18 rows here, not once per notch.
for (const gapMs of [250, 40]) {
it('accumulates coarse wheel notches across pauses instead of prepaying each opening', () => {
// Coarse events accumulate as on master, even across pauses; they do not
// pre-pay a full page each time a new gesture starts.
for (const gapMs of [250, 40, 1200]) {
const { app, sent, clock } = hollowApp();
let now = 1_000;
clock.now = () => now;
for (let i = 0; i < 5; i++) {
expect(app._maybePageCliTranscript({ shiftKey: false, deltaY: -100 }, -4)).toBe(true);
now += gapMs;
clock.flushTimers();
}
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
@@ -375,8 +395,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
});
it('pages a Codex transcript only while its local scrollback is empty', () => {
// Codex is never sent SGR wheel reports (it ignores them); this is the
// separate PageUp/PageDown fallback, which codex does honour.
// Codex without observed mouse tracking retains its PageUp/PageDown fallback.
const { app, sent } = hollowApp();
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
@@ -473,7 +492,14 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter
h.app._flushWheelSgrQueue();
return h.sent.map((s) => s.data).join('');
};
return { ...h, keys, advance: (ms: number) => (now += ms) };
return {
...h,
keys,
advance: (ms: number) => {
now += ms;
h.clock.flushTimers();
},
};
}
const count = (keys: string, key: string) => keys.split(key).length - 1;
@@ -558,9 +584,9 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: dy }, dy / 25);
p.advance(16);
}
// The opening event is a fresh gesture and pages; no reversal inside the
// gap pre-pays, so the wobble sends nothing after it.
expect(p.keys()).toBe(UP);
// A reversal inside the 40ms batch window cancels the unsent opening
// PageUp; subsequent in-gesture reversals do not pre-pay another page.
expect(p.keys()).toBe(''); // the opening report was cancelled before its 40ms flush
});
it('still pages a deliberate reversal at once after a pause', () => {
@@ -577,10 +603,10 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter
p.advance(16);
// Downward travel just short of a page: the upward debt must not count toward it.
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 * 18 }, 18);
expect(p.keys()).toBe(UP);
expect(p.keys()).toBe(''); // reversal discards the unsent UP
p.advance(16);
p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 }, 1);
expect(p.keys()).toBe(UP + DOWN);
expect(p.keys()).toBe(DOWN);
});
it('ends the one-second debt lifetime once the debt is repaid, exactly or overpaid', () => {
@@ -747,8 +773,8 @@ describe('GitHub Copilot wheel forwarding', () => {
expect(idle.app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
});
it('does not change what other non-claude modes do', () => {
for (const mode of ['codex', 'opencode', 'grok', 'gemini']) {
it('keeps unmeasured CLI modes out of native forwarding', () => {
for (const mode of ['opencode', 'grok', 'gemini']) {
const { app } = hollowApp({ mode, cliMouseTracking: true });
expect(app._shouldForwardWheelToApp({ shiftKey: false }), mode).toBe(false);
}
@@ -790,13 +816,13 @@ describe('the paging gates asked for another pane (a TerminalTile)', () => {
const event = { shiftKey: false, ...extra };
const step = pageKeysForGesture(state, event, lines, 36, 1_000);
state = step.state;
expected += step.keys;
expected = windowRef.CodemanTerminalInput.coalesceScrollBytes(expected, step.keys) || expected;
app._maybePageCliTranscript(event, lines);
}
app._flushWheelSgrQueue();
expect(app._pageKeyGesture).toEqual(state);
expect(expected).toContain('\x1b[5~');
expect(expected).toContain('\x1b[6~');
expect(expected).not.toContain('\x1b[6~');
expect(sent).toEqual([{ id: 'sess-1', data: expected }]);
});
@@ -876,4 +902,294 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => {
app._logScrollRouting('page-keys');
expect(logs[0]).toContain('cliVersion=unknown');
});
it("reports xterm's mouse encoding, the input touch forwarding to native tracking reads", () => {
const { app, logs } = hollowApp({ mode: 'shell' });
app._logScrollRouting('local-scrollback'); // no readable xterm private
expect(logs[0]).toContain('mouseEncoding=unknown');
// An app switching to SGR changes what touch does, so it prints again.
app.terminal.modes.mouseTrackingMode = 'any';
app.terminal._core = { coreMouseService: { activeEncoding: 'SGR' } };
app._logScrollRouting('local-scrollback');
expect(logs).toHaveLength(2);
expect(logs[1]).toContain('mouseTracking=any');
expect(logs[1]).toContain('mouseEncoding=SGR');
});
});
describe('touch scrolling when xterm owns native mouse tracking', () => {
it.each(['vt200', 'drag', 'any'])('forwards touch for %s tracking even for a shell session', (mode) => {
const { app } = hollowApp({ mode: 'shell' });
app.terminal.modes.mouseTrackingMode = mode;
app.terminal._core = { coreMouseService: { activeEncoding: 'SGR' } };
app.terminal.buffer.active.type = 'alternate';
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
expect(app._shouldForwardTouchToApp()).toBe(true);
});
it('does not send SGR to a legacy-protocol or X10 application', () => {
const { app } = hollowApp({ mode: 'shell' });
app.terminal.modes.mouseTrackingMode = 'vt200';
app.terminal._core = { coreMouseService: { activeEncoding: 'DEFAULT' } };
expect(app._shouldForwardTouchToApp()).toBe(false);
app.terminal._core.coreMouseService.activeEncoding = 'SGR';
app.terminal.modes.mouseTrackingMode = 'x10';
expect(app._shouldForwardTouchToApp()).toBe(false);
});
it('does not send cell-based SGR reports to an app that asked for SGR pixel coordinates', () => {
const { app } = hollowApp({ mode: 'shell' });
app.terminal.modes.mouseTrackingMode = 'any';
app.terminal._core = { coreMouseService: { activeEncoding: 'SGR_PIXELS' } };
expect(app._shouldForwardTouchToApp()).toBe(false);
});
it('keeps the explicit local-history preference for touch', () => {
const { app } = hollowApp({ mode: 'shell' });
app.terminal.modes.mouseTrackingMode = 'any';
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
expect(app._shouldForwardTouchToApp()).toBe(false);
});
it('uses normal CLI routing when xterm mouse tracking is off', () => {
const { app } = hollowApp({ cliVersion: '2.1.187', cliMouseTracking: true });
expect(app._shouldForwardTouchToApp()).toBe(true);
app.sessions.get('sess-1').cliMouseTracking = false;
expect(app._shouldForwardTouchToApp()).toBe(false);
});
});
describe('consistent scroll gesture adapters', () => {
it('keeps the shared paging opening threshold exported', () => {
expect(loadTerminalUiHarness().windowRef.CodemanTerminalInput.PAGE_KEY_IMMEDIATE_MAX_ROWS).toBe(2);
});
it('preserves rounded travel for isolated coarse clicks without accumulating a capped backlog', () => {
const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
for (const direction of [-1, 1]) {
expect(wheelTicksForGesture(null, direction * 4.8, 0).ticks).toBe(direction * 5); // 120px
expect(wheelTicksForGesture(null, direction * 1.6, 0).ticks).toBe(direction * 2); // 40px
let state = null;
let ticks = 0;
for (let i = 0; i < 5; i++) {
const step = wheelTicksForGesture(state, direction * 4.8, i * 300);
state = step.state;
ticks += step.ticks;
}
expect(ticks).toBe(direction * 25);
const capped = wheelTicksForGesture(null, direction * 40, 0);
expect(capped.ticks).toBe(direction * 5);
expect(capped.state.pending).toBe(0); // don't retain travel discarded by the event cap
}
});
it('pre-pays an isolated deliberate opening tick and repays its signed debt', () => {
const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
const opening = wheelTicksForGesture(null, -0.16, 0); // a 4px mouse click
expect(opening.ticks).toBe(-1);
expect(opening.state.pending).toBeCloseTo(0.84);
const continued = wheelTicksForGesture(opening.state, -0.16, 20);
expect(continued.ticks).toBe(0);
expect(continued.state.pending).toBeCloseTo(0.68);
expect(wheelTicksForGesture(null, 0.04, 0).ticks).toBe(0); // sub-threshold jitter
});
it('sends each slow 4px click, in either direction, while ten 2.5px events total one tick', () => {
const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
for (const direction of [-1, 1]) {
let slowState;
const slowTicks = [];
for (let i = 0; i < 5; i++) {
const step = wheelTicksForGesture(slowState, direction * 0.16, i * 300);
slowState = step.state;
slowTicks.push(step.ticks);
}
expect(slowTicks).toEqual(Array(5).fill(direction));
let fineState;
const fineTicks = [];
for (let i = 0; i < 10; i++) {
const step = wheelTicksForGesture(fineState, direction * 0.1, i * 8);
fineState = step.state;
fineTicks.push(step.ticks);
}
expect(fineTicks).toEqual([direction, ...Array(9).fill(0)]);
expect(fineState.pending).toBe(0);
}
});
it('makes ten small Claude deltas equal one combined delta', () => {
const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
let state;
let ticks = 0;
for (let i = 0; i < 10; i++) {
const step = wheelTicksForGesture(state, -0.12, i * 8);
state = step.state;
ticks += step.ticks;
}
expect(ticks).toBe(-1);
let decimalState;
let decimalTicks = 0;
for (let i = 0; i < 10; i++) {
const step = wheelTicksForGesture(decimalState, 0.1, i * 8);
decimalState = step.state;
decimalTicks += step.ticks;
}
expect(decimalTicks).toBe(1);
expect(wheelTicksForGesture(null, -1.2, 0).ticks).toBe(ticks);
});
it('drops fractional movement on reversal, inactivity and session changes', () => {
const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
const state = wheelTicksForGesture(null, 0.9, 0).state;
expect(wheelTicksForGesture(state, -0.2, 10).ticks).toBe(0);
expect(wheelTicksForGesture(state, 0.2, 200).ticks).toBe(1);
const { app, sent } = hollowApp();
app._clientPointToCell = () => ({ col: 1, row: 1 });
app._sendSyntheticSgrWheel(0, 0, 0.9);
app.activeSessionId = 'other';
app._sendSyntheticSgrWheel(0, 0, 0.2);
expect(sent).toEqual([
{ id: 'sess-1', data: '\x1b[<65;1;1M' },
{ id: 'other', data: '\x1b[<65;1;1M' },
]);
expect(app._wheelTickGesture.pending).toBeCloseTo(-0.8);
});
it('preserves accumulated coarse Codex wheel fallback travel', () => {
const { app, sent } = hollowApp({ mode: 'codex', rows: 43 });
app._maybePageCliTranscript({ deltaY: -100 }, -4);
app._flushWheelSgrQueue();
expect(sent).toEqual([]);
for (let i = 0; i < 5; i++) app._maybePageCliTranscript({ deltaY: -100 }, -4);
app._flushWheelSgrQueue();
expect(sent).toHaveLength(1);
});
it('preserves sustained report density and cancels obsolete direction before transport', () => {
const { coalesceScrollBytes } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
const up = '\x1b[<64;1;1M';
const down = '\x1b[<65;1;1M';
let queued = '';
for (let i = 0; i < 100; i++) queued = coalesceScrollBytes(queued, up);
expect(queued).toBe(up.repeat(100));
expect(coalesceScrollBytes(queued, down)).toBe(down);
expect(coalesceScrollBytes('\x1b[5~'.repeat(3), '\x1b[5~')).toBe('\x1b[5~'.repeat(4));
expect(coalesceScrollBytes('\x1b[5~', '\x1b[6~')).toBe('\x1b[6~');
});
it('preserves all 120 reports of a 30-frame touch fling with real batch deadlines', () => {
const { app, sent, clock } = hollowApp();
let now = 1000;
clock.now = () => now;
app._clientPointToCell = () => ({ col: 1, row: 1 });
for (let frame = 0; frame < 30; frame++) {
app._sendSyntheticSgrWheel(0, 0, -4);
now += 16;
clock.flushTimers();
}
now += 40;
clock.flushTimers();
expect(sent.map((s) => s.data).join('')).toBe('\x1b[<64;1;1M'.repeat(120));
});
it('does not deliver a queued wheel batch to a newly selected session', () => {
const { app, sent } = hollowApp();
app._queueScrollBytes('\x1b[5~');
app.activeSessionId = 'other';
app._flushWheelSgrQueue();
expect(sent).toEqual([]);
});
it('sends the first native report synchronously on every tab visit, batching subsequent reports', () => {
const { app, sent } = hollowApp();
const up = '\x1b[<64;1;1M';
app._selectGeneration = 1;
app._queueScrollBytes(up);
expect(sent).toEqual([{ id: 'sess-1', data: up }]);
app._queueScrollBytes(up);
app._queueScrollBytes(up);
expect(sent).toHaveLength(1);
app._flushWheelSgrQueue();
expect(sent.at(-1)).toEqual({ id: 'sess-1', data: up.repeat(2) });
// Returning A -> B -> A must reset onset even if B was never scrolled.
app._queueScrollBytes(up); // previous visit has an unsent report
app._selectGeneration = 3;
app._queueScrollBytes(up);
expect(sent).toHaveLength(3);
expect(sent.at(-1)).toEqual({ id: 'sess-1', data: up });
app._queueScrollBytes(up); // unsent A report must never leak to B
app.activeSessionId = 'other';
app._selectGeneration = 4;
app._queueScrollBytes(up);
expect(sent.at(-1)).toEqual({ id: 'other', data: up });
app._flushWheelSgrQueue();
expect(sent).toHaveLength(4);
});
it('cancels unsent Claude ticks even when reversed travel is sub-line', () => {
const { app, sent } = hollowApp();
app._clientPointToCell = () => ({ col: 1, row: 1 });
app._sendSyntheticSgrWheel(0, 0, -1); // leading report is already delivered
app._sendSyntheticSgrWheel(0, 0, -2);
app._sendSyntheticSgrWheel(0, 0, 0.2);
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;1;1M' }]);
});
});
describe('native Codex scrolling instead of an initial page jump', () => {
it('forwards only when Codex explicitly requests mouse tracking', () => {
for (const cliMouseTracking of [undefined, false, true]) {
const { app } = hollowApp({ mode: 'codex', cliMouseTracking });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(cliMouseTracking === true);
expect(app._shouldForwardWheelToApp({ shiftKey: true })).toBe(false);
}
});
it('keeps local-history opt-out and native xterm mouse routing', () => {
const { app } = hollowApp({ mode: 'codex', cliMouseTracking: true });
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
app.loadAppSettingsFromStorage = () => ({});
app.terminal.modes.mouseTrackingMode = 'drag';
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
});
});
describe('native wheel travel preserves upstream units', () => {
it('normalizes coarse Chrome, Firefox and page-unit wheel events without guessing the device', () => {
const { wheelDeltaLines } = loadTerminalUiHarness().windowRef.CodemanTerminalInput;
for (const [deltaY, deltaMode] of [
[100, 0],
[120, 0],
[1000, 0],
[3, 1],
[1, 2],
]) {
expect(wheelDeltaLines({ deltaY, deltaMode }, 43)).toBe(
deltaMode === 0 ? deltaY / 25 : deltaMode === 1 ? deltaY : deltaY * 43
);
expect(wheelDeltaLines({ deltaY: -deltaY, deltaMode }, 43)).toBe(
-(deltaMode === 0 ? deltaY / 25 : deltaMode === 1 ? deltaY : deltaY * 43)
);
}
});
it('keeps fractional touchpad distance and primary/tile normalization identical', () => {
const { app, windowRef } = hollowApp({ mode: 'codex', cliMouseTracking: true });
const { wheelDeltaLines, wheelTicksForGesture } = windowRef.CodemanTerminalInput;
let state;
let ticks = 0;
for (let i = 0; i < 10; i++) {
const ev = { deltaY: -3, deltaMode: 0 };
const lines = wheelDeltaLines(ev, 43);
expect(lines).toBe(-0.12);
expect(app._wheelScrollLinesFloat(ev)).toBe(lines);
const step = wheelTicksForGesture(state, lines, i * 10);
state = step.state;
ticks += step.ticks;
}
expect(ticks).toBe(-1);
});
});
+130 -1
View File
@@ -276,6 +276,16 @@ describe('TerminalTile wheel paging in a real browser', () => {
const down = `\x1b[<65;${expected.col};${expected.row}M`;
expect(after.sent.map(([, data]) => data)).toEqual([up.repeat(4), down.repeat(2)]);
// A slow macOS mouse click is only ~4px. Each isolated click must report,
// not lose its fraction during the 300ms pause between clicks.
await page.waitForTimeout(300);
for (let click = 0; click < 5; click++) {
await page.mouse.wheel(0, -4);
await page.waitForTimeout(300);
}
const afterSlow = await snap();
expect(afterSlow.sent.slice(after.sent.length).map(([, data]) => data)).toEqual(Array(5).fill(up));
// Shift+wheel is the explicit "local scrollback" gesture: scrolled locally
// by the tile (xterm alone turns it into a horizontal no-op), unforwarded.
await page.keyboard.down('Shift');
@@ -283,7 +293,7 @@ describe('TerminalTile wheel paging in a real browser', () => {
await page.keyboard.up('Shift');
await page.waitForTimeout(150);
const shifted = await snap();
expect(shifted.sent.length).toBe(after.sent.length);
expect(shifted.sent.length).toBe(afterSlow.sent.length);
expect(shifted.viewportY).toBeLessThan(before.baseY);
} finally {
await page.evaluate((id) => {
@@ -297,4 +307,123 @@ describe('TerminalTile wheel paging in a real browser', () => {
}, PROBE_ID);
}
});
it('preserves local Ctrl/sideways scrolling and forwards small native clicks without amplifying touchpad streams', async () => {
const id = 'primary-wheel-probe';
await page.evaluate(async (id) => {
const a = (window as any).app;
const t = a.terminal;
(window as any).__primaryWheelProbe = {
sent: [],
activeId: a.activeSessionId,
realEphemeral: a._sendInputEphemeral,
point: a._clientPointToCell,
};
a._sendInputEphemeral = (sid: string, data: string) => (window as any).__primaryWheelProbe.sent.push([sid, data]);
a._clientPointToCell = () => ({ col: 1, row: 1 });
a.sessions.set(id, { id, mode: 'shell' });
a.activeSessionId = id;
t.reset();
await new Promise<void>((resolve) =>
t.write(Array.from({ length: 100 }, (_, i) => `LOCAL_${i}\r\n`).join(''), resolve)
);
t.scrollToBottom();
}, id);
const fire = async (options: { deltaY: number; deltaX?: number; ctrlKey?: boolean }) => {
await page.evaluate((options) => {
const t = (window as any).app.terminal;
t.element.dispatchEvent(new WheelEvent('wheel', { ...options, deltaMode: 0, bubbles: true, cancelable: true }));
}, options);
await page.waitForTimeout(80); // actual 40ms batch and animation frames
};
try {
for (const options of [
{ deltaY: -25, ctrlKey: true },
{ deltaY: -25, deltaX: 100 },
]) {
const before = await page.evaluate(() => {
const t = (window as any).app.terminal;
t.scrollToBottom();
return t.buffer.active.viewportY;
});
expect(before).toBeGreaterThan(0);
await fire(options);
const after = await page.evaluate(() => (window as any).app.terminal.buffer.active.viewportY);
expect(after).toBe(before - 1); // local shell behavior matches master
}
for (const mode of ['claude', 'codex', 'copilot']) {
await page.evaluate(
({ id, mode }) => {
const a = (window as any).app;
a.sessions.set(id, {
id,
mode,
cliVersion: mode === 'claude' ? '2.1.300' : undefined,
cliMouseTracking: true,
});
a._wheelTickGesture = null;
a._wheelTickSession = null;
a._selectGeneration++;
(window as any).__primaryWheelProbe.sent.length = 0;
},
{ id, mode }
);
for (const options of [
{ deltaY: -25, ctrlKey: true },
{ deltaY: -25, deltaX: 100 },
])
await fire(options);
expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent.length)).toBe(0);
for (let click = 0; click < 5; click++) {
await fire({ deltaY: -4 });
await page.waitForTimeout(220);
}
expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent)).toEqual(
Array.from({ length: 5 }, () => [id, '\x1b[<64;1;1M'])
);
await page.waitForTimeout(200);
await page.evaluate(() => {
(window as any).__primaryWheelProbe.sent.length = 0;
});
await page.evaluate(() => {
const t = (window as any).app.terminal;
for (let event = 0; event < 10; event++)
t.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -2.5,
deltaMode: 0,
bubbles: true,
cancelable: true,
})
);
});
await page.waitForTimeout(80);
expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent)).toEqual([[id, '\x1b[<64;1;1M']]);
}
await page.evaluate((id) => {
const a = (window as any).app;
a.sessions.set(id, { id, mode: 'codex', cliMouseTracking: false });
a.terminal.clear();
a.terminal.scrollToBottom();
(window as any).__primaryWheelProbe.sent.length = 0;
}, id);
expect(await page.evaluate(() => (window as any).app.terminal.buffer.active.baseY)).toBe(0);
for (const options of [
{ deltaY: -25, ctrlKey: true },
{ deltaY: -25, deltaX: 100 },
])
await fire(options);
expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent.length)).toBe(0);
} finally {
await page.evaluate((id) => {
const a = (window as any).app,
p = (window as any).__primaryWheelProbe;
a.activeSessionId = p.activeId;
a._sendInputEphemeral = p.realEphemeral;
a._clientPointToCell = p.point;
a.sessions.delete(id);
delete (window as any).__primaryWheelProbe;
}, id);
}
});
});
+62 -13
View File
@@ -232,7 +232,7 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => {
expect(up.preventDefault).toHaveBeenCalled();
expect(up.stopPropagation).toHaveBeenCalled();
expect(ws.inputFrames()).toEqual([]); // coalesced, not sent per event
expect(ws.inputFrames()).toEqual([]); // fallback page reports remain batched
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); // no seq: never persisted
mount.fire('wheel', wheelLines(term.rows / 2));
@@ -328,14 +328,22 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports'
expect(up.preventDefault).toHaveBeenCalled();
expect(up.stopPropagation).toHaveBeenCalled();
expect(ws.inputFrames()).toEqual([]); // coalesced, not sent per event
expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP.repeat(3) }]); // first report never waits for a timer
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(3) }]); // no seq: never persisted
mount.fire('wheel', wheelLines(2, AT));
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN.repeat(2) });
});
it('forwards a Chrome-on-Windows notch (100 px) as four ticks and caps a fling at five', async () => {
it('uses fractional native scrolling for a tracked Codex tile, never PageUp', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex', cliMouseTracking: true } }), {
mode: 'codex',
});
for (let i = 0; i < 10; i++) mount.fire('wheel', wheel(-3, AT));
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
});
it('preserves coarse pixel wheel travel and caps individual accelerated events', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
mount.fire('wheel', wheel(-100, AT));
@@ -345,10 +353,34 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports'
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) });
});
it('moves a precision touchpad delta one line, and consumes a pure horizontal swipe without sending', async () => {
it('forwards an isolated small wheel event and five slow clicks for every tracked CLI', async () => {
for (const mode of ['claude', 'codex', 'copilot']) {
let now = 1000;
clock.now = () => now;
const session = mode === 'claude' ? FULLSCREEN : { mode, cliMouseTracking: true };
const { ws, mount } = await connectTile(makeApp({ 's-tile': session }), { mode });
mount.fire('wheel', wheel(-4, AT));
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
for (let i = 1; i < 5; i++) {
now += 300;
vi.advanceTimersByTime(300);
mount.fire('wheel', wheel(-4, AT));
flushed(ws);
}
expect(ws.inputFrames().map((f) => f.d)).toEqual(Array(5).fill(UP));
// Separate pane/visit: ten 2.5px events produce only the pre-paid tick.
const fine = await connectTile(makeApp({ 's-tile': session }), { mode });
for (let i = 0; i < 10; i++) fine.mount.fire('wheel', wheel(-2.5, AT));
expect(flushed(fine.ws)).toEqual([{ t: 'i', d: UP }]);
}
});
it('accumulates precision touchpad deltas and consumes a pure horizontal swipe without sending', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
mount.fire('wheel', wheel(-3, AT)); // 0.12 of a line
mount.fire('wheel', wheel(-3, AT)); // pre-pay one tick, then repay it
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
for (let i = 0; i < 8; i++) mount.fire('wheel', wheel(-3, AT));
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
const sideways = wheel(0, { ...AT, deltaX: 120 });
@@ -357,14 +389,30 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports'
expect(flushed(ws)).toHaveLength(1);
});
it('coalesces the reports of wheels within 40 ms into one frame', async () => {
it('sends the first report immediately and coalesces subsequent wheels within 40 ms', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
mount.fire('wheel', wheelLines(-2, AT));
vi.advanceTimersByTime(20);
mount.fire('wheel', wheelLines(-2, AT));
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]);
mount.fire('wheel', wheelLines(-2, AT));
expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP.repeat(2) }]);
expect(flushed(ws)).toEqual([
{ t: 'i', d: UP.repeat(2) },
{ t: 'i', d: UP.repeat(4) },
]);
});
it('does not discard queued reports when a zero-delta wheel arrives', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
mount.fire('wheel', wheelLines(-1, AT));
mount.fire('wheel', wheelLines(-2, AT));
mount.fire('wheel', wheel(0, AT));
expect(flushed(ws)).toEqual([
{ t: 'i', d: UP },
{ t: 'i', d: UP.repeat(2) },
]);
});
it('snaps a scrolled-up viewport home before reporting, since SGR rows address the live screen', async () => {
@@ -445,14 +493,14 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports'
const primary = makeApp({ other: FULLSCREEN }, 'other') as App & {
terminal: unknown;
_sendSyntheticSgrWheel(x: number, y: number, lines: number): void;
_wheelScrollLines(ev: unknown): number;
_wheelScrollLinesFloat(ev: unknown): number;
_flushWheelSgrQueue(): void;
};
const sent: string[] = [];
primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
primary.terminal = new FakeTerminal({});
for (const ev of sequence) {
primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollLines(ev));
primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollLinesFloat(ev));
primary._flushWheelSgrQueue();
}
@@ -540,7 +588,7 @@ describe('page-key travel, cap and coalescing', () => {
FakeFit.proposed = { cols: 80, rows: 36 };
});
it('accumulates sub-page travel: two wheels of 10 lines are one PageUp', async () => {
it('accumulates coarse sub-page travel before sending one page', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } }));
mount.fire('wheel', wheelLines(-10));
@@ -608,10 +656,11 @@ describe("gesture handling is the primary pane's, through the shared pure helper
clock.now = () => now;
return (ms: number) => {
now += ms;
vi.advanceTimersByTime(ms);
};
}
it('pages a Codex tile, which draws on the alternate screen and ignores SGR wheel reports', async () => {
it('pages an untracked Codex tile through the hollow-pane fallback', async () => {
const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex' } }), { mode: 'codex' });
mount.fire('wheel', wheelLines(-18));
@@ -744,8 +793,8 @@ describe("gesture handling is the primary pane's, through the shared pure helper
const tileBytes = flushed(ws)
.map((f) => f.d)
.join('');
// One page for the slow ticks, one for the wobble's opening event, nothing more.
expect(tileBytes).toBe(PAGE_UP + PAGE_UP);
// One page for the slow ticks; the wobble cancels its unsent opening page on reversal.
expect(tileBytes).toBe(PAGE_UP); // the first slow tick flushed before the later wobble
const primary = makeApp({ other: { mode: 'codex' } }, 'other') as App & {
terminal: unknown;
@@ -11,6 +11,10 @@
* the contact's id (one per touchstart, carried by its momentum frames), and a
* contact is one gesture.
*
* Its own describe covers the other touch route in the same listeners (#663):
* an app that turned on xterm's own SGR mouse tracking gets a drag and its
* fling momentum as SGR wheel reports, not a local scroll.
*
* What runs for real: the shipped app (page served by a WebServer on port 0),
* its xterm, the touch listeners, the momentum loop on requestAnimationFrame,
* the gates in terminal-ui.js and the 40 ms key coalescer. What is stubbed:
@@ -144,4 +148,65 @@ describe('touch paging of a hollow Codex pane in a real browser', () => {
expect(second.keys).toBe(UP + UP);
await touch('touchend', x, y0 + Math.ceil(cell));
}, 60000);
// Not the page-key fallback: a shell app (htop, vim) that turned on xterm's
// own SGR mouse tracking gets touch drags and fling momentum as SGR wheel
// reports (_shouldForwardTouchToApp), at phone and tablet widths. Shares this
// file's page and touch dispatcher.
describe('touch forwarding when xterm owns native SGR mouse tracking', () => {
it.each([390, 820])('forwards drag and momentum with native mouse tracking at %ipx', async (width) => {
await page.setViewportSize({ width, height: 900 });
await page.evaluate(async (id) => {
const app = (window as any).app;
app.sessions.set(id, { id, name: id, mode: 'shell', status: 'idle' });
app.activeSessionId = id;
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
app._wheelSgrQueue = '';
const sent: string[] = [];
app._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
(window as any).__mouseTouchProbe = { sent };
await new Promise<void>((resolve) => app.terminal.write('\x1b[?1049h\x1b[?1003h\x1b[?1006h', resolve));
}, PROBE_ID);
const point = await page.evaluate(() => {
const app = (window as any).app;
const r = document.getElementById('terminalContainer')!.getBoundingClientRect();
return {
x: r.left + 30,
y: r.top + 200,
mode: app.terminal.modes.mouseTrackingMode,
buffer: app.terminal.buffer.active.type,
};
});
expect(point.mode).toBe('any');
expect(point.buffer).toBe('alternate');
await touch('touchstart', point.x, point.y);
await touch('touchmove', point.x, point.y - 60);
const drag = await page.evaluate(() => {
(window as any).app._flushWheelSgrQueue();
return (window as any).__mouseTouchProbe.sent.join('');
});
expect(drag).toMatch(/\x1b\[<65;\d+;\d+M/);
await touch('touchend', point.x, point.y - 60);
await page.waitForTimeout(150);
const momentum = await page.evaluate(() => {
(window as any).app._flushWheelSgrQueue();
return (window as any).__mouseTouchProbe.sent.join('');
});
expect(momentum.length).toBeGreaterThan(drag.length);
// Stop momentum before changing the requested mouse encoding.
await touch('touchstart', point.x, point.y);
await touch('touchcancel', point.x, point.y);
await page.evaluate(async () => {
const app = (window as any).app;
app._wheelSgrQueue = '';
(window as any).__mouseTouchProbe.sent.length = 0;
await new Promise<void>((resolve) => app.terminal.write('\x1b[?1006l', resolve));
});
await touch('touchstart', point.x, point.y);
await touch('touchmove', point.x, point.y - 60);
await page.waitForTimeout(60);
expect(await page.evaluate(() => (window as any).__mouseTouchProbe.sent.join(''))).toBe('');
await touch('touchcancel', point.x, point.y);
});
});
});
@@ -0,0 +1,149 @@
/**
* @fileoverview Real Chromium coverage for the axis the primary pane's touch
* listeners lock on the first travel past the tap slop (#674): `'swipe'` for a
* sideways drag on a terminal that cannot pan, which belongs to the session
* swipe (SwipeHandler, mobile-handlers.js) and so neither scrolls nor ends as a
* tap.
*
* The edge this pins: a scroll whose first few pixels lean sideways (a thumb
* arc: +9 px across, +5 px down, then straight down) used to lock as `'swipe'`
* for the whole contact, so every later touchmove returned before the scroll
* code, the swipe itself gave up at its vertical drift limit, and the gesture
* did nothing. Past `SwipeHandler.maxVerticalDrift` the drag is handed back to
* the scroll (`'y'`). A clean sideways swipe must still lock as `'swipe'` and
* leave the buffer where it was.
*
* What runs for real: the shipped app (page served by a WebServer on port 0),
* its xterm, the touch listeners `initTerminal()` installs on
* `#terminalContainer` and the gates they call. What is stubbed: the active
* session (a shell session the page knows about but no PTY, so a drag scrolls
* xterm's own buffer), and `nextSession`/`prevSession`, so a swipe can never
* switch away mid-test. The touch events are DOM events carrying `touches`,
* dispatched on the container.
*
* Port: ephemeral (`new WebServer(0, …)`, read back from `boundPort`).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PROBE_ID = 'touch-swipe-axis-probe';
describe('touch axis lock of the primary pane in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1, hasTouch: true });
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
// Let the page settle first: its layout refits the terminal (62 rows to 50
// at this size, measured) a moment after load, which moves baseY.
await page.waitForTimeout(3500);
await page.evaluate(async (id) => {
const app = (window as any).app;
app.sessions.set(id, { id, name: id, mode: 'shell', status: 'idle' });
app.activeSessionId = id;
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
app.nextSession = () => {};
app.prevSession = () => {};
let text = '';
for (let i = 0; i < 300; i++) text += `\r\nscrollback row ${i}`;
await new Promise<void>((resolve) => app.terminal.write(text, resolve));
}, PROBE_ID);
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
/** Dispatch one touch event of `type` at (x, y) on the terminal container. */
const touch = (type: string, x: number, y: number) =>
page.evaluate(
([t, cx, cy]) => {
const container = document.getElementById('terminalContainer')!;
const ev = new Event(t as string, { bubbles: true, cancelable: true });
const point = { clientX: cx, clientY: cy, target: container };
Object.defineProperty(ev, 'touches', { value: t === 'touchend' ? [] : [point] });
Object.defineProperty(ev, 'changedTouches', { value: [point] });
container.dispatchEvent(ev);
},
[type, x, y] as const
);
/** Viewport back at the bottom, and a start point inside the container. */
const reset = () =>
page.evaluate(() => {
const app = (window as any).app;
app.terminal.scrollToBottom();
const container = document.getElementById('terminalContainer')!;
const r = container.getBoundingClientRect();
const b = app.terminal.buffer.active;
return {
x: Math.round(r.left + r.width / 2),
y: Math.round(r.top + 40),
height: Math.round(r.height),
baseY: b.baseY,
viewportY: b.viewportY,
rows: app.terminal.rows,
canPan: container.classList.contains('term-overflows-x'),
};
});
/** SwipeHandler is a top-level `const` (no window property): read it by name. */
const swipeDrift = () =>
page.evaluate<number | null>('typeof SwipeHandler === "undefined" ? null : SwipeHandler.maxVerticalDrift');
const state = () =>
page.evaluate(() => {
const app = (window as any).app;
const b = app.terminal.buffer.active;
return { axis: app._terminalGestureAxis, viewportY: b.viewportY, baseY: b.baseY, rows: app.terminal.rows };
});
it('hands a drag that opens sideways back to the scroll once it passes the swipe drift limit', async () => {
const start = await reset();
// The preconditions, so a pass is not vacuous: real scrollback to scroll
// through, a terminal that cannot pan (a sideways opening locks 'swipe',
// not 'x'), and room in the container for 300 px of travel.
expect(start.baseY).toBeGreaterThan(50);
expect(start.viewportY).toBe(start.baseY);
expect(start.canPan).toBe(false);
expect(await swipeDrift()).toBe(50);
expect(start.height).toBeGreaterThan(340);
const { x, y } = start;
await touch('touchstart', x, y);
await touch('touchmove', x + 9, y + 5); // the thumb arc: past the slop, mostly sideways
const opened = await state();
expect(opened.axis).toBe('swipe');
expect(opened.viewportY).toBe(opened.baseY);
for (let dy = 25; dy <= 305; dy += 20) await touch('touchmove', x + 9, y + dy);
const dragged = await state();
expect(dragged.axis).toBe('y');
// Finger down scrolls back through the scrollback, measured from the bottom
// (baseY) as it is now. 300 px of travel is well over ten rows; the travel
// up to the drift limit is not counted.
expect(dragged.baseY - dragged.viewportY).toBeGreaterThanOrEqual(5);
// Cancel rather than lift: a lift starts a momentum fling, which would keep
// scrolling into the next test.
await touch('touchcancel', x + 9, y + 305);
}, 60000);
it('keeps a clean sideways swipe locked as the session swipe, with nothing scrolled', async () => {
const start = await reset();
const { x, y } = start;
await touch('touchstart', x + 120, y);
for (let dx = 20; dx <= 160; dx += 20) await touch('touchmove', x + 120 - dx, y + 3);
const swiped = await state();
expect(swiped.axis).toBe('swipe');
expect(swiped.viewportY).toBe(swiped.baseY);
await touch('touchcancel', x - 40, y + 3);
}, 60000);
});
+26 -22
View File
@@ -26,6 +26,7 @@ function loadTerminalUiHarness() {
performance: { now: () => now },
requestAnimationFrame: (_fn: () => void) => 1,
setTimeout: (_fn: () => void) => 1,
clearTimeout: vi.fn(),
Blob: function Blob() {},
URL: {
createObjectURL: () => 'blob:yield',
@@ -737,17 +738,17 @@ describe('terminal touch tap mouse guard', () => {
app.terminal = { rows: 40 };
// DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad): ~110px per notch.
expect(app._wheelScrollLines({ deltaY: 110, deltaX: 0, deltaMode: 0, shiftKey: false })).toBe(4);
expect(app._wheelScrollLinesFloat({ deltaY: 110, deltaX: 0, deltaMode: 0, shiftKey: false })).toBe(4.4);
// DOM_DELTA_LINE (Firefox mouse wheel): deltaY is already lines. Read as
// pixels this rounded to 0 and fell through to the ±1 fallback.
expect(app._wheelScrollLines({ deltaY: 3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(3);
expect(app._wheelScrollLines({ deltaY: -3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(-3);
expect(app._wheelScrollLinesFloat({ deltaY: 3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(3);
expect(app._wheelScrollLinesFloat({ deltaY: -3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(-3);
// DOM_DELTA_PAGE: one page is one screenful.
expect(app._wheelScrollLines({ deltaY: 1, deltaX: 0, deltaMode: 2, shiftKey: false })).toBe(40);
expect(app._wheelScrollLinesFloat({ deltaY: 1, deltaX: 0, deltaMode: 2, shiftKey: false })).toBe(40);
// A pure horizontal swipe must not fall through to a phantom -1.
expect(app._wheelScrollLines({ deltaY: 0, deltaX: 90, deltaMode: 0, shiftKey: false })).toBe(0);
expect(app._wheelScrollLinesFloat({ deltaY: 0, deltaX: 90, deltaMode: 0, shiftKey: false })).toBe(0);
// Shift + macOS trackpad reports the magnitude on deltaX (issue #154).
expect(app._wheelScrollLines({ deltaY: 0, deltaX: -100, deltaMode: 0, shiftKey: true })).toBe(-4);
expect(app._wheelScrollLinesFloat({ deltaY: 0, deltaX: -100, deltaMode: 0, shiftKey: true })).toBe(-4);
});
it('wheel: gates claude forwarding on CLI version 2.1.187+ (unknown or older stays local)', () => {
@@ -789,7 +790,7 @@ describe('terminal touch tap mouse guard', () => {
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
});
it('wheel: only claude forwards — codex and gemini keep the local wheel', () => {
it('wheel: tracked fullscreen Codex forwards; inline Codex and Gemini do not', () => {
const { app } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.terminal = {
@@ -803,10 +804,10 @@ describe('terminal touch tap mouse guard', () => {
// (the codex transcript lives there — inline viewport, no in-app pager) sat unused.
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
// Tracking on and a high version, so only the mode check can say no: without
// them the gate is false for claude too and this would pin nothing.
app.sessions = new Map([['sess-1', { mode: 'codex', cliVersion: '9.9.9', cliMouseTracking: true }]]); // no version rescues it
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
// Current fullscreen Codex requests tracking and supports native row scrolling.
// Its version is not published as Claude-specific cliInfo; observed tracking gates it.
app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: true }]]);
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
app.sessions = new Map([['sess-1', { mode: 'gemini', cliVersion: '9.9.9', cliMouseTracking: true }]]); // unverified TUI
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
@@ -837,22 +838,22 @@ describe('terminal touch tap mouse guard', () => {
const { app } = loadTerminalUiHarness();
// Plain vertical wheel: unchanged, driven by deltaY.
expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: 100 })).toBe(4);
expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: -50 })).toBe(-2);
expect(app._wheelScrollLinesFloat({ shiftKey: false, deltaX: 0, deltaY: 100 })).toBe(4);
expect(app._wheelScrollLinesFloat({ shiftKey: false, deltaX: 0, deltaY: -50 })).toBe(-2);
// Shift on a macOS trackpad: deltaY≈0, deltaX carries direction+magnitude.
// Old code collapsed this to a fixed -1; now it tracks the horizontal delta.
expect(app._wheelScrollLines({ shiftKey: true, deltaX: -100, deltaY: 0 })).toBe(-4); // scroll up
expect(app._wheelScrollLines({ shiftKey: true, deltaX: 75, deltaY: 0 })).toBe(3); // scroll down
expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: -100, deltaY: 0 })).toBe(-4); // scroll up
expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: 75, deltaY: 0 })).toBe(3); // scroll down
// Shift with a real vertical wheel (mouse): deltaY dominates, deltaX ignored.
expect(app._wheelScrollLines({ shiftKey: true, deltaX: 2, deltaY: 100 })).toBe(4);
expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: 2, deltaY: 100 })).toBe(4);
// Sub-25px delta still nudges one line in the gesture's direction.
expect(app._wheelScrollLines({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-1);
// Sub-25px deltas retain their fractional travel.
expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-0.2);
});
it('wheel: encodes SGR 64/65 ticks, caps per event, and coalesces into one flush', () => {
it('wheel: encodes SGR 64/65 ticks, caps per event, and sends the first report before batching the rest', () => {
const { app } = loadTerminalUiHarness();
const sent: Array<{ id: string; data: string }> = [];
app.activeSessionId = 'sess-1';
@@ -871,13 +872,16 @@ describe('terminal touch tap mouse guard', () => {
app._sendSyntheticSgrWheel(50, 50, -2); // 2 ticks up
app._sendSyntheticSgrWheel(50, 50, 9); // capped at 5 ticks down
expect(sent).toEqual([]); // nothing until the flush timer fires
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(2) }]); // leading report is immediate
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(2) + '\x1b[<65;7;4M'.repeat(5) }]);
expect(sent).toEqual([
{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(2) },
{ id: 'sess-1', data: '\x1b[<65;7;4M'.repeat(5) },
]);
app._flushWheelSgrQueue(); // queue drained — no duplicate send
expect(sent).toHaveLength(1);
expect(sent).toHaveLength(2);
});
it('forwarded scrolls (wheel AND touch) snap the viewport home first, then encode SGR ticks', () => {
+13
View File
@@ -78,3 +78,16 @@ describe('test environment isolation', () => {
expect(deleted).toEqual(STRIPPED_ENV_VARS.map(([name]) => name).sort());
});
});
describe('a bare vitest invocation is still isolated', () => {
// Without a root config, `npx vitest run` loads no setup file, so HOME is never redirected and
// the suite writes fixtures into the developer's real ~/.codeman. Pin that the root config
// exists and carries the setup file, rather than trusting everyone to pass --config.
it('has a root vitest.config.ts that loads test/setup.ts', () => {
const root = fileURLToPath(new URL('..', import.meta.url));
const src = readFileSync(`${root}vitest.config.ts`, 'utf8');
expect(src).toContain('./config/vitest.ci.config');
const ci = readFileSync(`${root}config/vitest.ci.config.ts`, 'utf8');
expect(ci).toContain("setupFiles: ['./test/setup.ts']");
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* @fileoverview The test-only deletion guards must treat a REAL home as "not the test HOME".
*
* `safeRmHomeTree` and `isUnderTestHome` were written so a test could never delete production
* state, and they asked "is this path under `process.env.HOME`?". With `test/setup.ts` skipped
* (a bare `vitest`), HOME is the developer's real home, so that answer is "yes" for every real
* file, and on 2026-10-10 they approved wiping the real linked-cases registry. They now also
* require HOME to be the throwaway directory setup.ts made.
*
* Port: none. Everything happens in a temp tree; HOME is stubbed for the duration of a test.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
TEST_HOME_PREFIX,
assertSandboxedHome,
isSandboxedHome,
isUnderTestHome,
safeRmHomeTree,
} from './mocks/index.js';
import { createRouteTestHarness } from './routes/_route-test-utils.js';
const made: string[] = [];
/** A stand-in for a developer's REAL home: a real directory with real files, not a sandbox name. */
function fakeRealHome(): string {
const dir = mkdtempSync(join(tmpdir(), 'not-the-sandbox-'));
made.push(dir);
mkdirSync(join(dir, '.codeman'));
writeFileSync(join(dir, '.codeman', 'linked-cases.json'), '{"keep":"/me"}');
return dir;
}
afterEach(() => {
vi.unstubAllEnvs();
for (const d of made.splice(0)) rmSync(d, { recursive: true, force: true });
});
describe('test HOME guards', () => {
it('this suite really runs under the sandboxed HOME', () => {
expect(isSandboxedHome()).toBe(true);
expect(() => assertSandboxedHome()).not.toThrow();
});
it('pins the prefix test/setup.ts creates', () => {
expect(TEST_HOME_PREFIX).toBe('codeman-vitest-');
});
it('safeRmHomeTree does not delete anything under a real home', () => {
const home = fakeRealHome();
vi.stubEnv('HOME', home);
safeRmHomeTree(join(home, '.codeman'));
expect(existsSync(join(home, '.codeman', 'linked-cases.json'))).toBe(true);
});
it('isUnderTestHome is false for a real home, even for a file inside it', () => {
const home = fakeRealHome();
vi.stubEnv('HOME', home);
expect(isSandboxedHome()).toBe(false);
expect(isUnderTestHome(join(home, '.codeman', 'linked-cases.json'))).toBe(false);
});
it('still deletes inside the real sandbox', () => {
const inside = mkdtempSync(join(process.env.HOME as string, 'guard-'));
writeFileSync(join(inside, 'f'), 'x');
expect(isUnderTestHome(inside)).toBe(true);
safeRmHomeTree(inside);
expect(existsSync(inside)).toBe(false);
});
it('the route harness refuses to start against a real home', async () => {
const home = fakeRealHome();
vi.stubEnv('HOME', home);
await expect(createRouteTestHarness(() => undefined)).rejects.toThrow(/temp HOME/);
expect(existsSync(join(home, '.codeman', 'linked-cases.json'))).toBe(true);
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* @fileoverview scripts/tmux-manager.sh attaches with a way back out.
*
* Codeman keeps the prefix table on its socket down to `send-prefix`
* (src/tmux-prefix-table.ts), so tmux's `Ctrl+B d` no longer detaches there. The
* script's attach claims F1 in the root table for the length of the attach instead,
* and leaves a binding the user owns alone.
*
* Runs the real script against a REAL tmux server on a private throwaway socket
* (never Codeman's own) with a throwaway HOME, through a pty like a terminal would.
*
* Port: N/A (no server)
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as pty from 'node-pty';
const hasTools = (() => {
try {
execSync('tmux -V && jq --version', { stdio: 'ignore' });
return true;
} catch {
return false;
}
})();
describe.skipIf(!hasTools)('scripts/tmux-manager.sh attach (real tmux, private socket)', () => {
const socket = `codeman-script-test-${process.pid}`;
const tmux = `tmux -L ${socket} -f /dev/null`;
const run = (cmd: string): string => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
const rootKeys = () => run(`${tmux} list-keys -T root`);
const clients = () => run(`${tmux} list-clients -F '#{client_session}'`).trim();
let home = '';
async function waitFor(check: () => boolean, ms = 4000): Promise<boolean> {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (check()) return true;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return check();
}
/** `tmux-manager.sh attach 1` in a pty; resolves its output once it exits. */
function attach(): { script: pty.IPty; exited: Promise<string> } {
const env = { ...process.env, HOME: home, CODEMAN_TMUX_SOCKET: socket } as Record<string, string>;
delete env.TMUX; // the suite may itself run inside tmux
const script = pty.spawn('bash', ['scripts/tmux-manager.sh', 'attach', '1'], {
name: 'xterm-256color',
cols: 100,
rows: 24,
cwd: process.cwd(),
env,
});
let output = '';
script.onData((data) => (output += data));
const exited = new Promise<string>((resolve) => script.onExit(() => resolve(output)));
return { script, exited };
}
beforeAll(() => {
home = mkdtempSync(join(tmpdir(), 'codeman-script-home-'));
mkdirSync(join(home, '.codeman'));
writeFileSync(join(home, '.codeman', 'mux-sessions.json'), JSON.stringify([{ name: 'alpha', muxName: 'alpha' }]));
run(`${tmux} new-session -d -s alpha -x 80 -y 24 'cat'`);
// What Codeman leaves on its socket: nothing but send-prefix in the prefix table.
run(`${tmux} unbind-key -a -T prefix`);
run(`${tmux} bind-key -T prefix C-b send-prefix`);
});
afterAll(() => {
let socketPath = '';
try {
socketPath = run(`${tmux} display-message -p '#{socket_path}'`).trim();
run(`${tmux} kill-server`); // our own throwaway socket only
} catch {
/* already gone */
}
if (socketPath.endsWith(`/${socket}`)) rmSync(socketPath, { force: true });
if (home) rmSync(home, { recursive: true, force: true });
});
it('F1 detaches back out of the attach, and the claim is given back afterwards', async () => {
expect(rootKeys()).not.toMatch(/-T root F1\s/);
const { script, exited } = attach();
try {
expect(await waitFor(() => clients() === 'alpha')).toBe(true);
expect(rootKeys()).toMatch(/-T root F1\s+detach-client/);
script.write('\x1bOP'); // F1
const output = await exited;
expect(output).toContain('F1 to detach');
expect(clients()).toBe('');
expect(rootKeys()).not.toMatch(/-T root F1\s/);
expect(run(`${tmux} has-session -t alpha && echo alive`).trim()).toBe('alive');
} finally {
script.kill();
}
});
it("leaves an F1 binding of the user's own alone and says how to detach instead", async () => {
run(`${tmux} bind-key -T root F1 display-message mine`);
const { script, exited } = attach();
try {
expect(await waitFor(() => clients() === 'alpha')).toBe(true);
expect(rootKeys()).toMatch(/-T root F1\s+display-message mine/);
run(`${tmux} detach-client -s alpha`);
const output = await exited;
expect(output).toContain('detach-client -s alpha');
expect(output).not.toContain('F1 to detach');
expect(rootKeys()).toMatch(/-T root F1\s+display-message mine/);
} finally {
script.kill();
run(`${tmux} unbind-key -T root F1`);
}
});
});
+206 -11
View File
@@ -52,11 +52,24 @@ vi.mock('node:child_process', async () => {
};
});
// Paths realpathSync should resolve differently than this host does, e.g. a
// macOS `/tmp` -> `/private/tmp`. Hoisted so it survives vi.resetModules().
const realpathOverrides = vi.hoisted(() => new Map<string, string>());
// Mock fs to avoid file I/O
vi.mock('node:fs', async () => {
const actual = await vi.importActual('node:fs');
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
const realpathSync = Object.assign(
vi.fn((path: string, ...rest: unknown[]) =>
realpathOverrides.has(String(path))
? realpathOverrides.get(String(path))
: (actual.realpathSync as (...args: unknown[]) => string)(path, ...rest)
),
{ native: actual.realpathSync.native }
);
return {
...actual,
realpathSync,
existsSync: vi.fn().mockReturnValue(false),
readFileSync: vi.fn(),
mkdirSync: vi.fn(),
@@ -714,20 +727,99 @@ describe('TmuxManager (unit)', () => {
});
});
describe('tmux launch cwd hardening', () => {
async function importWithTmuxCommandsEnabled(): Promise<typeof TmuxManager> {
const originalVitest = process.env.VITEST;
vi.resetModules();
async function importWithTmuxCommandsEnabled(): Promise<typeof TmuxManager> {
const originalVitest = process.env.VITEST;
vi.resetModules();
delete process.env.VITEST;
const module = await import('../src/tmux-manager.js');
if (originalVitest === undefined) {
delete process.env.VITEST;
const module = await import('../src/tmux-manager.js');
if (originalVitest === undefined) {
delete process.env.VITEST;
} else {
process.env.VITEST = originalVitest;
} else {
process.env.VITEST = originalVitest;
}
return module.TmuxManager;
}
describe('prefix table on the codeman socket', () => {
it("reduces the socket's prefix table to send-prefix before the agent starts", async () => {
// Browser keystrokes reach the attach client; with the stock table Ctrl+B ) or s
// would move a tab onto another session (the behavior itself: tmux-prefix-table.test.ts).
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('which tmux')) return '/usr/bin/tmux\n';
if (typeof cmd === 'string' && cmd.includes('display-message') && cmd.includes('#{pane_pid}')) return '4242\n';
// Not tmux's stock C-b, so a hardcoded prefix cannot pass for the one read from tmux.
if (typeof cmd === 'string' && cmd.includes(' show-options -gv prefix')) return 'C-a\n';
if (typeof cmd === 'string' && cmd.includes(' list-keys -T prefix')) {
return 'bind-key -T prefix C-a send-prefix\nbind-key -T prefix ) switch-client -n\n';
}
return '';
});
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.createSession({
sessionId: 'abc12345-1234-5678-90ab-cdef12345678',
workingDir: '/project',
mode: 'shell',
});
const commands = mockedExecSync.mock.calls.map(([cmd]) => String(cmd));
expect(commands).toContain(`tmux -L 'codeman' unbind-key -a -T prefix`);
const bind = commands.indexOf(`tmux -L 'codeman' bind-key -T prefix 'C-a' send-prefix`);
expect(bind).toBeGreaterThan(commands.indexOf(`tmux -L 'codeman' unbind-key -a -T prefix`));
expect(bind).toBeLessThan(commands.findIndex((c) => c.includes(' respawn-pane ')));
} finally {
nonTestManager.destroy();
}
return module.TmuxManager;
});
/** execSync for a boot-time reconcile: `panes` is what `list-panes -a` prints. */
function mockReconcileTmux(panes: string): void {
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('which tmux')) return '/usr/bin/tmux\n';
if (typeof cmd === 'string' && cmd.includes(' list-panes -a ')) return panes;
if (typeof cmd === 'string' && cmd.includes(' show-options -gv prefix')) return 'C-a\n';
if (typeof cmd === 'string' && cmd.includes(' list-keys -T prefix')) {
return 'bind-key -T prefix C-a send-prefix\nbind-key -T prefix ) switch-client -n\n';
}
return '';
});
}
it('reduces it at boot too, for sessions that survived an upgrade', async () => {
mockReconcileTmux('codeman-abc12345|4242|0||\n');
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.reconcileSessions();
const commands = mockedExecSync.mock.calls.map(([cmd]) => String(cmd));
const unbind = commands.indexOf(`tmux -L 'codeman' unbind-key -a -T prefix`);
expect(unbind).toBeGreaterThan(-1);
expect(commands.indexOf(`tmux -L 'codeman' bind-key -T prefix 'C-a' send-prefix`)).toBeGreaterThan(unbind);
} finally {
nonTestManager.destroy();
}
});
it('leaves the key tables alone at boot when the socket lists no pane', async () => {
// On an empty socket `list-keys` would start a throwaway tmux server.
mockReconcileTmux('');
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.reconcileSessions();
const commands = mockedExecSync.mock.calls.map(([cmd]) => String(cmd));
expect(commands.some((c) => c.includes(' list-panes -a '))).toBe(true);
expect(commands.filter((c) => /show-options -gv prefix|list-keys|unbind-key|bind-key/.test(c))).toEqual([]);
} finally {
nonTestManager.destroy();
}
});
});
describe('tmux launch cwd hardening', () => {
beforeEach(() => {
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('which tmux')) {
@@ -903,6 +995,92 @@ describe('TmuxManager (unit)', () => {
nonTestManager.destroy();
}
});
it('gives a discovered session the directory tmux reports for its pane, not the server cwd', async () => {
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes(' list-panes ')) {
return [
'codeman-aaaa1111|101|0|||/home/user/project-a',
// A hand-split session answers with the pane tmux lists first.
'codeman-bbbb2222|102|0|||/home/user/project-b',
'codeman-bbbb2222|103|0|||/home/user/project-b/sub',
// Set by whoever owns the pane, so it must pass the same check as any workingDir.
'codeman-cccc3333|104|0|||/home/user/$(touch pwned)',
// A tmux that reported no path.
'codeman-dddd4444|105|0|||',
// The launch directory every pane starts in; a remote or docker pane never leaves it.
'codeman-eeee5555|106|0|||/tmp',
// A directory deleted while its pane ran, as tmux 3.4 on Linux reports it. isValidPath()
// rejects it for the parentheses; a relaxed path pattern must not adopt a dead directory.
'codeman-abcd7777|108|0|||/home/user/gone (deleted)',
// Last row, path ending in a space: the output must not be trimmed.
'codeman-ffff6666|107|0|||/home/user/trailing ',
].join('\n');
}
return '';
});
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
const { discovered } = await nonTestManager.reconcileSessions();
expect(discovered).toEqual([
'restored-aaaa1111',
'restored-bbbb2222',
'restored-cccc3333',
'restored-dddd4444',
'restored-eeee5555',
'restored-abcd7777',
'restored-ffff6666',
]);
const dirs = Object.fromEntries(nonTestManager.getSessions().map((s) => [s.muxName, s.workingDir]));
expect(dirs).toEqual({
'codeman-aaaa1111': '/home/user/project-a',
'codeman-bbbb2222': '/home/user/project-b',
'codeman-cccc3333': process.cwd(),
'codeman-dddd4444': process.cwd(),
'codeman-eeee5555': process.cwd(),
'codeman-abcd7777': process.cwd(),
'codeman-ffff6666': '/home/user/trailing ',
});
// The directory comes from the same list-panes call, as its last field.
const listCalls = mockedExecSync.mock.calls
.map(([cmd]) => cmd)
.filter((cmd): cmd is string => typeof cmd === 'string' && cmd.includes(' list-panes '));
expect(listCalls).toHaveLength(1);
expect(listCalls[0]).toContain("|#{pane_current_path}'");
} finally {
nonTestManager.destroy();
}
});
it('treats the resolved spelling of the launch directory as no answer too (macOS /private/tmp)', async () => {
// On macOS /tmp is a symlink and a pane reports its process cwd resolved.
realpathOverrides.set('/tmp', '/private/tmp');
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes(' list-panes ')) {
return ['codeman-aaaa1111|101|0|||/private/tmp', 'codeman-bbbb2222|102|0|||/tmp'].join('\n');
}
return '';
});
try {
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.reconcileSessions();
const dirs = Object.fromEntries(nonTestManager.getSessions().map((s) => [s.muxName, s.workingDir]));
expect(dirs).toEqual({
'codeman-aaaa1111': process.cwd(),
'codeman-bbbb2222': process.cwd(),
});
} finally {
nonTestManager.destroy();
}
} finally {
realpathOverrides.clear();
}
});
});
});
@@ -1013,6 +1191,23 @@ describe('parsePaneRows', () => {
expect(row.dead).toBeUndefined();
});
it('reads the pane directory from the last field', () => {
const [row] = parsePaneRows('codeman-aaaa|1234|0|||/home/user/project');
expect(row.pid).toBe(1234);
expect(row.dead).toBe(false);
expect(row.currentPath).toBe('/home/user/project');
});
it('keeps a pane directory that contains the separator whole', () => {
const [row] = parsePaneRows('codeman-aaaa|1234|0|||/home/user/a|b');
expect(row.currentPath).toBe('/home/user/a|b');
});
it('leaves the pane directory undefined when tmux reported none', () => {
expect(parsePaneRows('codeman-aaaa|1234')[0].currentPath).toBeUndefined();
expect(parsePaneRows('codeman-aaaa|1234|0|||')[0].currentPath).toBeUndefined();
});
it('returns one row per pane of a split session, in tmux order', () => {
const rows = parsePaneRows('codeman-aaaa|100|0|||\ncodeman-aaaa|200|1|0|');
expect(rows.map((row) => row.pid)).toEqual([100, 200]);
+280
View File
@@ -0,0 +1,280 @@
/**
* @fileoverview The tmux prefix table on Codeman's socket holds only
* `<prefix> send-prefix`, so keys typed into a browser terminal cannot move its
* attach client onto another session, without taking `codeman tui`'s way out away.
*
* Two layers here (the createSession wiring is in tmux-manager.test.ts):
* - the decision (`prefixTableNeedsReset`) on real `list-keys` shapes,
* - the effect against a REAL tmux server on a private throwaway socket (never
* Codeman's own), driven through a real attach client in a pty, the way a
* browser tab drives one.
*
* Port: N/A (no server)
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execSync } from 'node:child_process';
import { rmSync } from 'node:fs';
import * as pty from 'node-pty';
import { prefixTableNeedsReset, restrictTmuxPrefixTable } from '../src/tmux-prefix-table.js';
/** A trimmed copy of tmux 3.4's stock prefix table, as `list-keys -T prefix` prints it. */
const STOCK = [
'bind-key -T prefix C-b send-prefix',
'bind-key -T prefix C-o rotate-window',
'bind-key -T prefix C-z suspend-client',
'bind-key -T prefix ( switch-client -p',
'bind-key -T prefix ) switch-client -n',
'bind-key -T prefix L switch-client -l',
'bind-key -T prefix d detach-client',
'bind-key -T prefix s choose-tree -Zs',
'bind-key -T prefix w choose-tree -Zw',
'bind-key -r -T prefix Up select-pane -U',
].join('\n');
const RESTRICTED = 'bind-key -T prefix C-b send-prefix\n';
describe('prefixTableNeedsReset', () => {
it("resets tmux's stock table (switch-client, choose-tree, detach-client on d)", () => {
expect(prefixTableNeedsReset(STOCK)).toBe(true);
});
it('leaves a table that holds only C-b send-prefix alone', () => {
expect(prefixTableNeedsReset(RESTRICTED)).toBe(false);
});
it("leaves a codeman tui attach's held-Ctrl detach alias alone", () => {
expect(prefixTableNeedsReset(`${RESTRICTED}bind-key -T prefix C-d detach-client\n`)).toBe(
false
);
});
it('resets when a single foreign binding is back, or the stock detach key d', () => {
expect(prefixTableNeedsReset(`${RESTRICTED}bind-key -T prefix s choose-tree -Zs\n`)).toBe(
true
);
expect(prefixTableNeedsReset(`${RESTRICTED}bind-key -T prefix d detach-client\n`)).toBe(
true
);
});
it('resets an empty table or one without send-prefix, and output it cannot read', () => {
expect(prefixTableNeedsReset('')).toBe(true);
expect(prefixTableNeedsReset('bind-key -T prefix C-d detach-client\n')).toBe(true);
expect(prefixTableNeedsReset('something unexpected\n')).toBe(true);
});
it("follows the socket's configured prefix instead of assuming C-b", () => {
const ctrlA = 'bind-key -T prefix C-a send-prefix\n';
expect(prefixTableNeedsReset(ctrlA, 'C-a')).toBe(false);
// C-b send-prefix under a C-a prefix is a leftover, not the binding we want.
expect(prefixTableNeedsReset(RESTRICTED, 'C-a')).toBe(true);
expect(prefixTableNeedsReset('', 'None')).toBe(false);
});
it('reads a key tmux lists backslash-escaped as the key itself', () => {
// `prefix C-\` is printed as is by show-options, but listed as `C-\\`.
expect(prefixTableNeedsReset('bind-key -T prefix C-\\\\ send-prefix\n', 'C-\\')).toBe(false);
});
});
describe('restrictTmuxPrefixTable (recorded commands)', () => {
/** A runner that answers from `replies` (a thrown Error stands for a failing command) and records every call. */
function fakeTmux(replies: Record<string, string | Error>): { run: (cmd: string) => string; calls: string[] } {
const calls: string[] = [];
const run = (cmd: string): string => {
calls.push(cmd);
const sub = cmd.replace(/^tmux -L 'codeman' /, '');
const reply = replies[sub] ?? '';
if (reply instanceof Error) throw reply;
return reply;
};
return { run, calls };
}
it('still binds send-prefix when the table is empty and unbind-key -a fails on it', () => {
// tmux 3.4 on an empty prefix table: both commands exit 1 with "table prefix doesn't exist".
const missing = new Error("table prefix doesn't exist");
const tmux = fakeTmux({
'show-options -gv prefix': 'C-b\n',
'list-keys -T prefix': missing,
'unbind-key -a -T prefix': missing,
});
expect(restrictTmuxPrefixTable(`tmux -L 'codeman'`, tmux.run)).toBe(true);
expect(tmux.calls).toContain(`tmux -L 'codeman' bind-key -T prefix 'C-b' send-prefix`);
});
it('does not reset again when the listed prefix is only escaped differently', () => {
const tmux = fakeTmux({
'show-options -gv prefix': 'C-\\\n',
'list-keys -T prefix': 'bind-key -T prefix C-\\\\ send-prefix\n',
});
expect(restrictTmuxPrefixTable(`tmux -L 'codeman'`, tmux.run)).toBe(false);
expect(tmux.calls.filter((c) => c.includes('bind-key'))).toEqual([]);
});
});
const hasTmux = (() => {
try {
execSync('tmux -V', { stdio: 'ignore' });
return true;
} catch {
return false;
}
})();
describe.skipIf(!hasTmux)('restrictTmuxPrefixTable against a real tmux (private socket)', () => {
// Throwaway server, never Codeman's socket; -f /dev/null keeps the user's config out.
const socket = `codeman-prefix-test-${process.pid}`;
const tmux = `tmux -L ${socket} -f /dev/null`;
const run = (cmd: string): string => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
const keys = (table: string) => run(`${tmux} list-keys -T ${table}`);
let client: pty.IPty | null = null;
/** The session the attach client is looking at right now. */
const clientSession = () => run(`${tmux} list-clients -F '#{client_session}'`).trim();
async function waitFor(check: () => boolean, ms = 3000): Promise<boolean> {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (check()) return true;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return check();
}
/** Types keys into the attach client, then gives tmux time to act on them. */
async function type(keysToSend: string): Promise<void> {
for (const ch of keysToSend) {
client!.write(ch);
await new Promise((resolve) => setTimeout(resolve, 100));
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
beforeAll(async () => {
// `cat -v` prints a received Ctrl+B as ^B, which is how send-prefix shows up.
run(`${tmux} new-session -d -s alpha -x 80 -y 24 'cat -v'`);
run(`${tmux} new-session -d -s beta -x 80 -y 24 'cat -v'`);
const env = { ...process.env } as Record<string, string>;
delete env.TMUX; // the suite may itself run inside tmux
// What a browser tab is: a pty running `tmux attach-session` (tmux-manager getAttachArgs).
client = pty.spawn('tmux', ['-L', socket, 'attach-session', '-t', 'alpha'], {
name: 'xterm-256color',
cols: 80,
rows: 24,
env,
});
expect(await waitFor(() => clientSession() === 'alpha')).toBe(true);
});
afterAll(() => {
client?.kill();
let socketPath = '';
try {
socketPath = run(`${tmux} display-message -p '#{socket_path}'`).trim();
run(`${tmux} kill-server`); // our own throwaway socket only
} catch {
/* already gone */
}
// tmux leaves the socket file behind; remove ours (and only ours).
if (socketPath.endsWith(`/${socket}`)) rmSync(socketPath, { force: true });
});
it("with tmux's stock table, Ctrl+B ) moves the attach client onto another session", async () => {
// The bug, reproduced so the next test means something.
await type('\x02)');
expect(await waitFor(() => clientSession() === 'beta')).toBe(true);
run(`${tmux} switch-client -c $(${tmux} list-clients -F '#{client_name}') -t alpha`);
expect(await waitFor(() => clientSession() === 'alpha')).toBe(true);
});
it('after the reset, Ctrl+B ) and Ctrl+B s leave the client where it is, Ctrl+B Ctrl+B reaches the pane', async () => {
const rootBefore = keys('root');
expect(restrictTmuxPrefixTable(tmux, run)).toBe(true);
const prefix = keys('prefix')
.split('\n')
.filter((l) => l.trim());
expect(prefix).toHaveLength(1);
expect(prefix[0]).toMatch(/-T prefix C-b\s+send-prefix$/);
expect(keys('root')).toBe(rootBefore);
expect(run(`${tmux} show-options -gv prefix`).trim()).toBe('C-b');
await type('\x02)');
await type('\x02s');
expect(clientSession()).toBe('alpha');
expect(run(`${tmux} display-message -p -t alpha '#{pane_in_mode}'`).trim()).toBe('0');
await type('\x02\x02');
expect(await waitFor(() => run(`${tmux} capture-pane -p -t alpha`).includes('^B'))).toBe(true);
});
it("keeps a codeman tui attach's way out when another session is created mid-attach", () => {
// What beginAttachHandoff claims: C-d in the prefix table, F1 and M-1 in root.
run(`${tmux} bind-key -T prefix C-d detach-client`);
run(`${tmux} bind-key -T root F1 detach-client`);
run(`${tmux} bind-key -T root M-1 switch-client -t alpha`);
expect(restrictTmuxPrefixTable(tmux, run)).toBe(false);
expect(keys('prefix')).toMatch(/-T prefix C-d\s+detach-client/);
expect(keys('root')).toMatch(/-T root F1\s+detach-client/);
expect(keys('root')).toMatch(/-T root M-1\s+switch-client -t alpha/);
});
it("gives a running attach's held-Ctrl way out back when the reset clears a stock table", () => {
// A tmux server whose table is still stock while a codeman tui attach holds C-d:
// `unbind-key -a` takes C-d along with the rest.
try {
run(`${tmux} bind-key -T prefix d detach-client`);
run(`${tmux} bind-key -T prefix ')' switch-client -n`);
run(`${tmux} bind-key -T prefix C-d detach-client`);
expect(restrictTmuxPrefixTable(tmux, run)).toBe(true);
const prefix = keys('prefix')
.split('\n')
.filter((l) => l.trim());
expect(prefix).toHaveLength(2);
expect(keys('prefix')).toMatch(/-T prefix C-b\s+send-prefix/);
expect(keys('prefix')).toMatch(/-T prefix C-d\s+detach-client/);
} finally {
run(`${tmux} unbind-key -T prefix C-d`);
}
});
it('binds send-prefix to the configured prefix when the socket uses another one', () => {
run(`${tmux} set-option -g prefix C-a`);
try {
expect(restrictTmuxPrefixTable(tmux, run)).toBe(true);
const prefix = keys('prefix')
.split('\n')
.filter((l) => l.trim());
expect(prefix).toEqual([expect.stringMatching(/-T prefix C-a\s+send-prefix$/)]);
} finally {
run(`${tmux} set-option -g prefix C-b`);
}
});
it('binds send-prefix on a server whose prefix table is empty', () => {
// On an empty table tmux fails both `list-keys -T prefix` and `unbind-key -a -T prefix`.
run(`${tmux} unbind-key -a -T prefix`);
expect(() => run(`${tmux} unbind-key -a -T prefix`)).toThrow();
expect(restrictTmuxPrefixTable(tmux, run)).toBe(true);
const prefix = keys('prefix')
.split('\n')
.filter((l) => l.trim());
expect(prefix).toEqual([expect.stringMatching(/-T prefix C-b\s+send-prefix$/)]);
});
it('resets once, not on every call, for a prefix tmux lists escaped (C-\\)', () => {
run(`${tmux} set-option -g prefix 'C-\\'`);
try {
expect(restrictTmuxPrefixTable(tmux, run)).toBe(true);
expect(keys('prefix')).toMatch(/-T prefix C-\\\\\s+send-prefix/);
expect(restrictTmuxPrefixTable(tmux, run)).toBe(false);
} finally {
run(`${tmux} set-option -g prefix C-b`);
}
});
});
+110 -4
View File
@@ -12,16 +12,27 @@
* a one-line prompt). With `paste-buffer -p` the paste is marked and the separate
* Enter submits.
*
* Also the line breaks of a multi-line `sendInput`: kept, and pasted as ONE block
* with or without the Enter, where they used to be stripped and the lines joined.
*
* Strategy: pure command shape, plus a round trip through a real shell with a fake
* `tmux` that prints its argv, so quoting and tmux's trailing-`;` rule are checked
* on the bytes a shell actually hands over. `pasteText` runs against a recording
* runner. No real tmux.
* runner, and `sendInput` against a subclass whose `inputRunner()` records the
* commands instead of running them. No real tmux.
*
* Port: N/A (no server / no real tmux)
*/
import { execSync } from 'node:child_process';
import { describe, expect, it } from 'vitest';
import { buildPasteTextCommands, escapeTmuxTrailingSemicolon, pasteText, submitsAsKeys } from '../src/tmux-manager.js';
import type { MuxSession } from '../src/mux-interface.js';
import {
TmuxManager,
buildPasteTextCommands,
escapeTmuxTrailingSemicolon,
pasteText,
submitsAsKeys,
} from '../src/tmux-manager.js';
const FAKE_TMUX = `${JSON.stringify(process.execPath)} -e 'console.log(JSON.stringify(process.argv.slice(1)))' --`;
@@ -137,8 +148,8 @@ describe('pasteText', () => {
});
describe('sendInput wiring', () => {
// sendInput() returns before touching tmux under vitest (IS_TEST_MODE), so the
// branch that submits text is pinned on the source instead.
// These pin the branch shapes on the source; the commands sendInput actually runs
// are recorded in 'sendInput line breaks' below.
async function source(): Promise<string> {
const { readFileSync } = await import('node:fs');
return readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf-8');
@@ -188,3 +199,98 @@ describe('sendInput wiring', () => {
for (const cmd of literal) expect(cmd).toMatch(/\s-l -- \$\{shellescape\(/);
});
});
describe('sendInput line breaks', () => {
class RecordingManager extends TmuxManager {
ran: string[] = [];
protected override inputRunner(): (cmd: string) => Promise<unknown> {
return async (cmd: string) => {
this.ran.push(cmd);
};
}
}
function managerWithSession(): RecordingManager {
const manager = new RecordingManager();
manager.registerSession({
sessionId: 's1',
muxName: 'codeman-abc12345',
pid: 100,
createdAt: 0,
workingDir: '/tmp',
mode: 'claude',
attached: true,
} as MuxSession);
return manager;
}
/** The text a recorded `set-buffer` command hands to tmux, as a shell delivers it. */
function bufferText(setBufferCmd: string): string {
return argvOf(setBufferCmd.replace(/^.*? set-buffer /, `${FAKE_TMUX} set-buffer `)).at(-1) as string;
}
it('submits a multi-line prompt as one paste with its line breaks, then Enter', async () => {
const manager = managerWithSession();
expect(await manager.sendInput('s1', 'line one\nline two\n\nparagraph three\r')).toBe(true);
expect(manager.ran).toHaveLength(3);
const [set, paste, enter] = manager.ran;
expect(bufferText(set)).toBe('line one\nline two\n\nparagraph three');
expect(paste).toMatch(/ paste-buffer -p -d -b \S+ -t 'codeman-abc12345'$/);
expect(enter).toMatch(/ send-keys -t "codeman-abc12345" Enter$/);
});
it('turns CRLF line endings into plain line breaks', async () => {
const manager = managerWithSession();
await manager.sendInput('s1', 'one\r\ntwo\r');
expect(bufferText(manager.ran[0])).toBe('one\ntwo');
});
it('pastes multi-line text without Enter too, and never types it as keys', async () => {
const manager = managerWithSession();
await manager.sendInput('s1', 'one\ntwo');
expect(manager.ran).toHaveLength(2);
expect(bufferText(manager.ran[0])).toBe('one\ntwo');
expect(manager.ran[1]).toContain(' paste-buffer -p ');
for (const cmd of manager.ran) expect(cmd).not.toContain('send-keys');
});
it('drops trailing line breaks instead of pasting an empty last line', async () => {
const manager = managerWithSession();
await manager.sendInput('s1', 'one\ntwo\n\n\r');
expect(bufferText(manager.ran[0])).toBe('one\ntwo');
});
it('drops leading line breaks instead of pasting an empty first line', async () => {
const single = managerWithSession();
await single.sendInput('s1', '\nfix it\r');
expect(bufferText(single.ran[0])).toBe('fix it');
const multi = managerWithSession();
await multi.sendInput('s1', '\r\n\r\none\ntwo\r');
expect(bufferText(multi.ran[0])).toBe('one\ntwo');
// A blank first line made of spaces is dropped too; the first real line keeps its indentation.
const spaced = managerWithSession();
await spaced.sendInput('s1', ' \n\t\n fix it\nnow\r');
expect(bufferText(spaced.ran[0])).toBe(' fix it\nnow');
// Without an Enter, what is left is one line, so it is typed like any other.
const typed = managerWithSession();
await typed.sendInput('s1', '\n\njust type');
expect(typed.ran).toEqual([expect.stringMatching(/ send-keys -t "codeman-abc12345" -l 'just type'$/)]);
});
it('leaves single-line input as it was: pasted with Enter, typed without', async () => {
const submitted = managerWithSession();
await submitted.sendInput('s1', 'read your inbox\r');
expect(bufferText(submitted.ran[0])).toBe('read your inbox');
expect(submitted.ran.at(-1)).toMatch(/ send-keys -t "codeman-abc12345" Enter$/);
const typed = managerWithSession();
await typed.sendInput('s1', 'just type');
expect(typed.ran).toHaveLength(1);
expect(typed.ran[0]).toMatch(/ send-keys -t "codeman-abc12345" -l 'just type'$/);
});
});
+41 -2
View File
@@ -13,6 +13,7 @@ import { describe, it, expect } from 'vitest';
import {
applyLiveMetrics,
applyMuxNames,
beginAttachHandoff,
buildAttachBanner,
buildListLines,
confirmKillStep,
@@ -38,6 +39,7 @@ import {
tmuxSocketFromEnv,
} from '../../src/tui/tui-app.js';
import { createTuiModel, stateSince } from '../../src/tui/tui-model.js';
import type { TuiClient } from '../../src/tui/tui-client.js';
import { glyphsFor } from '../../src/tui/tui-render.js';
import type { TuiLiveSessionMetrics, TuiTmuxSession } from '../../src/tui/tui-client.js';
import type { TuiConfirmState, TuiRow, TuiSessionRow } from '../../src/tui/tui-types.js';
@@ -596,6 +598,40 @@ describe('the held-Ctrl detach alias', () => {
});
});
describe('the way out when F1 is taken, on a socket with no detach key', () => {
// Codeman keeps only `<prefix> send-prefix` in its socket's prefix table
// (src/tmux-prefix-table.ts), so tmux's stock `Ctrl+B then d` does nothing there.
it('names the held-Ctrl chord on the bar instead of the stock d', () => {
const bar = buildAttachBanner({ prefix: 'C-b', heldAlias: 'C-d' })['status-format[0]'];
expect(bar).toContain('#[bold]Ctrl+B then Ctrl+D#[nobold]');
expect(bar).not.toContain('then d');
});
it('hands the same chord back from the attach handoff, for the help overlay and the hint line', async () => {
const free = new Set(['C-d', 'M-1']);
const client = {
readPrefixKey: async () => 'C-b',
readDetachKey: async () => null,
readPrefixBinding: async (key: string) => (free.has(key) ? null : 'display-message mine'),
bindDetachKey: async () => true,
unbindDetachKey: async () => true,
bindSwitchKey: async () => true,
unbindSwitchKey: async () => true,
readSessionOptions: async () => null,
applySessionOptions: async () => true,
restoreSessionOptions: async () => true,
readWindowSizing: async () => null,
presizeWindow: async () => true,
followAttachingClient: async () => true,
restoreWindowSizing: async () => true,
} as unknown as TuiClient;
const handoff = await beginAttachHandoff(client, 'codeman-abc12345', 'w1-case');
expect(handoff.chord).toBe('Ctrl+B then Ctrl+D');
});
});
describe('naming a session the TUI starts', () => {
it("follows the web UI's w<n>-<case> convention", () => {
expect(nextSessionName('mirofish', [])).toBe('w1-mirofish');
@@ -737,8 +773,11 @@ describe('the way out of an attach', () => {
const keys = helpKeysFor(GLYPHS, { server: true, detach: 'Ctrl+A then d' });
const detach = keys.find(([key]) => key === 'Ctrl+A then d');
expect(detach?.[1]).toContain('detach');
// Degraded mode still attaches, so it still needs the way out.
expect(helpKeysFor(GLYPHS, { server: false }).map(([key]) => key)).toContain('Ctrl+B then d');
// Degraded mode still attaches, so it still needs the way out. Before the first
// attach that is F1: Codeman's socket has no `d` in its prefix table to name.
const before = helpKeysFor(GLYPHS, { server: false }).map(([key]) => key);
expect(before).toContain(ONE_KEY_DETACH);
expect(before).not.toContain('Ctrl+B then d');
});
});
+1 -1
View File
@@ -327,7 +327,7 @@ describe('TuiClient.sendInput', () => {
expect(JSON.parse(recorded[0].body)).toMatchObject({ input: 'hello world\r', useMux: true });
});
it('collapses embedded newlines into spaces (multi-line breaks Ink)', async () => {
it('collapses embedded newlines into spaces (a multi-line paste cannot be confirmed as submitted)', async () => {
await client().sendInput('abc', 'echo A\necho B\r\nline three ');
expect(JSON.parse(recorded[0].body).input).toBe('echo A echo B line three\r');
});
+6
View File
@@ -558,6 +558,12 @@ describe('codeman tui (under a pty)', () => {
term.write('?');
await waitFor(() => frameLines(output).some((line) => line.includes('Keys')), 'the help overlay');
expect(frameLines(output).join('\n')).toContain('kill (y to confirm)');
// Nothing has been attached yet, so this is the dashboard's starting label:
// F1, what an attach claims, not tmux's stock chord, which Codeman's socket
// does not bind (src/tmux-prefix-table.ts).
const detachLine = frameLines(output).find((line) => line.includes('detach from an attached session'));
expect(detachLine).toContain('F1');
expect(detachLine).not.toContain('Ctrl+B');
term.write('\u001b');
await waitFor(() => !frameLines(output).some((line) => line.includes('Keys')), 'escape to close the overlay');
+147
View File
@@ -0,0 +1,147 @@
/**
* The senders that run unattended write a prompt as ONE line, whatever the config holds.
*
* Respawn's update and kickstart prompts are textareas in the settings, and the
* auto-compact focus prompt's schema accepts line breaks, so a saved config can hold
* several lines. `TmuxManager.sendInput()` used to strip every `\n`, which ran the
* lines together; it now pastes multi-line text as one block. These senders run
* unattended, and the check that re-presses a stranded Enter cannot reliably
* recognise a multi-line paste in the composer, so they join the lines with a space
* before the write (`toSingleLine()`) instead of leaning on the mux to drop them.
*
* Strategy: drive the respawn step methods against a MockSession, and feed a real
* Session a token count over its auto-compact threshold, then read what reached
* `writeViaMux`. No real tmux.
*
* Port: N/A (no server / no real tmux)
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('node:child_process', async (orig) => {
const actual = await orig<typeof import('node:child_process')>();
return { ...actual, exec: vi.fn((_cmd: string, cb?: (e: Error | null, o: string) => void) => cb?.(null, '')) };
});
import { RespawnController } from '../src/respawn-controller.js';
import { toSingleLine } from '../src/utils/single-line.js';
import { Session } from '../src/session.js';
import { MockSession } from './mocks/index.js';
describe('toSingleLine', () => {
it('joins lines with one space, folding the whitespace around each break', () => {
expect(toSingleLine('update the docs\n\n then CLAUDE.md\r\nand continue')).toBe(
'update the docs then CLAUDE.md and continue'
);
});
it('returns a single-line prompt unchanged', () => {
expect(toSingleLine(' keep "quotes" and $vars ')).toBe(' keep "quotes" and $vars ');
});
it('drops leading and trailing blank lines instead of leaving a space there', () => {
expect(toSingleLine('\n compact the log \n\n')).toBe('compact the log');
});
it('stays linear on a long whitespace run', () => {
// The run sits inside a line, so the result is the same either way; only the time
// differs. A regex that folds the whitespace around each break retries the run from
// every start position: 50,000 spaces took over a second that way. The split takes
// well under a millisecond, so the bound leaves room either side.
const run = ' '.repeat(50_000);
const started = performance.now();
const joined = toSingleLine(`a${run}b\nc`);
const elapsed = performance.now() - started;
expect(joined).toBe(`a${run}b c`);
expect(elapsed).toBeLessThan(250);
});
});
describe('respawn steps send a multi-line prompt as one line', () => {
let controller: RespawnController | null = null;
afterEach(() => controller?.stop());
/** Run one step method and return what it wrote through the mux. */
async function written(
config: { updatePrompt?: string; kickstartPrompt?: string },
step: 'sendUpdateDocs' | 'sendKickstart',
recommendation?: string
): Promise<string[]> {
const session = new MockSession();
if (recommendation !== undefined) {
(session as unknown as { ralphTracker: unknown }).ralphTracker = { lastStatusBlock: { recommendation } };
}
controller = new RespawnController(session as unknown as Session, {
interStepDelayMs: 10,
completionConfirmMs: 10,
noOutputTimeoutMs: 500,
aiIdleCheckEnabled: false,
...config,
});
(controller as never as { _state: string })._state = 'watching';
(controller as never as Record<typeof step, () => void>)[step]();
// The step schedules its write on the step-delay timer.
await new Promise((r) => setTimeout(r, 40));
return session.writeBuffer;
}
it('update prompt', async () => {
const writes = await written(
{ updatePrompt: 'summarise progress\nin CLAUDE.md\n\nthen continue' },
'sendUpdateDocs'
);
expect(writes).toEqual(['summarise progress in CLAUDE.md then continue\r']);
});
it('update prompt with a recommendation appended', async () => {
const writes = await written({ updatePrompt: 'summarise\nprogress' }, 'sendUpdateDocs', 'fix the\nlogin test');
expect(writes).toEqual(["summarise progress (Claude's last recommendation: fix the login test)\r"]);
});
it('kickstart prompt', async () => {
const writes = await written(
{ kickstartPrompt: 'read @fix_plan.md\r\ncontinue with the next task' },
'sendKickstart'
);
expect(writes).toEqual(['read @fix_plan.md continue with the next task\r']);
});
it('a single-line prompt is sent as before', async () => {
const writes = await written({ updatePrompt: 'update all the docs and CLAUDE.md' }, 'sendUpdateDocs');
expect(writes).toEqual(['update all the docs and CLAUDE.md\r']);
});
});
describe('auto-compact sends a multi-line focus prompt as one line', () => {
afterEach(() => vi.useRealTimers());
/**
* Feed a Session a status line over the threshold and return what its auto-compact
* wrote through `writeViaMux`: the session's own token parser, the trigger and its
* timers, and the `writeCommand` wiring all run as in production.
*/
async function compactWrites(prompt?: string): Promise<string[]> {
vi.useFakeTimers();
const session = new Session({ workingDir: '/tmp' });
const writeViaMux = vi.spyOn(session, 'writeViaMux').mockResolvedValue(true);
session.setAutoCompact(true, 1_000, prompt);
(session as never as { parseTokensFromStatusLine(s: string): void }).parseTokensFromStatusLine('5.0k tokens');
// The trigger waits AUTO_INITIAL_DELAY_MS (1 s) before it writes.
await vi.advanceTimersByTimeAsync(1_500);
return writeViaMux.mock.calls.map((call) => call[0]);
}
it('focus prompt with line breaks', async () => {
expect(await compactWrites('keep the open tasks\n\n and the test results\r\nfrom today')).toEqual([
'/compact keep the open tasks and the test results from today\r',
]);
});
it('a single-line focus prompt is sent as before', async () => {
expect(await compactWrites('keep the open tasks')).toEqual(['/compact keep the open tasks\r']);
});
it('no focus prompt sends a bare /compact', async () => {
expect(await compactWrites()).toEqual(['/compact\r']);
});
});
+141
View File
@@ -0,0 +1,141 @@
/**
* @fileoverview The webview proxy relays a request body with the browser's own
* Content-Length, not as `Transfer-Encoding: chunked`.
*
* The proxy hands undici the body as a stream, and undici frames a stream with
* no known length as chunked. Servers that read only Content-Length (Python's
* `http.server`, many small Go/C ones) then see an EMPTY body: a WoL dashboard's
* "Shut down" posted `{"confirm":true}` and was refused for missing the flag on
* every click through the web tab, while curl straight at it worked.
*
* Uses app.inject() against the real registerWebviewRoutes with a real upstream
* that parses requests the way such a server does: headers, then exactly
* Content-Length bytes, and nothing else. Port: ephemeral (server.listen(0)).
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import { createServer, type Server } from 'node:net';
import { registerWebviewRoutes } from '../src/web/routes/webview-routes.js';
import { webviewCapabilities } from '../src/webview-capabilities.js';
import { writeWebviews } from '../src/webview-store.js';
import { getDataDir } from '../src/config/instance.js';
import type { EventPort } from '../src/web/ports/index.js';
const WEBVIEW_ID = 'wv-body-framing-test';
let upstream: Server;
let upstreamPort: number;
let app: FastifyInstance;
let capability: string;
const eventPortStub: EventPort = {
broadcast: vi.fn(),
sendPushNotifications: vi.fn(),
batchTerminalData: vi.fn(),
broadcastSessionStateDebounced: vi.fn(),
batchTaskUpdate: vi.fn(),
getSseClientCount: vi.fn(() => 0),
};
interface Seen {
method: string;
body: string;
contentLength: string | null;
transferEncoding: string | null;
}
beforeAll(async () => {
// A Content-Length-only HTTP/1.0 server. It answers with what it saw, so a
// chunked body shows up as `body: ''` exactly as it did for the dashboard.
upstream = createServer((socket) => {
let buf = Buffer.alloc(0);
const onData = (chunk: Buffer) => {
buf = Buffer.concat([buf, chunk]);
const end = buf.indexOf('\r\n\r\n');
if (end < 0) return;
const lines = buf.subarray(0, end).toString('latin1').split('\r\n');
const headers = new Map(
lines.slice(1).map((l) => [l.slice(0, l.indexOf(':')).trim().toLowerCase(), l.slice(l.indexOf(':') + 1).trim()])
);
const length = Number(headers.get('content-length') ?? 0);
if (buf.length - (end + 4) < length) return;
socket.off('data', onData);
const seen: Seen = {
method: lines[0].split(' ')[0],
body: buf.subarray(end + 4, end + 4 + length).toString(),
contentLength: headers.get('content-length') ?? null,
transferEncoding: headers.get('transfer-encoding') ?? null,
};
const payload = JSON.stringify(seen);
socket.end(
`HTTP/1.0 200 OK\r\ncontent-type: application/json\r\ncontent-length: ${Buffer.byteLength(payload)}\r\n\r\n${payload}`
);
};
socket.on('data', onData);
socket.on('error', () => {});
});
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
const address = upstream.address();
if (typeof address === 'object' && address) upstreamPort = address.port;
await writeWebviews(getDataDir(), [
{
id: WEBVIEW_ID,
name: 'body-framing-under-test',
url: `http://127.0.0.1:${upstreamPort}/`,
embedMode: 'proxy',
trusted: false,
createdAt: Date.now(),
},
]);
capability = webviewCapabilities.mint(WEBVIEW_ID, undefined);
app = Fastify({ logger: false });
registerWebviewRoutes(app, eventPortStub);
await app.ready();
});
afterAll(async () => {
await app.close();
webviewCapabilities.revokeWebview(WEBVIEW_ID);
await new Promise<void>((resolve) => upstream.close(() => resolve()));
});
describe('webview proxy request body framing', () => {
it('relays a JSON POST body with its Content-Length, so a length-only server reads it', async () => {
const res = await app.inject({
method: 'POST',
url: `/webview/${capability}/api/shutdown`,
headers: { 'content-type': 'application/json' },
payload: '{"confirm":true}',
});
expect(res.statusCode).toBe(200);
const seen = res.json() as Seen;
expect(seen.transferEncoding).toBeNull();
expect(seen.contentLength).toBe('16');
expect(JSON.parse(seen.body)).toEqual({ confirm: true });
});
it('keeps a large body intact under the same framing', async () => {
const big = 'x'.repeat(256 * 1024);
const res = await app.inject({
method: 'PUT',
url: `/webview/${capability}/upload`,
headers: { 'content-type': 'application/octet-stream' },
payload: big,
});
const seen = res.json() as Seen;
expect(seen.contentLength).toBe(String(big.length));
expect(seen.body).toBe(big);
});
it('sends no length on a GET, which carries no body', async () => {
const res = await app.inject({ method: 'GET', url: `/webview/${capability}/status` });
const seen = res.json() as Seen;
expect(seen.method).toBe('GET');
expect(seen.contentLength).toBeNull();
expect(seen.transferEncoding).toBeNull();
});
});
+30 -2
View File
@@ -247,17 +247,45 @@ describe('buildUpstreamRequestHeaders', () => {
expect(headers.referer).toBe('http://127.0.0.1:4000/panel');
});
it('drops hop-by-hop and recomputed headers', () => {
it('drops hop-by-hop headers, and a length that a transfer-encoding contradicts', () => {
const headers = buildUpstreamRequestHeaders(
{ host: 'codeman.local', connection: 'keep-alive', 'transfer-encoding': 'chunked', 'content-length': '5' },
upstream,
{ forwardCookies: false, sessionCookieName: 'codeman_session' }
{ forwardCookies: false, sessionCookieName: 'codeman_session', relayBodyLength: true }
);
expect(headers.host).toBeUndefined();
expect(headers.connection).toBeUndefined();
expect(headers['transfer-encoding']).toBeUndefined();
expect(headers['content-length']).toBeUndefined();
});
// A streamed body without a length goes upstream chunked, and a dashboard that
// reads only Content-Length (Python's http.server) then sees an empty body.
it('relays the browser content-length alongside a body', () => {
const headers = buildUpstreamRequestHeaders(
{ 'content-length': '16', 'content-type': 'application/json' },
upstream,
{ forwardCookies: false, sessionCookieName: 'codeman_session', relayBodyLength: true }
);
expect(headers['content-length']).toBe('16');
});
it('never relays a content-length without a body, or one that is not a plain number', () => {
const opts = { forwardCookies: false, sessionCookieName: 'codeman_session' };
expect(buildUpstreamRequestHeaders({ 'content-length': '16' }, upstream, opts)['content-length']).toBeUndefined();
expect(
buildUpstreamRequestHeaders({ 'content-length': '16' }, upstream, { ...opts, relayBodyLength: false })[
'content-length'
]
).toBeUndefined();
for (const bad of ['-1', '16, 16', ' 16', '0x10', '']) {
expect(
buildUpstreamRequestHeaders({ 'content-length': bad }, upstream, { ...opts, relayBodyLength: true })[
'content-length'
]
).toBeUndefined();
}
});
});
describe('buildDownstreamResponseHeaders', () => {
+13 -5
View File
@@ -49,8 +49,10 @@ describe('xterm private-API dependency guard', () => {
'xterm moved off the verified version — re-verify _kickRenderer in a real browser ' +
'(terminal-ui.js: _core._renderService._renderDebouncer._animationFrame) AND the ' +
'CompositionHelper fields installEditSync() uses (terminal-keycode229-recovery.js: ' +
'_handleAnyTextareaChanges, _coreService, _isComposing, _isSendingComposition, _dataAlreadySent), ' +
'then update VERIFIED_XTERM_VERSION here. The accessor is optional-chained, so a renamed field ' +
'_handleAnyTextareaChanges, _coreService, _isComposing, _isSendingComposition, _dataAlreadySent) ' +
'and the mouse-encoding read _shouldForwardTouchToApp makes (terminal-ui.js: ' +
'_core.coreMouseService.activeEncoding), then update VERIFIED_XTERM_VERSION here. ' +
'The accessor is optional-chained, so a renamed field ' +
'degrades to a silent no-op and the freeze it heals comes back unnoticed.'
).toBe(VERIFIED_XTERM_VERSION);
});
@@ -60,9 +62,12 @@ describe('xterm private-API dependency guard', () => {
// append-only diff duplicates). It reaches `_compositionHelper`, `_coreService`, `_isComposing`,
// `_isSendingComposition` and `_dataAlreadySent`; if xterm renames any of them the install quietly
// falls back to xterm's own handler and the duplication returns (or, for `_isSendingComposition`, a
// composition xterm finalizes at an Enter keydown is sent twice). Property names survive
// composition xterm finalizes at an Enter keydown is sent twice). terminal-ui.js's
// _shouldForwardTouchToApp reads `_core.coreMouseService.activeEncoding` to forward a touch drag
// only to an app that asked for SGR mouse reports; if either name changes the read is undefined
// and touch quietly stops forwarding to native SGR tracking apps. Property names survive
// minification, so a string check on the shipped bundle catches a rename on upgrade.
it('still ships the composition-helper fields the edit-based 229 sync depends on', () => {
it('still ships the composition-helper and mouse-service fields terminal input depends on', () => {
const bundle = readFileSync(resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js'), 'utf8');
for (const name of [
'_handleAnyTextareaChanges',
@@ -71,11 +76,14 @@ describe('xterm private-API dependency guard', () => {
'_isComposing',
'_isSendingComposition',
'_dataAlreadySent',
'coreMouseService',
'activeEncoding',
]) {
expect(
bundle,
`xterm no longer mentions ${name}: re-verify terminal-keycode229-recovery.js installEditSync() ` +
'(src/web/public) against the new CompositionHelper before bumping VERIFIED_XTERM_VERSION'
'against the new CompositionHelper, and terminal-ui.js _shouldForwardTouchToApp against the ' +
'new CoreMouseService (both in src/web/public), before bumping VERIFIED_XTERM_VERSION'
).toContain(name);
}
});