mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Merge remote-tracking branch 'origin/master' into relprep/pr-677
# Conflicts: # src/tmux-manager.ts
This commit is contained in:
commit
effb1ef7a5
185 files changed
+13452
-644
No files matched your search
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @fileoverview Accessory keys that keep the keyboard up must not take focus on
|
||||
* press. A tap used to blur the terminal's textarea and the click handler then
|
||||
* refocused it, so every arrow/Tab/Esc tap started a keyboard dismissal. The bar
|
||||
* now cancels the mousedown default for exactly those keys (and the first,
|
||||
* arming tap of clear/compact); every other key keeps the old behavior.
|
||||
*
|
||||
* Loaded with `vm` against a minimal fake DOM, like the other accessory-bar tests.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type Listener = (e: unknown) => void;
|
||||
type Bar = {
|
||||
init(): void;
|
||||
_confirmAction: string | null;
|
||||
syncReadMyMind(): void;
|
||||
syncCodexKeys(): void;
|
||||
_syncComposerDraftIndicator(): void;
|
||||
};
|
||||
|
||||
function loadBar() {
|
||||
const listeners: Record<string, Listener> = {};
|
||||
const element = {
|
||||
className: '',
|
||||
innerHTML: '',
|
||||
addEventListener: (type: string, fn: Listener) => {
|
||||
listeners[type] = fn;
|
||||
},
|
||||
};
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
document: { createElement: () => element, querySelector: () => null },
|
||||
MobileDetection: { isTouchDevice: () => true },
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
vm.runInContext(
|
||||
`${readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8')}\nglobalThis.__bar = KeyboardAccessoryBar;`,
|
||||
context
|
||||
);
|
||||
const bar = (context as unknown as { __bar: Bar }).__bar;
|
||||
bar.syncReadMyMind = () => {};
|
||||
bar.syncCodexKeys = () => {};
|
||||
bar._syncComposerDraftIndicator = () => {};
|
||||
bar.init();
|
||||
// `null` is a press on the bar itself (padding, a gap): closest() finds no key.
|
||||
const press = (action: string | null) => {
|
||||
const preventDefault = vi.fn();
|
||||
listeners.mousedown({
|
||||
target: { closest: () => (action === null ? null : { dataset: { action } }) },
|
||||
preventDefault,
|
||||
});
|
||||
return preventDefault.mock.calls.length > 0;
|
||||
};
|
||||
return { bar, press };
|
||||
}
|
||||
|
||||
describe('accessory keys keep the terminal focused on press', () => {
|
||||
it('cancels the press default for keys that leave the keyboard up', () => {
|
||||
const { press } = loadBar();
|
||||
for (const action of [
|
||||
'scroll-up',
|
||||
'scroll-down',
|
||||
'arrow-left',
|
||||
'arrow-right',
|
||||
'tab',
|
||||
'shift-tab',
|
||||
'shift-left',
|
||||
'shift-right',
|
||||
'ctrl',
|
||||
'ctrl-o',
|
||||
'opt-enter',
|
||||
'esc',
|
||||
'effort-max',
|
||||
'clear-input',
|
||||
]) {
|
||||
expect(press(action), action).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves keys that move focus or dismiss the keyboard alone', () => {
|
||||
const { press } = loadBar();
|
||||
for (const action of ['compose', 'readmymind', 'pick-path', 'paste', 'dismiss', 'init']) {
|
||||
expect(press(action), action).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels only the arming tap of clear/compact, not the confirming one', () => {
|
||||
const { bar, press } = loadBar();
|
||||
bar._confirmAction = null;
|
||||
expect(press('clear')).toBe(true);
|
||||
bar._confirmAction = 'clear';
|
||||
expect(press('clear')).toBe(false);
|
||||
expect(press('compact')).toBe(true);
|
||||
bar._confirmAction = 'compact';
|
||||
expect(press('compact')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a press outside a key', () => {
|
||||
const { press } = loadBar();
|
||||
expect(press(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -189,10 +189,13 @@ describe('admin case-folder API', () => {
|
||||
expect(sl.status).toBe(400);
|
||||
await expect(fs.stat(path.join(spacesDir, 'outside'))).resolves.toBeTruthy();
|
||||
|
||||
// A real folder is deleted.
|
||||
// A real folder is deleted, and kim's default session group for it goes with it.
|
||||
const store = (server as unknown as { store: import('../src/state-store.js').StateStore }).store;
|
||||
store.setCaseDefaultGroup('kim', 'proj1', null);
|
||||
const del = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody });
|
||||
expect(del.status).toBe(200);
|
||||
await expect(fs.stat(path.join(spacesDir, 'kim', 'cases', 'proj1'))).rejects.toBeTruthy();
|
||||
expect(store.getCaseDefaultGroup('kim', 'proj1')).toBeUndefined();
|
||||
|
||||
// Deleting it again 404s.
|
||||
const gone = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody });
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @fileoverview `sendwait` in the packaged agent preamble sends one line.
|
||||
*
|
||||
* Runs the REAL `skills/codeman/preamble.sh` in bash with `CURL` replaced by a stub
|
||||
* that records the request body, so it checks what the function puts on the wire,
|
||||
* not a copy of its logic. The server pastes a multi-line prompt as one block, and
|
||||
* neither sendwait's Enter loop nor the server's submit check can recognise a pasted
|
||||
* block still sitting on the composer, so the prompt must arrive as one line, the
|
||||
* way `toSingleLine` joins it for the other unattended senders.
|
||||
*
|
||||
* Port: N/A (no server; the stub answers every request).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { toSingleLine } from '../src/utils/single-line.js';
|
||||
|
||||
const PREAMBLE = join(__dirname, '..', 'skills', 'codeman', 'preamble.sh');
|
||||
|
||||
// The preamble builds every request body with jq.
|
||||
let hasJq = true;
|
||||
try {
|
||||
execFileSync('jq', ['--version'], { stdio: 'ignore' });
|
||||
} catch {
|
||||
hasJq = false;
|
||||
}
|
||||
|
||||
/** The `input` field of the first body sendwait posts for `prompt`. */
|
||||
function sentInput(prompt: string): string {
|
||||
// sendwait calls CURL inside $(...), a subshell, so the stub records to a file.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeman-sendwait-'));
|
||||
const bodies = join(dir, 'bodies');
|
||||
const script = `
|
||||
. "$PREAMBLE" >/dev/null
|
||||
stub() {
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = --data-binary ]; then printf '%s\\n' "$2" >> "$BODIES"; shift; fi
|
||||
shift
|
||||
done
|
||||
printf '%s' '{"data":{"delivered":true,"wait":{"timedOut":false}}}'
|
||||
}
|
||||
CURL=(stub)
|
||||
sendwait worker1234 "$PROMPT" 7 >/dev/null
|
||||
`;
|
||||
try {
|
||||
execFileSync('bash', ['-c', script], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: process.env.HOME,
|
||||
PREAMBLE,
|
||||
PROMPT: prompt,
|
||||
BODIES: bodies,
|
||||
CODEMAN_API_URL: 'http://127.0.0.1:9',
|
||||
CODEMAN_SESSION_ID: 'caller-session-0000',
|
||||
},
|
||||
});
|
||||
const first = readFileSync(bodies, 'utf-8').split('\n')[0];
|
||||
return (JSON.parse(first) as { input: string }).input;
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!hasJq)('preamble sendwait', () => {
|
||||
it('joins a multi-line prompt onto one line before sending it', () => {
|
||||
expect(sentInput('Review this:\n- item 1\n- item 2')).toBe('Review this: - item 1 - item 2\r');
|
||||
});
|
||||
|
||||
it('joins the same way as toSingleLine (trimmed lines, blank lines dropped, \\r\\n as a break)', () => {
|
||||
const prompt = ' first line \r\n\n\t second\n\n \nthird\n';
|
||||
expect(sentInput(prompt)).toBe(toSingleLine(prompt) + '\r');
|
||||
});
|
||||
|
||||
it('refuses a prompt made only of blank lines instead of sending a bare Enter', () => {
|
||||
expect(() => sentInput(' \n \t\n')).toThrow(/sendwait: empty prompt/);
|
||||
});
|
||||
|
||||
it('leaves a single-line prompt, glob characters included, as it was', () => {
|
||||
expect(sentInput('fix * in ?.ts ')).toBe('fix * in ?.ts \r');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @fileoverview The Case settings "Default session group" selector (session-ui.js):
|
||||
* it lists Ungrouped plus every named group the server reports, saves through
|
||||
* PUT /api/cases/:name/default-group, reverts on a refused save, mirrors the
|
||||
* desktop and phone popovers, and refreshes its options when the tab layout
|
||||
* changes (groups created, renamed or deleted). Also pins the zh-CN strings.
|
||||
*
|
||||
* Port: none (vm sandbox, stubbed fetch and DOM).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
|
||||
function fakeSelect() {
|
||||
const select: any = {
|
||||
value: '',
|
||||
disabled: false,
|
||||
options: [] as any[],
|
||||
replaceChildren(...nodes: any[]) {
|
||||
select.options = nodes;
|
||||
},
|
||||
};
|
||||
return select;
|
||||
}
|
||||
|
||||
function harness(server: { groups: Array<{ id: string; name: string }>; groupId: string | null; putStatus?: number }) {
|
||||
const elements: Record<string, any> = {
|
||||
quickStartCase: { value: 'proj' },
|
||||
caseDefaultGroup: fakeSelect(),
|
||||
caseDefaultGroupMobile: fakeSelect(),
|
||||
};
|
||||
const requests: Array<{ url: string; method: string; body?: any }> = [];
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: {
|
||||
getElementById: (id: string) => elements[id] ?? null,
|
||||
createElement: () => {
|
||||
const attrs: Record<string, string> = {};
|
||||
return { value: '', textContent: '', attrs, setAttribute: (k: string, v: string) => (attrs[k] = v) };
|
||||
},
|
||||
},
|
||||
fetch: async (url: string, init?: { method?: string; body?: string }) => {
|
||||
const method = init?.method ?? 'GET';
|
||||
const body = init?.body ? JSON.parse(init.body) : undefined;
|
||||
requests.push({ url, method, body });
|
||||
const ok = method !== 'PUT' || (server.putStatus ?? 200) === 200;
|
||||
if (method === 'PUT' && ok) server.groupId = body.groupId;
|
||||
const payload = ok
|
||||
? { success: true, data: { caseName: 'proj', groupId: server.groupId, groups: server.groups } }
|
||||
: { success: false, error: 'nope', errorCode: 'INVALID_INPUT' };
|
||||
return { ok, status: ok ? 200 : (server.putStatus ?? 400), json: async () => payload };
|
||||
},
|
||||
console,
|
||||
});
|
||||
for (const file of ['api-client.js', 'session-ui.js']) {
|
||||
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
||||
}
|
||||
const app = new (CodemanApp as any)();
|
||||
app.showToast = vi.fn();
|
||||
return { app, elements, requests, server };
|
||||
}
|
||||
|
||||
const optionView = (select: any) => select.options.map((o: any) => [o.value, o.textContent]);
|
||||
|
||||
describe('Case settings: Default session group selector', () => {
|
||||
it('lists Ungrouped plus every named group, selecting the stored one in both popovers', async () => {
|
||||
const h = harness({
|
||||
groups: [
|
||||
{ id: 'g1', name: 'Core' },
|
||||
{ id: 'g2', name: 'Ops' },
|
||||
],
|
||||
groupId: 'g2',
|
||||
});
|
||||
await h.app._loadCaseDefaultGroup();
|
||||
expect(h.requests[0]).toMatchObject({ url: '/api/cases/proj/default-group', method: 'GET' });
|
||||
for (const id of ['caseDefaultGroup', 'caseDefaultGroupMobile']) {
|
||||
const select = h.elements[id];
|
||||
expect(optionView(select)).toEqual([
|
||||
['', 'Ungrouped'],
|
||||
['g1', 'Core'],
|
||||
['g2', 'Ops'],
|
||||
]);
|
||||
expect(select.value).toBe('g2');
|
||||
expect(select.disabled).toBe(false);
|
||||
// Group names are user text: never handed to the translator.
|
||||
expect(select.options[1].attrs['data-i18n-skip']).toBe('');
|
||||
expect(select.options[0].attrs['data-i18n-skip']).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('saves a choice (and Ungrouped as null) and mirrors it to the other popover', async () => {
|
||||
const h = harness({ groups: [{ id: 'g1', name: 'Core' }], groupId: null });
|
||||
await h.app._loadCaseDefaultGroup();
|
||||
h.elements.caseDefaultGroup.value = 'g1';
|
||||
await h.app.onCaseDefaultGroupChanged(h.elements.caseDefaultGroup);
|
||||
expect(h.requests.at(-1)).toEqual({ url: '/api/cases/proj/default-group', method: 'PUT', body: { groupId: 'g1' } });
|
||||
expect(h.elements.caseDefaultGroupMobile.value).toBe('g1');
|
||||
|
||||
h.elements.caseDefaultGroupMobile.value = '';
|
||||
await h.app.onCaseDefaultGroupChanged(h.elements.caseDefaultGroupMobile);
|
||||
expect(h.requests.at(-1)?.body).toEqual({ groupId: null });
|
||||
expect(h.elements.caseDefaultGroup.value).toBe('');
|
||||
});
|
||||
|
||||
it('reverts to the confirmed value and says so when the save is refused', async () => {
|
||||
const h = harness({ groups: [{ id: 'g1', name: 'Core' }], groupId: 'g1', putStatus: 400 });
|
||||
await h.app._loadCaseDefaultGroup();
|
||||
h.elements.caseDefaultGroup.value = '';
|
||||
await h.app.onCaseDefaultGroupChanged(h.elements.caseDefaultGroup);
|
||||
expect(h.elements.caseDefaultGroup.value).toBe('g1');
|
||||
expect(h.app.showToast).toHaveBeenCalledWith('Could not save the default session group.', 'error');
|
||||
});
|
||||
|
||||
it('refreshes its options when the layout changes: created, renamed and deleted groups', async () => {
|
||||
const h = harness({ groups: [{ id: 'g1', name: 'Core' }], groupId: 'g1' });
|
||||
await h.app._loadCaseDefaultGroup();
|
||||
h.app._refreshCaseDefaultGroupOptions({
|
||||
groups: [
|
||||
{ id: 'g1', name: 'Core team' },
|
||||
{ id: 'g3', name: 'New' },
|
||||
],
|
||||
});
|
||||
expect(optionView(h.elements.caseDefaultGroup)).toEqual([
|
||||
['', 'Ungrouped'],
|
||||
['g1', 'Core team'],
|
||||
['g3', 'New'],
|
||||
]);
|
||||
expect(h.elements.caseDefaultGroup.value).toBe('g1');
|
||||
// The selected group is deleted: the selector visibly falls back to Ungrouped.
|
||||
h.app._refreshCaseDefaultGroupOptions({ groups: [{ id: 'g3', name: 'New' }] });
|
||||
expect(h.elements.caseDefaultGroup.value).toBe('');
|
||||
expect(h.elements.caseDefaultGroupMobile.value).toBe('');
|
||||
});
|
||||
|
||||
it('leaves the options alone when a layout broadcast does not change the groups', async () => {
|
||||
const h = harness({
|
||||
groups: [
|
||||
{ id: 'g1', name: 'Core' },
|
||||
{ id: 'g2', name: 'Ops' },
|
||||
],
|
||||
groupId: 'g2',
|
||||
});
|
||||
await h.app._loadCaseDefaultGroup();
|
||||
const before = h.elements.caseDefaultGroup.options;
|
||||
// A session created or moved elsewhere: same groups, other refs.
|
||||
h.app._refreshCaseDefaultGroupOptions({
|
||||
groups: [
|
||||
{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 's9' }] },
|
||||
{ id: 'g2', name: 'Ops', refs: [] },
|
||||
],
|
||||
});
|
||||
expect(h.elements.caseDefaultGroup.options).toBe(before); // not rebuilt, so an open dropdown stays open
|
||||
expect(h.elements.caseDefaultGroup.value).toBe('g2');
|
||||
});
|
||||
|
||||
it('the layout refresh is wired into _applyTabLayout', () => {
|
||||
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const body = app.slice(app.indexOf(' _applyTabLayout(layout) {'));
|
||||
expect(body.slice(0, body.indexOf('\n }\n'))).toMatch(/^\s*this\._refreshCaseDefaultGroupOptions\?\.\(next\);/m);
|
||||
});
|
||||
|
||||
it('both popovers carry the selector, and every new string has a zh-CN entry', () => {
|
||||
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||
expect(html).toContain('id="caseDefaultGroup"');
|
||||
expect(html).toContain('id="caseDefaultGroupMobile"');
|
||||
const i18n = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
|
||||
for (const text of [
|
||||
'Default session group',
|
||||
'New sessions for this case land in this group',
|
||||
'Could not save the default session group.',
|
||||
'Ungrouped',
|
||||
]) {
|
||||
expect(i18n, text).toMatch(new RegExp(`['"]?${text.replace(/[.]/g, '\\.')}['"]?: '`));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* @fileoverview Per-case default session group: the state.json store and the
|
||||
* TabLayoutService placement precedence (explicit group > parent > case default > none).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { StateStore } from '../src/state-store.js';
|
||||
import { TabLayoutService } from '../src/tab-layout-service.js';
|
||||
import { TabLayoutValidationError, type TabLayout } from '../src/tab-layout.js';
|
||||
|
||||
type SessionFact = { id: string; owner?: string; createdAt: number; parentSessionId?: string };
|
||||
|
||||
describe('StateStore case default groups', () => {
|
||||
let dir: string;
|
||||
let file: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codeman-case-default-'));
|
||||
file = join(dir, 'state.json');
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it('distinguishes unset from an intentional Ungrouped choice, per owner', () => {
|
||||
const store = new StateStore(file);
|
||||
expect(store.getCaseDefaultGroup('@single', 'app')).toBeUndefined();
|
||||
store.setCaseDefaultGroup('@single', 'app', 'g1');
|
||||
store.setCaseDefaultGroup('@single', 'other', null);
|
||||
store.setCaseDefaultGroup('bob', 'app', 'g9');
|
||||
expect(store.getCaseDefaultGroup('@single', 'app')).toBe('g1');
|
||||
expect(store.getCaseDefaultGroup('@single', 'other')).toBeNull();
|
||||
expect(store.getCaseDefaultGroup('bob', 'app')).toBe('g9');
|
||||
expect(store.getCaseDefaultGroup('alice', 'app')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('survives a save and reload (persistence round-trip)', () => {
|
||||
const store = new StateStore(file);
|
||||
store.setCaseDefaultGroup('@single', 'app', 'g1');
|
||||
store.setCaseDefaultGroup('@single', 'none', null);
|
||||
store.saveNow();
|
||||
const disk = JSON.parse(readFileSync(file, 'utf-8'));
|
||||
expect(disk.caseDefaultGroups).toEqual({ '@single': { app: 'g1', none: null } });
|
||||
const reloaded = new StateStore(file);
|
||||
expect(reloaded.getCaseDefaultGroup('@single', 'app')).toBe('g1');
|
||||
expect(reloaded.getCaseDefaultGroup('@single', 'none')).toBeNull();
|
||||
});
|
||||
|
||||
it('forgets one owner mapping without touching another owner', () => {
|
||||
const store = new StateStore(file);
|
||||
store.setCaseDefaultGroup('alice', 'app', 'g1');
|
||||
store.setCaseDefaultGroup('bob', 'app', 'g2');
|
||||
store.deleteCaseDefaultGroup('alice', 'app');
|
||||
expect(store.getCaseDefaultGroup('alice', 'app')).toBeUndefined();
|
||||
expect(store.getCaseDefaultGroup('bob', 'app')).toBe('g2');
|
||||
});
|
||||
|
||||
it('reads a malformed on-disk value as unset instead of throwing', () => {
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ sessions: {}, caseDefaultGroups: { '@single': { app: 42, ok: 'g1' }, bad: 'x' } })
|
||||
);
|
||||
const store = new StateStore(file);
|
||||
expect(store.getCaseDefaultGroup('@single', 'app')).toBeUndefined();
|
||||
expect(store.getCaseDefaultGroup('@single', 'ok')).toBe('g1');
|
||||
expect(store.getCaseDefaultGroup('bad', 'app')).toBeUndefined();
|
||||
expect(store.getCaseDefaultGroup('@single', '__proto__')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function createHarness(options: { layouts?: Record<string, TabLayout>; live?: SessionFact[]; follow?: boolean } = {}) {
|
||||
const layouts = { ...(options.layouts ?? {}) };
|
||||
const live = new Map((options.live ?? []).map((session) => [session.id, session]));
|
||||
const order: string[] = [];
|
||||
const defaults: Record<string, Record<string, string | null>> = {};
|
||||
const caseDefaults = {
|
||||
getCaseDefaultGroup: vi.fn((owner: string, caseName: string) =>
|
||||
defaults[owner] && caseName in defaults[owner] ? defaults[owner][caseName] : undefined
|
||||
),
|
||||
setCaseDefaultGroup: vi.fn((owner: string, caseName: string, groupId: string | null) => {
|
||||
defaults[owner] = { ...(defaults[owner] ?? {}), [caseName]: groupId };
|
||||
}),
|
||||
deleteCaseDefaultGroup: vi.fn((owner: string, caseName: string) => {
|
||||
if (defaults[owner]) delete defaults[owner][caseName];
|
||||
}),
|
||||
};
|
||||
const store = {
|
||||
getTabLayout: vi.fn((owner: string) => layouts[owner] ?? null),
|
||||
getTabLayouts: vi.fn(() => ({ ...layouts })),
|
||||
getSessions: vi.fn(() => ({})),
|
||||
getSessionOrder: vi.fn(() => [...order]),
|
||||
commitTabLayoutProjection: vi.fn(
|
||||
(updates: Readonly<Record<string, TabLayout>>, project: (latest: readonly string[]) => readonly string[]) => {
|
||||
const projected = [...project([...order])];
|
||||
Object.assign(layouts, structuredClone(updates));
|
||||
order.splice(0, order.length, ...projected);
|
||||
return { layouts: structuredClone(updates), sessionOrder: [...order] };
|
||||
}
|
||||
),
|
||||
};
|
||||
const service = new TabLayoutService({
|
||||
store: store as never,
|
||||
sessions: live as never,
|
||||
readWebviews: vi.fn(async () => []),
|
||||
broadcast: vi.fn(),
|
||||
broadcastSessionOrder: vi.fn(),
|
||||
childrenFollowParent: vi.fn(() => options.follow === true),
|
||||
caseDefaults,
|
||||
now: () => '2026-10-22T12:00:00.000Z',
|
||||
});
|
||||
const register = (fact: SessionFact) =>
|
||||
vi.fn(() => {
|
||||
live.set(fact.id, fact);
|
||||
return () => live.delete(fact.id);
|
||||
});
|
||||
return { service, store, layouts, live, defaults, caseDefaults, register };
|
||||
}
|
||||
|
||||
const grouped = (version = 7): TabLayout => ({
|
||||
version,
|
||||
groups: [
|
||||
{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 'a' }] },
|
||||
{ id: 'g2', name: 'Ops', refs: [] },
|
||||
],
|
||||
ungrouped: [{ kind: 'session', id: 'b' }],
|
||||
updatedAt: '2026-10-21T00:00:00.000Z',
|
||||
});
|
||||
const facts = (owner = 'alice'): SessionFact[] => [
|
||||
{ id: 'a', owner, createdAt: 1 },
|
||||
{ id: 'b', owner, createdAt: 2 },
|
||||
];
|
||||
const idsIn = (layout: TabLayout, groupId: string | null): string[] =>
|
||||
(groupId === null ? layout.ungrouped : (layout.groups.find((g) => g.id === groupId)?.refs ?? [])).map((r) => r.id);
|
||||
|
||||
describe('TabLayoutService case default placement', () => {
|
||||
it('places a session for the case into its default group in the creating write', async () => {
|
||||
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
|
||||
h.defaults.alice = { app: 'g2' };
|
||||
const layout = await h.service.sessionCreated(
|
||||
'alice',
|
||||
'new',
|
||||
{ caseName: 'app' },
|
||||
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
|
||||
);
|
||||
expect(idsIn(layout, 'g2')).toEqual(['new']);
|
||||
expect(idsIn(layout, null)).toEqual(['b']);
|
||||
expect(layout.version).toBe(8);
|
||||
// One versioned write: no transient Ungrouped layout was ever committed.
|
||||
expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('an explicit tabGroupId wins over the case default', async () => {
|
||||
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
|
||||
h.defaults.alice = { app: 'g2' };
|
||||
const layout = await h.service.sessionCreated(
|
||||
'alice',
|
||||
'new',
|
||||
{ caseName: 'app', tabGroupId: 'g1' },
|
||||
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
|
||||
);
|
||||
expect(idsIn(layout, 'g1')).toEqual(['a', 'new']);
|
||||
expect(idsIn(layout, 'g2')).toEqual([]);
|
||||
});
|
||||
|
||||
it('an unknown explicit group falls through to the case default', async () => {
|
||||
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
|
||||
h.defaults.alice = { app: 'g2' };
|
||||
const layout = await h.service.sessionCreated(
|
||||
'alice',
|
||||
'new',
|
||||
{ caseName: 'app', tabGroupId: 'gone' },
|
||||
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
|
||||
);
|
||||
expect(idsIn(layout, 'g2')).toEqual(['new']);
|
||||
});
|
||||
|
||||
it.each([true, false])('the parent placement wins over the case default (follow=%s)', async (follow) => {
|
||||
const h = createHarness({ layouts: { alice: grouped() }, live: facts(), follow });
|
||||
h.defaults.alice = { app: 'g2' };
|
||||
const layout = await h.service.sessionCreated(
|
||||
'alice',
|
||||
'child',
|
||||
{ caseName: 'app' },
|
||||
h.register({ id: 'child', owner: 'alice', createdAt: 3, parentSessionId: 'a' })
|
||||
);
|
||||
expect(idsIn(layout, 'g1')).toEqual(['a', 'child']);
|
||||
expect(idsIn(layout, 'g2')).toEqual([]);
|
||||
});
|
||||
|
||||
it('a parent that is not in the layout does not block the case default', async () => {
|
||||
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
|
||||
h.defaults.alice = { app: 'g2' };
|
||||
const layout = await h.service.sessionCreated(
|
||||
'alice',
|
||||
'orphan',
|
||||
{ caseName: 'app' },
|
||||
h.register({ id: 'orphan', owner: 'alice', createdAt: 3, parentSessionId: 'nobody' })
|
||||
);
|
||||
expect(idsIn(layout, 'g2')).toEqual(['orphan']);
|
||||
});
|
||||
|
||||
it('an intentional Ungrouped default (null) or no default leaves the session ungrouped', async () => {
|
||||
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
|
||||
h.defaults.alice = { app: null };
|
||||
const first = await h.service.sessionCreated(
|
||||
'alice',
|
||||
'n1',
|
||||
{ caseName: 'app' },
|
||||
h.register({ id: 'n1', owner: 'alice', createdAt: 3 })
|
||||
);
|
||||
expect(idsIn(first, null)).toEqual(['b', 'n1']);
|
||||
const second = await h.service.sessionCreated(
|
||||
'alice',
|
||||
'n2',
|
||||
{ caseName: 'unset' },
|
||||
h.register({ id: 'n2', owner: 'alice', createdAt: 4 })
|
||||
);
|
||||
expect(idsIn(second, null)).toEqual(['b', 'n1', 'n2']);
|
||||
});
|
||||
|
||||
it('a deleted default group never fails creation: Ungrouped, and the stale value is cleared', async () => {
|
||||
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
|
||||
h.defaults.alice = { app: 'deleted-group' };
|
||||
const layout = await h.service.sessionCreated(
|
||||
'alice',
|
||||
'new',
|
||||
{ caseName: 'app' },
|
||||
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
|
||||
);
|
||||
expect(idsIn(layout, null)).toEqual(['b', 'new']);
|
||||
expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never applies another owner's group id", async () => {
|
||||
const h = createHarness({
|
||||
layouts: { alice: grouped(), bob: { ...grouped(3), groups: [], ungrouped: [] } },
|
||||
live: [...facts()],
|
||||
});
|
||||
// A value naming alice's group, stored under bob, is not found in bob's layout.
|
||||
h.defaults.bob = { app: 'g1' };
|
||||
const layout = await h.service.sessionCreated(
|
||||
'bob',
|
||||
'bob-new',
|
||||
{ caseName: 'app' },
|
||||
h.register({ id: 'bob-new', owner: 'bob', createdAt: 5 })
|
||||
);
|
||||
expect(layout.groups).toEqual([]);
|
||||
expect(idsIn(layout, null)).toEqual(['bob-new']);
|
||||
expect(idsIn(h.layouts.alice, 'g1')).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('a renamed group keeps receiving sessions (the default is the stable id)', async () => {
|
||||
const renamed = grouped();
|
||||
renamed.groups[1] = { ...renamed.groups[1], name: 'Operations' };
|
||||
const h = createHarness({ layouts: { alice: renamed }, live: facts() });
|
||||
h.defaults.alice = { app: 'g2' };
|
||||
const layout = await h.service.sessionCreated(
|
||||
'alice',
|
||||
'new',
|
||||
{ caseName: 'app' },
|
||||
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
|
||||
);
|
||||
expect(layout.groups[1]).toMatchObject({ id: 'g2', name: 'Operations' });
|
||||
expect(idsIn(layout, 'g2')).toEqual(['new']);
|
||||
});
|
||||
|
||||
it('changing the default never moves an existing session', async () => {
|
||||
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
|
||||
h.defaults.alice = { app: 'g2' };
|
||||
await h.service.sessionCreated(
|
||||
'alice',
|
||||
's1',
|
||||
{ caseName: 'app' },
|
||||
h.register({ id: 's1', owner: 'alice', createdAt: 3 })
|
||||
);
|
||||
await h.service.setCaseDefaultGroup('alice', 'app', 'g1');
|
||||
const layout = await h.service.sessionCreated(
|
||||
'alice',
|
||||
's2',
|
||||
{ caseName: 'app' },
|
||||
h.register({ id: 's2', owner: 'alice', createdAt: 4 })
|
||||
);
|
||||
expect(idsIn(layout, 'g2')).toEqual(['s1']);
|
||||
expect(idsIn(layout, 'g1')).toEqual(['a', 's2']);
|
||||
});
|
||||
|
||||
it('a failed layout write rolls the registration back and writes no case default change', async () => {
|
||||
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
|
||||
h.defaults.alice = { app: 'deleted-group' };
|
||||
h.store.commitTabLayoutProjection.mockImplementationOnce(() => {
|
||||
throw new Error('disk full');
|
||||
});
|
||||
const added = h.register({ id: 'new', owner: 'alice', createdAt: 3 });
|
||||
await expect(h.service.sessionCreated('alice', 'new', { caseName: 'app' }, added)).rejects.toThrow('disk full');
|
||||
expect(h.live.has('new')).toBe(false);
|
||||
expect(h.layouts.alice).toEqual(grouped());
|
||||
expect(h.caseDefaults.setCaseDefaultGroup).not.toHaveBeenCalled();
|
||||
expect(h.caseDefaults.deleteCaseDefaultGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabLayoutService case default reads and writes', () => {
|
||||
it('reports the selection with the owner group options, and a stale id as Ungrouped without writing', async () => {
|
||||
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
|
||||
expect(await h.service.caseDefaultGroup('alice', 'app')).toEqual({
|
||||
groupId: null,
|
||||
groups: [
|
||||
{ id: 'g1', name: 'Core' },
|
||||
{ id: 'g2', name: 'Ops' },
|
||||
],
|
||||
});
|
||||
h.defaults.alice = { app: 'gone' };
|
||||
expect((await h.service.caseDefaultGroup('alice', 'app')).groupId).toBeNull();
|
||||
// A read never writes: the next creation for the case is what forgets the stale id.
|
||||
expect(h.caseDefaults.deleteCaseDefaultGroup).not.toHaveBeenCalled();
|
||||
expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBe('gone');
|
||||
});
|
||||
|
||||
it('saves a known group or null, and refuses an id that is not in the owner layout', async () => {
|
||||
const h = createHarness({ layouts: { alice: grouped(), bob: { ...grouped(1), groups: [] } }, live: facts() });
|
||||
await h.service.setCaseDefaultGroup('alice', 'app', 'g2');
|
||||
expect(h.defaults.alice.app).toBe('g2');
|
||||
await h.service.setCaseDefaultGroup('alice', 'app', null);
|
||||
expect(h.defaults.alice.app).toBeNull();
|
||||
await expect(h.service.setCaseDefaultGroup('bob', 'app', 'g1')).rejects.toBeInstanceOf(TabLayoutValidationError);
|
||||
expect(h.defaults.bob).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forgets a mapping when its case goes away', async () => {
|
||||
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
|
||||
h.defaults.alice = { app: 'g1' };
|
||||
await h.service.forgetCaseDefaultGroup('alice', 'app');
|
||||
expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
// Adversarial browser layout fixture, not evidence of native Codex history:
|
||||
// authentic CLI behavior is measured separately in an isolated live pane.
|
||||
describe('tracked Codex wheel routing in the primary pane', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 60000);
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.stop();
|
||||
});
|
||||
|
||||
it('sends plain wheel to the CLI rather than scrolling stale repaint frames', async () => {
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
|
||||
try {
|
||||
await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal);
|
||||
await page.waitForTimeout(3500);
|
||||
await page.evaluate(async () => {
|
||||
const app = (window as any).app;
|
||||
app.activeSessionId = 'codex-wheel-probe';
|
||||
app.sessions.set(app.activeSessionId, { id: app.activeSessionId, mode: 'codex', cliMouseTracking: true });
|
||||
(window as any).scrollInputs = [];
|
||||
// Cell hit testing is irrelevant to routing; the empty dashboard covers the test terminal.
|
||||
app._clientPointToCell = () => ({ col: 1, row: 1 });
|
||||
app._sendInputEphemeral = (id: string, data: string) => (window as any).scrollInputs.push({ id, data });
|
||||
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
|
||||
await new Promise<void>((resolve) => app.terminal.write('\r\nold repaint row'.repeat(100), resolve));
|
||||
app.terminal.scrollToTop();
|
||||
});
|
||||
const fire = (shiftKey = false) =>
|
||||
page.evaluate((shiftKey) => {
|
||||
const t = (window as any).app.terminal;
|
||||
t.element.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, shiftKey, bubbles: true, cancelable: true }));
|
||||
}, shiftKey);
|
||||
await fire();
|
||||
await page.waitForTimeout(150);
|
||||
const forwarded = await page.evaluate(() => {
|
||||
const app = (window as any).app;
|
||||
return {
|
||||
inputs: (window as any).scrollInputs,
|
||||
baseY: app.terminal.buffer.active.baseY,
|
||||
viewportY: app.terminal.buffer.active.viewportY,
|
||||
};
|
||||
});
|
||||
expect(forwarded.inputs.length).toBeGreaterThan(0);
|
||||
expect(forwarded.inputs.every((i: { id: string }) => i.id === 'codex-wheel-probe')).toBe(true);
|
||||
expect(
|
||||
forwarded.inputs
|
||||
.map((i: { data: string }) => i.data)
|
||||
.join('')
|
||||
.match(/\x1b\[<64;/g)
|
||||
).toHaveLength(4);
|
||||
expect(forwarded.viewportY).toBe(forwarded.baseY);
|
||||
for (const shift of [false, true]) {
|
||||
await page.evaluate((tracked) => {
|
||||
const app = (window as any).app;
|
||||
app.sessions.get(app.activeSessionId).cliMouseTracking = tracked;
|
||||
app.terminal.scrollToBottom();
|
||||
(window as any).scrollInputs = [];
|
||||
}, shift);
|
||||
await fire(shift);
|
||||
await page.waitForTimeout(150);
|
||||
const local = await page.evaluate(() => {
|
||||
const b = (window as any).app.terminal.buffer.active;
|
||||
return { inputs: (window as any).scrollInputs, baseY: b.baseY, viewportY: b.viewportY };
|
||||
});
|
||||
expect(local.inputs).toEqual([]);
|
||||
expect(local.viewportY).toBeLessThan(local.baseY);
|
||||
}
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @fileoverview The snapshot origin must survive a hostname change (a Docker container gets a new
|
||||
* hostname on every recreate) and still differ between data dirs.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const host = vi.hoisted(() => ({ name: 'container-a' }));
|
||||
vi.mock('node:os', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('node:os')>()),
|
||||
hostname: () => host.name,
|
||||
}));
|
||||
|
||||
import {
|
||||
configBackupOrigin,
|
||||
createConfigBackup,
|
||||
listConfigBackups,
|
||||
pruneConfigBackups,
|
||||
restoreConfigBackup,
|
||||
} from '../src/config-backup.js';
|
||||
|
||||
let root: string;
|
||||
let data: string;
|
||||
let backups: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'cfg-origin-'));
|
||||
data = join(root, 'data');
|
||||
backups = join(root, 'backups');
|
||||
mkdirSync(data, { recursive: true });
|
||||
writeFileSync(join(data, 'settings.json'), '{"a":1}');
|
||||
host.name = 'container-a';
|
||||
});
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
describe('configBackupOrigin', () => {
|
||||
it('is made once, kept in the data dir, and differs between data dirs', () => {
|
||||
const a = configBackupOrigin(data);
|
||||
expect(configBackupOrigin(data)).toBe(a);
|
||||
expect(readFileSync(join(data, 'config-backup-id'), 'utf8').trim()).toBe(a);
|
||||
const other = join(root, 'other');
|
||||
expect(configBackupOrigin(other)).not.toBe(a);
|
||||
});
|
||||
|
||||
it('keeps snapshots listed, restorable and prunable after the hostname changes', () => {
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: 1_000_000 }) as {
|
||||
id: string;
|
||||
};
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'manual', force: true, now: 2_000_000 });
|
||||
host.name = 'container-b'; // the container was recreated
|
||||
const origin = configBackupOrigin(data);
|
||||
expect(listConfigBackups(backups, origin)).toHaveLength(2);
|
||||
writeFileSync(join(data, 'settings.json'), '{"a":2}'); // a change, so the restore takes a pre-restore copy
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id })).not.toThrow();
|
||||
const removed = pruneConfigBackups(backups, { keepCount: 1, keepDays: 0, origin });
|
||||
expect(removed).toHaveLength(2); // 3 now: the restore took a pre-restore copy
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @fileoverview Restoring settings.json from inside App Settings must refill the form, or Save
|
||||
* would PUT the pre-restore values back over the restored file. Evaluates the real settings-ui.js
|
||||
* against a stub app.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const SRC = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf-8');
|
||||
|
||||
function makeApp(
|
||||
restored: string[],
|
||||
confirmed = true,
|
||||
reply: { ok: boolean; body: unknown } = { ok: true, body: { success: true, data: { restored, restartRequired: [] } } }
|
||||
) {
|
||||
const calls: string[] = [];
|
||||
const confirms: string[] = [];
|
||||
const toasts: Array<[string, string]> = [];
|
||||
function CodemanApp(this: unknown) {}
|
||||
const ctx = vm.createContext({
|
||||
CodemanApp,
|
||||
window: {},
|
||||
document: {},
|
||||
confirm: (m: string) => (confirms.push(m), confirmed),
|
||||
escapeHtml: (s: string) => s,
|
||||
});
|
||||
vm.runInContext(SRC, ctx);
|
||||
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>;
|
||||
Object.assign(app, {
|
||||
_configBackups: [
|
||||
{
|
||||
id: 'cfg-1',
|
||||
files: [{ name: 'settings.json' }, { name: 'users.json' }, { name: '.env' }, { name: 'clis.json' }],
|
||||
},
|
||||
],
|
||||
_apiPost: async () => ({ ok: reply.ok, json: async () => reply.body }),
|
||||
loadAppSettingsFromServer: async () => void calls.push('reload-settings'),
|
||||
openAppSettings: () => void calls.push('refill-form'),
|
||||
loadConfigBackups: () => void calls.push('reload-list'),
|
||||
showToast: (m: string, type: string) => void toasts.push([m, type]),
|
||||
});
|
||||
return { app, calls, confirms, toasts };
|
||||
}
|
||||
|
||||
describe('restoreConfigBackup (Settings UI)', () => {
|
||||
it('re-reads settings into the form when settings.json was restored', async () => {
|
||||
const { app, calls } = makeApp(['settings.json', 'clis.json']);
|
||||
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
|
||||
expect(calls).toEqual(['reload-settings', 'refill-form', 'reload-list']);
|
||||
});
|
||||
|
||||
it('leaves the form alone when settings.json was not part of the restore', async () => {
|
||||
const { app, calls } = makeApp(['clis.json']);
|
||||
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
|
||||
expect(calls).toEqual(['reload-list']);
|
||||
});
|
||||
|
||||
it('a restore that failed partway still refills the form when settings.json was among the files written', async () => {
|
||||
const { app, calls, toasts } = makeApp([], true, {
|
||||
ok: false,
|
||||
body: {
|
||||
success: false,
|
||||
errorCode: 'OPERATION_FAILED',
|
||||
error: 'Restore incomplete: could not write clis.json (EISDIR); settings.json restored',
|
||||
data: {
|
||||
restored: ['settings.json'],
|
||||
failed: [{ name: 'clis.json', error: 'EISDIR' }],
|
||||
safetySnapshot: 'cfg-2',
|
||||
restartRequired: ['settings.json'],
|
||||
},
|
||||
},
|
||||
});
|
||||
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
|
||||
expect(calls).toEqual(['reload-settings', 'refill-form', 'reload-list']);
|
||||
expect(toasts).toHaveLength(1);
|
||||
expect(toasts[0][1]).toBe('error');
|
||||
expect(toasts[0][0]).toContain('clis.json');
|
||||
});
|
||||
|
||||
it('a refused restore (nothing written) only reports the error', async () => {
|
||||
const { app, calls, toasts } = makeApp([], true, {
|
||||
ok: false,
|
||||
body: { success: false, errorCode: 'CONFLICT', error: 'settings.json does not match its checksum' },
|
||||
});
|
||||
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
|
||||
expect(calls).toEqual([]);
|
||||
expect(toasts).toEqual([['settings.json does not match its checksum', 'error']]);
|
||||
});
|
||||
|
||||
it('names the files being replaced and calls out the credential ones', async () => {
|
||||
const { app, confirms } = makeApp(['settings.json'], false);
|
||||
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
|
||||
expect(confirms[0]).toContain('settings.json, users.json, .env, clis.json');
|
||||
expect(confirms[0]).toContain('credentials');
|
||||
expect(confirms[0]).toMatch(/users\.json, \.env\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfigBackups (retention fields)', () => {
|
||||
function load(saved: Record<string, unknown>) {
|
||||
const inputs: Record<string, { value: string; placeholder: string }> = {
|
||||
appSettingsConfigBackupKeepCount: { value: '20', placeholder: '' },
|
||||
appSettingsConfigBackupKeepDays: { value: '30', placeholder: '' },
|
||||
appSettingsConfigBackupDir: { value: '', placeholder: '' },
|
||||
};
|
||||
function CodemanApp(this: unknown) {}
|
||||
const ctx = vm.createContext({
|
||||
CodemanApp,
|
||||
window: {},
|
||||
document: { getElementById: (id: string) => inputs[id] ?? null },
|
||||
escapeHtml: (s: string) => s,
|
||||
});
|
||||
vm.runInContext(SRC, ctx);
|
||||
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>;
|
||||
Object.assign(app, {
|
||||
$: () => ({ textContent: '', innerHTML: '' }),
|
||||
loadAppSettingsFromStorage: () => saved,
|
||||
_api: async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: { settings: { keepCount: 100, keepDays: 0 }, dir: '/d', defaultDir: '/d', backups: [] },
|
||||
}),
|
||||
}),
|
||||
});
|
||||
return { app, inputs };
|
||||
}
|
||||
|
||||
it('shows the retention the passes use when settings.json has none of the keys (a reset file)', async () => {
|
||||
const { app, inputs } = load({});
|
||||
await (app.loadConfigBackups as () => Promise<void>)();
|
||||
expect(inputs.appSettingsConfigBackupKeepCount.value).toBe(100);
|
||||
expect(inputs.appSettingsConfigBackupKeepDays.value).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves the form alone when the saved settings state the retention', async () => {
|
||||
const { app, inputs } = load({ configBackupKeepCount: 7, configBackupKeepDays: 9 });
|
||||
await (app.loadConfigBackups as () => Promise<void>)();
|
||||
expect(inputs.appSettingsConfigBackupKeepCount.value).toBe('20'); // the open-settings code owns it
|
||||
});
|
||||
});
|
||||
|
||||
describe('Save and the displayed retention', () => {
|
||||
function payload(
|
||||
prev: Record<string, unknown>,
|
||||
touched: { count?: boolean; days?: boolean },
|
||||
count = '100',
|
||||
days = '0'
|
||||
) {
|
||||
const inputs: Record<string, { value: string; dataset: Record<string, string> }> = {
|
||||
appSettingsConfigBackupKeepCount: { value: count, dataset: touched.count ? { touched: '1' } : {} },
|
||||
appSettingsConfigBackupKeepDays: { value: days, dataset: touched.days ? { touched: '1' } : {} },
|
||||
};
|
||||
function CodemanApp(this: unknown) {}
|
||||
const ctx = vm.createContext({
|
||||
CodemanApp,
|
||||
window: {},
|
||||
document: { getElementById: (id: string) => inputs[id] },
|
||||
});
|
||||
vm.runInContext(SRC, ctx);
|
||||
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>;
|
||||
return (app._configBackupRetentionPayload as (p: Record<string, unknown>) => Record<string, unknown>).call(
|
||||
app,
|
||||
prev
|
||||
);
|
||||
}
|
||||
|
||||
it('leaves out a retention value that was only displayed (a reset settings.json)', () => {
|
||||
expect(payload({}, {})).toEqual({});
|
||||
});
|
||||
|
||||
it('sends a value that was saved before, or edited here, and only that one', () => {
|
||||
expect(payload({ configBackupKeepCount: 100 }, {})).toEqual({ configBackupKeepCount: 100 });
|
||||
expect(payload({}, { days: true })).toEqual({ configBackupKeepDays: 0 });
|
||||
expect(payload({}, { count: true, days: true }, '5', '7')).toEqual({
|
||||
configBackupKeepCount: 5,
|
||||
configBackupKeepDays: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps like the form always did', () => {
|
||||
expect(payload({}, { count: true, days: true }, '1', '99999')).toEqual({
|
||||
configBackupKeepCount: 2,
|
||||
configBackupKeepDays: 3650,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,686 @@
|
||||
/**
|
||||
* @fileoverview Config backups (src/config-backup.ts): what is tracked, when a snapshot is made,
|
||||
* how retention prunes, and that restore verifies before it writes. Pure fs over temp dirs;
|
||||
* port: N/A.
|
||||
*/
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CONFIG_BACKUP_FILES,
|
||||
ConfigBackupCorruptError,
|
||||
ConfigBackupNotFoundError,
|
||||
ConfigBackupPartialRestoreError,
|
||||
createConfigBackup,
|
||||
defaultConfigBackupDir,
|
||||
diffConfigBackups,
|
||||
listConfigBackups,
|
||||
normalizeBackupDir,
|
||||
pruneConfigBackups,
|
||||
readConfigBackupSettings,
|
||||
resolveConfigBackupDir,
|
||||
restoreConfigBackup,
|
||||
configBackupOrigin,
|
||||
latestBackupRetention,
|
||||
sweepConfigBackupTemps,
|
||||
} from '../src/config-backup.js';
|
||||
|
||||
let root: string;
|
||||
let data: string;
|
||||
let backups: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'cfg-backup-'));
|
||||
data = join(root, 'data');
|
||||
backups = join(root, 'backups');
|
||||
mkdirSync(data);
|
||||
});
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const put = (name: string, text: string) => writeFileSync(join(data, name), text);
|
||||
const DAY = 86_400_000;
|
||||
const T0 = Date.UTC(2026, 9, 1, 12, 0, 0);
|
||||
|
||||
describe('settings', () => {
|
||||
it('defaults to ON, 20 copies, 30 days, default folder', () => {
|
||||
expect(readConfigBackupSettings({})).toEqual({ enabled: true, dir: null, keepCount: 20, keepDays: 30 });
|
||||
expect(readConfigBackupSettings(null).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('only an explicit false turns it off, and numbers are clamped to their ranges', () => {
|
||||
expect(readConfigBackupSettings({ configBackupEnabled: false }).enabled).toBe(false);
|
||||
const s = readConfigBackupSettings({ configBackupKeepCount: 9999, configBackupKeepDays: -3 });
|
||||
expect(s.keepCount).toBe(500);
|
||||
expect(s.keepDays).toBe(0);
|
||||
expect(readConfigBackupSettings({ configBackupKeepCount: 0 }).keepCount).toBe(2);
|
||||
expect(readConfigBackupSettings({ configBackupKeepCount: 'abc' }).keepCount).toBe(20);
|
||||
expect(readConfigBackupSettings({ configBackupKeepCount: 7.9 }).keepCount).toBe(7);
|
||||
});
|
||||
|
||||
it('expands ~ and refuses anything that is not an absolute path', () => {
|
||||
expect(normalizeBackupDir('~/cfg', '/home/u')).toBe('/home/u/cfg');
|
||||
expect(normalizeBackupDir('/mnt/backups/codeman', '/home/u')).toBe('/mnt/backups/codeman');
|
||||
for (const bad of ['', ' ', 'relative/dir', './here', undefined, 42, '/x\0y']) {
|
||||
expect(normalizeBackupDir(bad as never, '/home/u'), String(bad)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses <data dir>/backups/config by default and never the data dir itself', () => {
|
||||
expect(resolveConfigBackupDir(readConfigBackupSettings({}), '/d')).toBe(defaultConfigBackupDir('/d'));
|
||||
expect(resolveConfigBackupDir(readConfigBackupSettings({ configBackupDir: '/elsewhere' }), '/d')).toBe(
|
||||
'/elsewhere'
|
||||
);
|
||||
expect(resolveConfigBackupDir(readConfigBackupSettings({ configBackupDir: '/d' }), '/d')).toBe(
|
||||
defaultConfigBackupDir('/d')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createConfigBackup', () => {
|
||||
it('snapshots only the tracked files that exist, with a manifest', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
put('clis.json', '{"clis":{}}');
|
||||
put('state.json', '{"runtime":true}'); // not config: must not be copied
|
||||
put('hook-secret', 'x'); // regenerated: must not be copied
|
||||
const r = createConfigBackup({
|
||||
dataDir: data,
|
||||
backupDir: backups,
|
||||
reason: 'startup',
|
||||
appVersion: '1.2.3',
|
||||
now: T0,
|
||||
});
|
||||
expect(r.status).toBe('created');
|
||||
const [b] = listConfigBackups(backups);
|
||||
expect(b.files.map((f) => f.name).sort()).toEqual(['clis.json', 'settings.json']);
|
||||
expect(b.reason).toBe('startup');
|
||||
expect(b.appVersion).toBe('1.2.3');
|
||||
expect(b.createdAt).toBe(T0);
|
||||
expect(existsSync(join(backups, b.id, 'state.json'))).toBe(false);
|
||||
expect(readFileSync(join(backups, b.id, 'settings.json'), 'utf8')).toBe('{"a":1}');
|
||||
});
|
||||
|
||||
it('tracks a fixed allowlist', () => {
|
||||
expect([...CONFIG_BACKUP_FILES]).toContain('settings.json');
|
||||
expect([...CONFIG_BACKUP_FILES]).not.toContain('state.json');
|
||||
expect([...CONFIG_BACKUP_FILES]).not.toContain('hook-secret');
|
||||
});
|
||||
|
||||
it('makes no second snapshot while nothing changed, and one when something does', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
const again = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 });
|
||||
expect(again).toEqual({ status: 'unchanged', id: (first as { id: string }).id });
|
||||
expect(listConfigBackups(backups)).toHaveLength(1);
|
||||
|
||||
put('settings.json', '{"a":2}');
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
|
||||
'created'
|
||||
);
|
||||
expect(listConfigBackups(backups)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not count a self-refreshed discovery timestamp as a change', () => {
|
||||
const hosts = (stamp: string, model = 'm1') =>
|
||||
JSON.stringify({ hosts: [{ id: 'h', models: [{ id: model }], lastDiscoveredAt: stamp }] });
|
||||
put('custom-model-hosts.json', hosts('2026-10-12T08:44:22.026Z'));
|
||||
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
|
||||
// The 5-minute discovery poll rewrites only the timestamp: still the same state.
|
||||
put('custom-model-hosts.json', hosts('2026-10-12T08:49:22.025Z'));
|
||||
const again = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 });
|
||||
expect(again).toEqual({ status: 'unchanged', id: (first as { id: string }).id });
|
||||
expect(listConfigBackups(backups)).toHaveLength(1);
|
||||
|
||||
// A real edit next to a refreshed timestamp is still a change, stored exactly as written.
|
||||
const edited = hosts('2026-10-12T08:54:22.025Z', 'm2');
|
||||
put('custom-model-hosts.json', edited);
|
||||
const third = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 });
|
||||
expect(third.status).toBe('created');
|
||||
expect(readFileSync(join(backups, (third as { id: string }).id, 'custom-model-hosts.json'), 'utf8')).toBe(edited);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'webviews.json',
|
||||
(n: number) =>
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'w1',
|
||||
name: 'Grafana',
|
||||
url: 'http://localhost:3001',
|
||||
embedMode: 'proxy',
|
||||
trusted: false,
|
||||
createdAt: 1,
|
||||
lastOpenedAt: n,
|
||||
},
|
||||
]),
|
||||
'name',
|
||||
],
|
||||
[
|
||||
'docker-cases.json',
|
||||
(n: number) => JSON.stringify([{ name: 'app', hostId: 'h', lastClaudeSessionId: `c${n}` }]),
|
||||
'hostId',
|
||||
],
|
||||
['users.json', (n: number) => JSON.stringify({ users: [{ name: 'a', role: 'admin', lastLoginAt: n }] }), 'role'],
|
||||
['settings.json', (n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}` }), 'displayName'],
|
||||
[
|
||||
'intents.json',
|
||||
(n: number) => JSON.stringify({ profiles: [{ key: 'k', goals: 'g', updatedAt: n, recentPrompts: [`p${n}`] }] }),
|
||||
'goals',
|
||||
],
|
||||
])('does not count activity in %s as a change, but does count an edit next to it', (name, make, editKey) => {
|
||||
put(name, make(1));
|
||||
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
put(name, make(2));
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 })).toEqual({
|
||||
status: 'unchanged',
|
||||
id: (first as { id: string }).id,
|
||||
});
|
||||
put(name, make(3).replace(new RegExp(`"${editKey}":"[^"]*"`), `"${editKey}":"edited"`));
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
|
||||
'created'
|
||||
);
|
||||
});
|
||||
|
||||
it('compares a tracked file that is not valid JSON by its bytes', () => {
|
||||
put('custom-model-hosts.json', 'not json lastDiscoveredAt 1');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
put('custom-model-hosts.json', 'not json lastDiscoveredAt 2');
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }).status).toBe(
|
||||
'created'
|
||||
);
|
||||
});
|
||||
|
||||
it('force makes a snapshot even when unchanged, and two in one second get distinct ids', () => {
|
||||
put('settings.json', '{}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'a', now: T0 });
|
||||
const forced = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'manual', force: true, now: T0 });
|
||||
expect(forced.status).toBe('created');
|
||||
const ids = listConfigBackups(backups).map((b) => b.id);
|
||||
expect(new Set(ids).size).toBe(2);
|
||||
});
|
||||
|
||||
it('reports empty, creating nothing, when no tracked file exists', () => {
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x' })).toEqual({ status: 'empty' });
|
||||
expect(existsSync(backups)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps snapshots private: 0700 folder and 0600 files', () => {
|
||||
put('settings.json', '{}');
|
||||
put('.env', 'CODEMAN_PASSWORD=secret');
|
||||
const r = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x', now: T0 });
|
||||
const id = (r as { id: string }).id;
|
||||
expect(statSync(join(backups, id)).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(join(backups, id, '.env')).mode & 0o777).toBe(0o600);
|
||||
expect(statSync(join(backups, id, 'manifest.json')).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('leaves no temp folder behind', () => {
|
||||
put('settings.json', '{}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x', now: T0 });
|
||||
const stray = readdirSync(backups).filter((n) => n.startsWith('.tmp-'));
|
||||
expect(stray).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retention', () => {
|
||||
const makeN = (n: number) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
put('settings.json', JSON.stringify({ i }));
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + i * DAY });
|
||||
}
|
||||
};
|
||||
|
||||
it('keeps the newest keepCount', () => {
|
||||
makeN(6);
|
||||
const removed = pruneConfigBackups(backups, { keepCount: 3, keepDays: 0, now: T0 + 6 * DAY });
|
||||
expect(removed).toHaveLength(3);
|
||||
const left = listConfigBackups(backups);
|
||||
expect(left).toHaveLength(3);
|
||||
expect(left.map((b) => b.createdAt)).toEqual([T0 + 5 * DAY, T0 + 4 * DAY, T0 + 3 * DAY]);
|
||||
});
|
||||
|
||||
it('drops anything older than keepDays, but never the newest', () => {
|
||||
makeN(4); // T0 .. T0+3d
|
||||
// At T0+100d everything is older than 30 days: the newest still survives.
|
||||
const removed = pruneConfigBackups(backups, { keepCount: 50, keepDays: 30, now: T0 + 100 * DAY });
|
||||
expect(removed).toHaveLength(3);
|
||||
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([T0 + 3 * DAY]);
|
||||
});
|
||||
|
||||
it('applies both limits together and keepDays 0 means no age limit', () => {
|
||||
makeN(5);
|
||||
expect(pruneConfigBackups(backups, { keepCount: 50, keepDays: 0, now: T0 + 999 * DAY })).toEqual([]);
|
||||
const removed = pruneConfigBackups(backups, { keepCount: 4, keepDays: 3, now: T0 + 5 * DAY });
|
||||
// Newest first: T0+4d, +3d, +2d, +1d, T0. A snapshot ages from its successor: T0+1d was live
|
||||
// until T0+2d, which sits exactly on the cutoff (kept); only T0 is over the count.
|
||||
expect(removed.length).toBe(1);
|
||||
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([
|
||||
T0 + 4 * DAY,
|
||||
T0 + 3 * DAY,
|
||||
T0 + 2 * DAY,
|
||||
T0 + DAY,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the last good copy when the first change comes after a quiet month', () => {
|
||||
put('settings.json', '{"theme":"good"}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'startup', now: T0 });
|
||||
put('settings.json', '{}'); // the bad write, 40 days later
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 40 * DAY });
|
||||
pruneConfigBackups(backups, { keepCount: 20, keepDays: 30, now: T0 + 40 * DAY });
|
||||
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([T0 + 40 * DAY, T0]);
|
||||
});
|
||||
|
||||
it('never deletes a folder it did not make', () => {
|
||||
makeN(3);
|
||||
mkdirSync(join(backups, 'photos'));
|
||||
writeFileSync(join(backups, 'photos', 'a.jpg'), 'x');
|
||||
mkdirSync(join(backups, 'cfg-20200101-000000')); // right name, no manifest: not ours
|
||||
writeFileSync(join(backups, 'notes.txt'), 'keep');
|
||||
pruneConfigBackups(backups, { keepCount: 1, keepDays: 1, now: T0 + 500 * DAY });
|
||||
expect(existsSync(join(backups, 'photos', 'a.jpg'))).toBe(true);
|
||||
expect(existsSync(join(backups, 'cfg-20200101-000000'))).toBe(true);
|
||||
expect(existsSync(join(backups, 'notes.txt'))).toBe(true);
|
||||
expect(listConfigBackups(backups)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does nothing for a folder that does not exist', () => {
|
||||
expect(pruneConfigBackups(join(root, 'nope'), { keepCount: 1, keepDays: 1 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreConfigBackup', () => {
|
||||
it('puts the files back, atomically, after taking a pre-restore snapshot', () => {
|
||||
put('settings.json', '{"good":true}');
|
||||
put('clis.json', '{"clis":{"grok":{"enabled":true}}}');
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
|
||||
put('settings.json', '{"polluted":true}');
|
||||
rmSync(join(data, 'clis.json'));
|
||||
|
||||
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id, now: T0 + 1000 });
|
||||
expect(r.restored.sort()).toEqual(['clis.json', 'settings.json']);
|
||||
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"good":true}');
|
||||
expect(readFileSync(join(data, 'clis.json'), 'utf8')).toBe('{"clis":{"grok":{"enabled":true}}}');
|
||||
expect(statSync(join(data, 'settings.json')).mode & 0o777).toBe(0o600);
|
||||
|
||||
// The state it replaced is recoverable.
|
||||
expect(r.safetySnapshot).toBeTruthy();
|
||||
const safety = listConfigBackups(backups).find((b) => b.id === r.safetySnapshot)!;
|
||||
expect(safety.reason).toBe('pre-restore');
|
||||
expect(readFileSync(join(backups, safety.id, 'settings.json'), 'utf8')).toBe('{"polluted":true}');
|
||||
});
|
||||
|
||||
it('refuses a snapshot whose file no longer matches its checksum, changing nothing', () => {
|
||||
put('settings.json', '{"good":true}');
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
chmodSync(join(backups, made.id, 'settings.json'), 0o600);
|
||||
writeFileSync(join(backups, made.id, 'settings.json'), '{"tampered":true}');
|
||||
put('settings.json', '{"live":true}');
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id })).toThrow(
|
||||
ConfigBackupCorruptError
|
||||
);
|
||||
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"live":true}');
|
||||
expect(listConfigBackups(backups)).toHaveLength(1); // no pre-restore snapshot for a refused restore
|
||||
});
|
||||
|
||||
it('rejects unknown ids and anything that is not a snapshot name (no path traversal)', () => {
|
||||
put('settings.json', '{}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
for (const id of ['cfg-19990101-000000', '../data', '..', 'photos', 'cfg-20261001-120000/../../x']) {
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id }), id).toThrow(
|
||||
ConfigBackupNotFoundError
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses the newest backup as the undo copy when the live files already equal it', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
put('settings.json', '{"a":2}');
|
||||
const newest = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + DAY }) as {
|
||||
id: string;
|
||||
};
|
||||
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id: first.id, now: T0 + 2 * DAY });
|
||||
expect(r.safetySnapshot).toBe(newest.id);
|
||||
expect(listConfigBackups(backups).map((b) => b.id)).toEqual([newest.id, first.id]); // no duplicate
|
||||
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"a":1}');
|
||||
});
|
||||
|
||||
it('a file it cannot write does not stop the others, and the error names both lists', () => {
|
||||
put('settings.json', '{"good":true}');
|
||||
put('clis.json', '{"clis":{}}');
|
||||
put('webhook.json', '{"enabled":false}');
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
put('settings.json', '{"polluted":true}');
|
||||
put('webhook.json', '{"enabled":true}');
|
||||
rmSync(join(data, 'clis.json'));
|
||||
mkdirSync(join(data, 'clis.json')); // between the other two in the tracked list
|
||||
let caught: unknown;
|
||||
try {
|
||||
restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id, now: T0 + 1000 });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(ConfigBackupPartialRestoreError);
|
||||
const err = caught as ConfigBackupPartialRestoreError;
|
||||
expect(err.restored).toEqual(['settings.json', 'webhook.json']);
|
||||
expect(err.failed.map((f) => f.name)).toEqual(['clis.json']);
|
||||
expect(err.safetySnapshot).toBeTruthy();
|
||||
expect(readFileSync(join(data, 'webhook.json'), 'utf8')).toBe('{"enabled":false}');
|
||||
expect(readdirSync(data).filter((n) => n.includes('.restore-'))).toEqual([]); // no temp left behind
|
||||
});
|
||||
|
||||
it('only ever writes allowlisted names, even if a manifest lists others', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
const mpath = join(backups, made.id, 'manifest.json');
|
||||
const m = JSON.parse(readFileSync(mpath, 'utf8'));
|
||||
m.files.push({ name: '../escape.json', bytes: 1, sha256: 'x' }, { name: 'state.json', bytes: 1, sha256: 'x' });
|
||||
writeFileSync(mpath, JSON.stringify(m));
|
||||
restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id });
|
||||
expect(existsSync(join(root, 'escape.json'))).toBe(false);
|
||||
expect(existsSync(join(data, 'state.json'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diffConfigBackups', () => {
|
||||
const snap = (now: number) => {
|
||||
const r = createConfigBackup({ dataDir: data, backupDir: backups, reason: 't', force: true, now });
|
||||
if (r.status !== 'created') throw new Error('expected a snapshot');
|
||||
return r.id;
|
||||
};
|
||||
const diff = (from: string, to: string) => diffConfigBackups({ dataDir: data, backupDir: backups, from, to });
|
||||
const file = (d: ReturnType<typeof diff>, name: string) => d.files.find((f) => f.name === name)!;
|
||||
|
||||
it('lists key-level changes between two backups', () => {
|
||||
put('settings.json', JSON.stringify({ displayName: 'a', theme: { mode: 'dark' }, tags: ['x'] }));
|
||||
const a = snap(T0);
|
||||
put('settings.json', JSON.stringify({ displayName: 'b', theme: { mode: 'dark' }, tags: ['x', 'y'], extra: 1 }));
|
||||
const b = snap(T0 + 1000);
|
||||
const f = file(diff(a, b), 'settings.json');
|
||||
expect(f.status).toBe('changed');
|
||||
expect(f.changes).toEqual([
|
||||
{ path: 'displayName', kind: 'changed', before: '"a"', after: '"b"' },
|
||||
{ path: 'extra', kind: 'added', after: '1' },
|
||||
{ path: 'tags[1]', kind: 'added', after: '"y"' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('compares a backup with the running config, and reports files that appeared or vanished', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
put('webhook.json', '{"url":"u"}');
|
||||
const a = snap(T0);
|
||||
put('settings.json', '{"a":2}');
|
||||
rmSync(join(data, 'webhook.json'));
|
||||
put('intents.json', '{"i":true}');
|
||||
const d = diff(a, 'current');
|
||||
expect(d.changed).toBe(3);
|
||||
expect(file(d, 'settings.json').changes).toEqual([{ path: 'a', kind: 'changed', before: '1', after: '2' }]);
|
||||
expect(file(d, 'webhook.json').status).toBe('removed');
|
||||
expect(file(d, 'intents.json').status).toBe('added');
|
||||
expect(diff(a, a).changed).toBe(0);
|
||||
});
|
||||
|
||||
it('never returns a webhook URL, captured prompts, or URL userinfo and query', () => {
|
||||
put('webhook.json', JSON.stringify({ url: 'https://hooks.slack.com/services/T0/B0/oldsecret' }));
|
||||
put('intents.json', JSON.stringify({ profiles: [{ key: 'k', prompts: ['my password is pw1'] }] }));
|
||||
put('settings.json', JSON.stringify({ baseUrl: 'https://user:pw@host/p?token=t1#f' }));
|
||||
const a = snap(T0);
|
||||
put('webhook.json', JSON.stringify({ url: 'https://hooks.slack.com/services/T0/B0/newsecret' }));
|
||||
put('intents.json', JSON.stringify({ profiles: [{ key: 'k', prompts: ['my password is pw2'] }] }));
|
||||
put('settings.json', JSON.stringify({ baseUrl: 'https://user:pw@host/p?token=t2#f' }));
|
||||
const d = diff(a, 'current');
|
||||
const json = JSON.stringify(d);
|
||||
for (const s of ['secret', 'pw1', 'pw2', 'user:pw', 'token=', 'hooks.slack']) expect(json).not.toContain(s);
|
||||
expect(file(d, 'webhook.json').changes.every((c) => c.redacted)).toBe(true);
|
||||
expect(file(d, 'intents.json').changes.every((c) => c.redacted)).toBe(true);
|
||||
});
|
||||
|
||||
it('never returns a credential value, only that it differs', () => {
|
||||
put('.env', 'SECRET_ONE=hunter2\nOTHER=abc\n');
|
||||
put('users.json', JSON.stringify({ admin: { name: 'root' } }));
|
||||
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', apiKey: 'sk-old', url: 'http://x' }]));
|
||||
const a = snap(T0);
|
||||
put('.env', 'SECRET_ONE=hunter3\nOTHER=abd\n');
|
||||
put('users.json', JSON.stringify({ admin: { name: 'rooted' } }));
|
||||
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', apiKey: 'sk-new', url: 'http://y' }]));
|
||||
const d = diff(a, 'current');
|
||||
const json = JSON.stringify(d);
|
||||
for (const secret of ['hunter', 'abc', 'abd', 'root', 'sk-old', 'sk-new']) expect(json).not.toContain(secret);
|
||||
expect(file(d, '.env').changes.every((c) => c.redacted)).toBe(true);
|
||||
expect(file(d, 'custom-model-hosts.json').changes).toEqual([
|
||||
{ path: '[0].apiKey', kind: 'changed', redacted: true },
|
||||
{ path: '[0].url', kind: 'changed', before: '"http://x"', after: '"http://y"' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not count a self-refreshed discovery timestamp as a difference', () => {
|
||||
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', lastDiscoveredAt: 1 }]));
|
||||
const a = snap(T0);
|
||||
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', lastDiscoveredAt: 2 }]));
|
||||
const d = diff(a, 'current');
|
||||
expect(file(d, 'custom-model-hosts.json').status).toBe('unchanged');
|
||||
expect(d.changed).toBe(0);
|
||||
});
|
||||
|
||||
it('reports a file that is not JSON as differing, without its content', () => {
|
||||
put('settings.json', 'not json at all');
|
||||
const a = snap(T0);
|
||||
put('settings.json', 'still not json');
|
||||
const f = file(diff(a, 'current'), 'settings.json');
|
||||
expect(f).toMatchObject({ status: 'changed', detail: 'bytes', changes: [] });
|
||||
});
|
||||
|
||||
it('caps the changes listed per file', () => {
|
||||
put('settings.json', '{}');
|
||||
const a = snap(T0);
|
||||
put('settings.json', JSON.stringify(Object.fromEntries(Array.from({ length: 250 }, (_, i) => [`k${i}`, i]))));
|
||||
const f = file(diff(a, 'current'), 'settings.json');
|
||||
expect(f.changes).toHaveLength(200);
|
||||
expect(f.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses unknown ids, path traversal and a tampered snapshot', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const a = snap(T0);
|
||||
expect(() => diff('cfg-20200101-000000', 'current')).toThrow(ConfigBackupNotFoundError);
|
||||
expect(() => diff('../etc', 'current')).toThrow(ConfigBackupNotFoundError);
|
||||
writeFileSync(join(backups, a, 'settings.json'), '{"a":99}');
|
||||
expect(() => diff(a, 'current')).toThrow(ConfigBackupCorruptError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createConfigBackup: a snapshot dated ahead of the clock', () => {
|
||||
it('is not written again on every pass once the clock is corrected', () => {
|
||||
put('settings.json', '{"v":0}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
put('settings.json', '{"v":1}');
|
||||
const ahead = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2 * 3_600_000 });
|
||||
expect(ahead.status).toBe('created');
|
||||
put('settings.json', '{"v":2}'); // the clock is back at T0 + 1 minute
|
||||
const corrected = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 60_000 });
|
||||
expect(corrected.status).toBe('created');
|
||||
const [newest, second] = listConfigBackups(backups);
|
||||
expect(newest.id).toBe((corrected as { id: string }).id); // dated no earlier than the one before it
|
||||
expect(newest.createdAt).toBeGreaterThan(second.createdAt);
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const r = createConfigBackup({
|
||||
dataDir: data,
|
||||
backupDir: backups,
|
||||
reason: 'auto',
|
||||
now: T0 + 120_000 + i * 300_000,
|
||||
});
|
||||
expect(r.status, `pass ${i}`).toBe('unchanged');
|
||||
}
|
||||
expect(listConfigBackups(backups)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('instances sharing one backup folder', () => {
|
||||
it('keeps to its own snapshots: no churn, no prune, no restore of the other', () => {
|
||||
const other = join(root, 'other-data');
|
||||
mkdirSync(other, { recursive: true });
|
||||
put('settings.json', '{"a":1}');
|
||||
writeFileSync(join(other, 'settings.json'), '{"b":2}');
|
||||
const mine = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
|
||||
const theirs = createConfigBackup({ dataDir: other, backupDir: backups, reason: 'auto', now: T0 + 1000 });
|
||||
expect(theirs.status).toBe('created');
|
||||
// Each side's change check compares with its own newest snapshot, so a second tick is a no-op.
|
||||
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
|
||||
'unchanged'
|
||||
);
|
||||
expect(createConfigBackup({ dataDir: other, backupDir: backups, reason: 'auto', now: T0 + 3000 }).status).toBe(
|
||||
'unchanged'
|
||||
);
|
||||
expect(listConfigBackups(backups, configBackupOrigin(data))).toHaveLength(1);
|
||||
expect(
|
||||
pruneConfigBackups(backups, { keepCount: 1, keepDays: 1, now: T0 + 999 * DAY, origin: configBackupOrigin(data) })
|
||||
).toEqual([]);
|
||||
expect(listConfigBackups(backups)).toHaveLength(2);
|
||||
const theirId = (theirs as { id: string }).id;
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: theirId })).toThrow(
|
||||
ConfigBackupNotFoundError
|
||||
);
|
||||
expect((mine as { id: string }).id).not.toBe(theirId);
|
||||
});
|
||||
|
||||
it('a restore reports tracked files it left alone, and sweeps only old temp folders', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
put('webhook.json', '{"url":"u"}');
|
||||
expect(restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id }).untouched).toEqual(['webhook.json']);
|
||||
const stale = join(backups, '.tmp-cfg-20200101-000000-99999');
|
||||
mkdirSync(stale);
|
||||
sweepConfigBackupTemps(backups, Date.now() + 2 * 3_600_000);
|
||||
expect(existsSync(stale)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore trusts only what this module wrote', () => {
|
||||
const made = () =>
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
|
||||
|
||||
it('refuses a symlinked snapshot file and a snapshot file with group or world bits', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
put('clis.json', '{"clis":{}}');
|
||||
const { id } = made();
|
||||
const file = join(backups, id, 'clis.json');
|
||||
chmodSync(file, 0o644);
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(ConfigBackupCorruptError);
|
||||
|
||||
chmodSync(file, 0o600);
|
||||
const real = join(root, 'elsewhere.json');
|
||||
writeFileSync(real, '{"clis":{}}');
|
||||
rmSync(file);
|
||||
symlinkSync(real, file);
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(/symlink/);
|
||||
// nothing was written and no pre-restore copy was taken for a refused restore
|
||||
expect(listConfigBackups(backups)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips the group/world bit test on a filesystem that does not keep modes, but never the symlink test', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
put('clis.json', '{"clis":{}}');
|
||||
const { id } = made();
|
||||
const file = join(backups, id, 'clis.json');
|
||||
chmodSync(file, 0o644); // what vfat, CIFS and WSL /mnt/d report for every file
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id, keepsModes: false })).not.toThrow();
|
||||
|
||||
const real = join(root, 'elsewhere.json');
|
||||
writeFileSync(real, '{"clis":{}}');
|
||||
rmSync(file);
|
||||
symlinkSync(real, file);
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id, keepsModes: false })).toThrow(/symlink/);
|
||||
});
|
||||
|
||||
it('the mode probe never writes through a planted symlink or trips over a planted directory', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const { id } = made();
|
||||
const target = join(root, 'victim.txt');
|
||||
writeFileSync(target, 'precious', { mode: 0o644 });
|
||||
symlinkSync(target, join(backups, `.mode-probe-${process.pid}`));
|
||||
mkdirSync(join(backups, '.mode-probe-0'));
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).not.toThrow();
|
||||
expect(() => diffConfigBackups({ dataDir: data, backupDir: backups, from: id, to: 'current' })).not.toThrow();
|
||||
expect(readFileSync(target, 'utf8')).toBe('precious');
|
||||
expect(statSync(target).mode & 0o777).toBe(0o644);
|
||||
});
|
||||
|
||||
it('compare applies the same check to a snapshot file', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const { id } = made();
|
||||
chmodSync(join(backups, id, 'settings.json'), 0o644);
|
||||
expect(() => diffConfigBackups({ dataDir: data, backupDir: backups, from: id, to: 'current' })).toThrow(
|
||||
ConfigBackupCorruptError
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a snapshot folder that others can write to', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
const { id } = made();
|
||||
chmodSync(join(backups, id), 0o777);
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(ConfigBackupCorruptError);
|
||||
chmodSync(join(backups, id), 0o700);
|
||||
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps the backup retention when the live settings.json was reset and has none of the four keys', () => {
|
||||
put('settings.json', JSON.stringify({ displayName: 'old', configBackupKeepCount: 100, configBackupKeepDays: 0 }));
|
||||
const { id } = made();
|
||||
put('settings.json', '{}');
|
||||
restoreConfigBackup({ dataDir: data, backupDir: backups, id });
|
||||
expect(JSON.parse(readFileSync(join(data, 'settings.json'), 'utf8'))).toEqual({
|
||||
displayName: 'old',
|
||||
configBackupKeepCount: 100,
|
||||
configBackupKeepDays: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('latestBackupRetention skips a newer snapshot that recorded none, and a pre-restore copy records it', () => {
|
||||
put('settings.json', '{"a":1}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0, keepCount: 100, keepDays: 0 });
|
||||
put('settings.json', '{"a":2}');
|
||||
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }); // an older build
|
||||
const origin = configBackupOrigin(data);
|
||||
expect(latestBackupRetention(backups, origin)).toEqual({ keepCount: 100, keepDays: 0 });
|
||||
const id = listConfigBackups(backups, origin).at(-1)!.id;
|
||||
// A change since the newest backup, so the restore makes a pre-restore copy rather than reusing it.
|
||||
put('settings.json', '{"a":3}');
|
||||
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id, now: T0 + 2000 });
|
||||
expect(r.safetySnapshot).toBeTruthy();
|
||||
const pre = listConfigBackups(backups, origin).find((b) => b.id === r.safetySnapshot)!;
|
||||
expect(pre.reason).toBe('pre-restore');
|
||||
expect(JSON.parse(readFileSync(join(backups, pre.id, 'manifest.json'), 'utf8'))).toMatchObject({
|
||||
keepCount: 100,
|
||||
keepDays: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the current folder and retention when settings.json is restored', () => {
|
||||
put('settings.json', JSON.stringify({ displayName: 'old', configBackupDir: '/old', configBackupKeepCount: 5 }));
|
||||
const { id } = made();
|
||||
put('settings.json', JSON.stringify({ displayName: 'new', configBackupDir: '/new', configBackupKeepDays: 7 }));
|
||||
restoreConfigBackup({ dataDir: data, backupDir: backups, id });
|
||||
const after = JSON.parse(readFileSync(join(data, 'settings.json'), 'utf8'));
|
||||
// the folder and days follow the live file; the live file has no keep count (reset), so the backup's 5 stays
|
||||
expect(after).toEqual({
|
||||
displayName: 'old',
|
||||
configBackupDir: '/new',
|
||||
configBackupKeepCount: 5,
|
||||
configBackupKeepDays: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @fileoverview The global session cap can be set with `CODEMAN_MAX_SESSIONS`.
|
||||
*
|
||||
* Tested through `sessionCapacityState` / `sessionCapacityMessage`, the check every
|
||||
* session-creating route goes through, so a regression in the env parsing OR in
|
||||
* the wiring from `map-limits` to the routes (and to the derived per-user cap in
|
||||
* multi-user mode) fails here.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type Helpers = typeof import('../../src/web/route-helpers.js');
|
||||
|
||||
/** A sessions map of the given size, every session owned by `owner`. */
|
||||
function sessionsOf(n: number, owner?: string): ReadonlyMap<string, never> {
|
||||
return new Map(Array.from({ length: n }, (_, i) => [`s${i}`, { id: `s${i}`, owner } as never]));
|
||||
}
|
||||
|
||||
/** Fresh module graph, so map-limits re-reads the environment at import. */
|
||||
async function loadWithEnv(env: Record<string, string | undefined>): Promise<Helpers> {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('CODEMAN_MAX_SESSIONS', undefined);
|
||||
vi.stubEnv('CODEMAN_MAX_SESSIONS_PER_USER', undefined);
|
||||
vi.stubEnv('CODEMAN_MULTIUSER', undefined);
|
||||
for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value);
|
||||
return import('../../src/web/route-helpers.js');
|
||||
}
|
||||
|
||||
describe('global session cap (CODEMAN_MAX_SESSIONS)', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('stays at 50 when the variable is not set', async () => {
|
||||
const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({});
|
||||
expect(sessionCapacityState(sessionsOf(49)).atGlobalCap).toBe(false);
|
||||
expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(true);
|
||||
expect(sessionCapacityMessage(sessionsOf(50))).toContain('(50)');
|
||||
});
|
||||
|
||||
it('raises the cap', async () => {
|
||||
const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: '120' });
|
||||
expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(false);
|
||||
expect(sessionCapacityState(sessionsOf(119)).atGlobalCap).toBe(false);
|
||||
expect(sessionCapacityState(sessionsOf(120)).atGlobalCap).toBe(true);
|
||||
expect(sessionCapacityMessage(sessionsOf(120))).toContain('(120)');
|
||||
});
|
||||
|
||||
it('lowers the cap, ignoring surrounding whitespace', async () => {
|
||||
const { sessionCapacityState } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: ' 3 ' });
|
||||
expect(sessionCapacityState(sessionsOf(2)).atGlobalCap).toBe(false);
|
||||
expect(sessionCapacityState(sessionsOf(3)).atGlobalCap).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['1000', '500', '463', '99999999999999999999'])('clamps %j to the ceiling of 462', async (value) => {
|
||||
const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value });
|
||||
expect(sessionCapacityState(sessionsOf(461)).atGlobalCap).toBe(false);
|
||||
expect(sessionCapacityState(sessionsOf(462)).atGlobalCap).toBe(true);
|
||||
expect(sessionCapacityMessage(sessionsOf(462))).toContain('(462)');
|
||||
});
|
||||
|
||||
it('keeps the ceiling inside the tab-layout budget next to the saved web tabs', async () => {
|
||||
vi.resetModules();
|
||||
const { MAX_CONCURRENT_SESSIONS_CEILING } = await import('../../src/config/map-limits.js');
|
||||
const { MAX_WEBVIEWS } = await import('../../src/config/webview-limits.js');
|
||||
const { MAX_TAB_REFS } = await import('../../src/tab-layout.js');
|
||||
expect(MAX_CONCURRENT_SESSIONS_CEILING + MAX_WEBVIEWS).toBeLessThanOrEqual(MAX_TAB_REFS);
|
||||
});
|
||||
|
||||
it.each(['', '0', '-5', '2.5', 'abc', '1e3'])('falls back to 50 for an unusable value %j', async (value) => {
|
||||
const { sessionCapacityState } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value });
|
||||
expect(sessionCapacityState(sessionsOf(49)).atGlobalCap).toBe(false);
|
||||
expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(true);
|
||||
});
|
||||
|
||||
it('derives the multi-user per-user cap (half the global cap) from it', async () => {
|
||||
const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({
|
||||
CODEMAN_MAX_SESSIONS: '100',
|
||||
CODEMAN_MULTIUSER: '1',
|
||||
});
|
||||
expect(sessionCapacityState(sessionsOf(49, 'alice'), 'alice').atUserCap).toBe(false);
|
||||
expect(sessionCapacityState(sessionsOf(50, 'alice'), 'alice')).toEqual({ atGlobalCap: false, atUserCap: true });
|
||||
expect(sessionCapacityMessage(sessionsOf(50, 'alice'), 'alice')).toContain('(50)');
|
||||
});
|
||||
|
||||
it('leaves an explicit CODEMAN_MAX_SESSIONS_PER_USER in charge of the per-user cap', async () => {
|
||||
const { sessionCapacityState } = await loadWithEnv({
|
||||
CODEMAN_MAX_SESSIONS: '100',
|
||||
CODEMAN_MAX_SESSIONS_PER_USER: '10',
|
||||
CODEMAN_MULTIUSER: '1',
|
||||
});
|
||||
expect(sessionCapacityState(sessionsOf(9, 'alice'), 'alice').atUserCap).toBe(false);
|
||||
expect(sessionCapacityState(sessionsOf(10, 'alice'), 'alice').atUserCap).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;/);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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';
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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
@@ -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 + '/');
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 } },
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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	://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>');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,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', () => {
|
||||
|
||||
@@ -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']");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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'$/);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user