Merge remote-tracking branch 'origin/master' into pr/cod-455-xlsx-preview

# Conflicts:
#	src/web/public/constants.js
This commit is contained in:
Aamer Akhter
2026-10-01 21:35:28 -04:00
83 changed files with 5545 additions and 177 deletions
@@ -22,6 +22,8 @@ import {
agentImageNpmPackages as mjsPackages,
GIT_HOST_CLI_BUILD_ARGS as mjsGitHostArgs,
gitHostCliBuildArgPairs as mjsGitHostPairs,
GIT_IDENTITY_BUILD_ARGS as mjsGitIdentityArgs,
gitIdentityBuildArgPairs as mjsGitIdentityPairs,
} from '../scripts/lib/cli-catalog.mjs';
import {
agentImageBuildArgPairs as tsPairs,
@@ -29,6 +31,8 @@ import {
agentImageNpmPackages as tsPackages,
GIT_HOST_CLI_BUILD_ARGS as tsGitHostArgs,
gitHostCliBuildArgPairs as tsGitHostPairs,
GIT_IDENTITY_BUILD_ARGS as tsGitIdentityArgs,
gitIdentityBuildArgPairs as tsGitIdentityPairs,
} from '../src/docker-hosts.js';
const CATALOG = JSON.parse(readFileSync(fileURLToPath(new URL('../config/clis.stock.json', import.meta.url)), 'utf-8'));
@@ -145,3 +149,52 @@ describe('optional gh / az in the agent image: both producers pass the same swit
}
});
});
describe('Git identity in the agent image: both producers pass the same settings', () => {
it('maps the Git environment variables to matching Dockerfile ARGs', () => {
expect(tsGitIdentityArgs).toEqual(mjsGitIdentityArgs);
expect(tsGitIdentityArgs).toEqual([
['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'],
['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'],
]);
});
it('passes a complete identity and omits an absent identity', () => {
const identity = {
CODEMAN_AGENT_IMAGE_GIT_USER_NAME: 'Ada Lovelace',
CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: 'ada@example.com',
};
const expected: Array<[string, string]> = [
['GIT_USER_NAME', 'Ada Lovelace'],
['GIT_USER_EMAIL', 'ada@example.com'],
];
expect(tsGitIdentityPairs(identity)).toEqual(expected);
expect(mjsGitIdentityPairs(identity)).toEqual(expected);
expect(tsGitIdentityPairs({})).toEqual([]);
expect(mjsGitIdentityPairs({})).toEqual([]);
// The combined argv, not just the helper: the manual build path could drop the identity otherwise.
expect(tsPairs(identity)).toEqual(mjsPairs(CATALOG, identity));
expect(tsPairs(identity)).toEqual(expect.arrayContaining(expected));
});
it('refuses a partial identity in both build paths', () => {
for (const identity of [
{ CODEMAN_AGENT_IMAGE_GIT_USER_NAME: 'Ada Lovelace' },
{ CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: 'ada@example.com' },
]) {
const named = /CODEMAN_AGENT_IMAGE_GIT_USER_NAME and CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL must both be set/;
expect(() => tsGitIdentityPairs(identity)).toThrow(named);
expect(() => mjsGitIdentityPairs(identity)).toThrow(named);
}
});
it('both Dockerfiles configure system Git identity from the build arguments', () => {
for (const file of ['../docker/agent.Dockerfile', '../docker/server.Dockerfile']) {
const dockerfile = readFileSync(fileURLToPath(new URL(file, import.meta.url)), 'utf-8');
expect(dockerfile, file).toMatch(/^ARG GIT_USER_NAME=$/m);
expect(dockerfile, file).toMatch(/^ARG GIT_USER_EMAIL=$/m);
expect(dockerfile, file).toContain('git config --system user.name "${GIT_USER_NAME}"');
expect(dockerfile, file).toContain('git config --system user.email "${GIT_USER_EMAIL}"');
}
});
});
+121
View File
@@ -0,0 +1,121 @@
/**
* @fileoverview scripts/check-browser-test-excludes.mjs: the detection side (which test
* files need a real browser) and the leak computation. The exclusion side is vitest's own
* `vitest list`, which `npm run check:browser-excludes` exercises for real in CI.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import {
findBrowserTests,
findLeaks,
findTestFiles,
importsBrowserDriver,
listingMatchesTree,
parseVitestFileList,
} from '../scripts/check-browser-test-excludes.mjs';
import { BROWSER_TEST_GLOBS } from '../config/test-suites';
const repoRoot = resolve(import.meta.dirname, '..');
// Fixture sources are assembled from the module name at runtime, so THIS file never contains
// a literal driver import and is not itself flagged by the checker it tests.
const fromImport = (mod: string) => `import { chromium, type Browser } from '${mod}';\n`;
describe('importsBrowserDriver', () => {
it.each([
fromImport('playwright'),
fromImport('playwright-core').replace(/'/g, '"'),
fromImport('@playwright/test'),
fromImport('puppeteer'),
`import type { Page } from '${'playwright'}';`,
`const { chromium } = require('${'playwright'}');`,
`const pw = await import('${'playwright'}');`,
])('flags %s', (src) => {
expect(importsBrowserDriver(src)).toBe(true);
});
it.each([
"import { describe } from 'vitest';",
"// needs ms-playwright's cache dir\nconst dir = '.cache/ms-playwright';",
fromImport('./playwright-helpers'),
fromImport('playwright-extra-thing'),
])('ignores %s', (src) => {
expect(importsBrowserDriver(src)).toBe(false);
});
});
describe('findBrowserTests (fixture tree)', () => {
let root: string;
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'codeman-browser-excludes-'));
const put = (rel: string, src: string) => {
mkdirSync(join(root, rel, '..'), { recursive: true });
writeFileSync(join(root, rel), src);
};
put('test/unit.test.ts', "import { it } from 'vitest';\n");
put('test/legacy-name.test.ts', fromImport('playwright'));
put('test/new.browser.test.ts', fromImport('playwright'));
put('test/nested/deep.test.ts', fromImport('puppeteer'));
put('test/helpers/browser.ts', fromImport('playwright')); // not a test file
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
it('finds driver imports by content, recursively, as sorted repo-relative paths', () => {
expect(findBrowserTests(root)).toEqual([
'test/legacy-name.test.ts',
'test/nested/deep.test.ts',
'test/new.browser.test.ts',
]);
});
it('lists every test file, browser-driven or not, in the same form', () => {
expect(findTestFiles(root)).toEqual([
'test/legacy-name.test.ts',
'test/nested/deep.test.ts',
'test/new.browser.test.ts',
'test/unit.test.ts',
]);
});
});
describe('parseVitestFileList + findLeaks', () => {
it('keeps only test paths and normalizes a leading ./', () => {
const out = '\n./test/a.test.ts\ntest/b.test.ts\nsome banner line\n test/c.test.ts \n';
expect([...parseVitestFileList(out)].sort()).toEqual(['test/a.test.ts', 'test/b.test.ts', 'test/c.test.ts']);
});
it('reports exactly the browser tests the CI set still collects', () => {
const ci = new Set(['test/unit.test.ts', 'test/legacy-name.test.ts']);
expect(findLeaks(['test/legacy-name.test.ts', 'test/new.browser.test.ts'], ci)).toEqual([
'test/legacy-name.test.ts',
]);
expect(findLeaks(['test/new.browser.test.ts'], ci)).toEqual([]);
});
it('flags a non-empty listing whose paths never match the tree instead of passing vacuously', () => {
const tree = ['test/legacy-name.test.ts', 'test/unit.test.ts'];
// e.g. a vitest upgrade that starts printing absolute paths: nothing leaks, but only
// because nothing matches, so the checker must refuse rather than report success.
const drifted = parseVitestFileList('/repo/test/legacy-name.test.ts\n/repo/test/unit.test.ts\n');
expect(drifted.size).toBe(2);
expect(findLeaks(['test/legacy-name.test.ts'], drifted)).toEqual([]);
expect(listingMatchesTree(drifted, tree)).toBe(false);
const healthy = parseVitestFileList('test/legacy-name.test.ts\ntest/unit.test.ts\n');
expect(listingMatchesTree(healthy, tree)).toBe(true);
});
});
describe('against this repository', () => {
it('detects every file already listed in BROWSER_TEST_GLOBS', () => {
// If detection stopped recognising a known browser test, the checker would go blind to
// exactly the class of file it exists for.
const detected = new Set(findBrowserTests(repoRoot));
const literals = BROWSER_TEST_GLOBS.filter((g) => !/[*?[{]/.test(g));
expect(literals.length).toBeGreaterThan(0);
for (const file of literals) expect(detected, file).toContain(file);
});
});
+18
View File
@@ -165,6 +165,24 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
expect(compileVersionRegex(src), `${entry.id} declares a watchingLine the guard refuses`).not.toBeNull();
}
});
it('holds the optional awaitingLine to the same guard', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
awaitingLine: '(a+)+b',
};
}, 'it is tested against a pane row every time a session settles');
});
it('accepts every shipped awaitingLine', () => {
for (const entry of STOCK_CLIS) {
const src = entry.capabilities.workDetect?.awaitingLine;
if (!src) continue;
expect(compileVersionRegex(src), `${entry.id} declares an awaitingLine the guard refuses`).not.toBeNull();
}
});
});
describe('no shell text can reach the command line', () => {
+145 -1
View File
@@ -111,7 +111,7 @@ function loadPaletteHarness(overrides: Record<string, any> = {}) {
app.getSessionName = (session: any) =>
session.name || session.workingDir?.split('/').pop() || app.getShortId(session.id);
return { app, elements, listeners };
return { app, elements, listeners, makeClassList };
}
describe('Command-K session palette', () => {
@@ -480,6 +480,150 @@ describe('Session Manager unified list', () => {
});
});
describe('overlay focus restoration (Escape must not strand the keyboard)', () => {
/**
* Both overlays focus their search box on open. Closing them used to leave
* focus on <body>, so after Escape every keystroke went nowhere until the
* user clicked the terminal — measured in a real browser against a shell
* session: activeElement BODY, zero onData for anything typed afterwards.
*/
function focusHarness() {
const terminalTextarea = { focus: vi.fn(), isConnected: true };
const priorElement = { focus: vi.fn(), isConnected: true, tagName: 'TEXTAREA' };
const body = { tagName: 'BODY' };
let active: any = priorElement;
// The harness builds its element map internally, so getElementById reads it
// through this binding, filled in once the harness returns.
let els: Record<string, any> = {};
const { app, elements, makeClassList } = loadPaletteHarness({
document: {
getElementById: (id: string) => els[id] ?? null,
get activeElement() {
return active;
},
body,
},
});
els = elements;
app.terminal = { focus: terminalTextarea.focus };
app.activeSessionId = 'sess-beta';
// The overlay's own focus() is what moves focus in a real browser; the
// fake document needs the same transition or the test proves nothing.
elements.commandPaletteSearch.focus = vi.fn(() => {
active = elements.commandPaletteSearch;
});
// The restore keeps a focus that already left the overlay, so the modal has
// to know its own search box is inside it, as the real DOM does.
elements.commandPaletteModal.contains = (el: any) => el === elements.commandPaletteSearch;
// Same wiring for the Session Manager. A real classList: the close guard
// reads `contains('active')`, and a stub without it reports "not open" and
// skips the restore.
const installSessionManager = () => {
const search: any = { value: '', addEventListener: vi.fn() };
search.focus = vi.fn(() => {
active = search;
});
elements.sessionManagerSearch = search;
elements.sessionManagerModal = {
classList: makeClassList(),
addEventListener: vi.fn(),
contains: (el: any) => el === search,
};
elements.sessionManagerList = { replaceChildren: vi.fn(), appendChild: vi.fn() };
app._loadSessionManagerList = vi.fn();
};
return {
app,
elements,
priorElement,
terminalTextarea,
body,
makeClassList,
installSessionManager,
setActive: (v: any) => (active = v),
};
}
it('returns focus to whatever had it when the command palette closes', () => {
const { app, priorElement } = focusHarness();
app.openCommandPalette();
expect(priorElement.focus).not.toHaveBeenCalled();
app.closeCommandPalette();
expect(priorElement.focus).toHaveBeenCalledTimes(1);
});
it('falls back to the terminal when the prior element is gone, but only with a live session', () => {
const { app, priorElement, terminalTextarea } = focusHarness();
app.openCommandPalette();
priorElement.isConnected = false;
app.closeCommandPalette();
expect(priorElement.focus).not.toHaveBeenCalled();
expect(terminalTextarea.focus).toHaveBeenCalledTimes(1);
});
it('never focuses the terminal from the welcome screen (a phone would pop the keyboard)', () => {
const { app, priorElement, terminalTextarea } = focusHarness();
app.activeSessionId = null;
app.openCommandPalette();
priorElement.isConnected = false;
app.closeCommandPalette();
expect(terminalTextarea.focus).not.toHaveBeenCalled();
});
it('does not restore focus to <body>, which is the bug itself', () => {
const { app, terminalTextarea, body, setActive } = focusHarness();
setActive(body);
app.openCommandPalette();
app.closeCommandPalette();
expect(terminalTextarea.focus).toHaveBeenCalledTimes(1);
});
it('leaves focus alone when neither overlay was open — the path every Escape takes', () => {
// app.js's global Escape handler calls both close methods on EVERY Escape,
// in the capture phase. Nothing was saved, so an unguarded restore would fall
// through to the terminal and steal focus from split Pane B, from any text
// field, and turn the inline rename's Escape into a commit.
const { app, elements, terminalTextarea, priorElement, makeClassList } = focusHarness();
elements.sessionManagerModal = { classList: makeClassList(), addEventListener: vi.fn() };
app.closeCommandPalette();
app.closeSessionManager();
expect(terminalTextarea.focus).not.toHaveBeenCalled();
expect(priorElement.focus).not.toHaveBeenCalled();
});
it('does not focus the terminal on touch while the keyboard is down', () => {
const { app, terminalTextarea, body, setActive } = focusHarness();
app._shouldFocusTerminalForTabSwitch = () => false;
setActive(body);
app.openCommandPalette();
app.closeCommandPalette();
expect(terminalTextarea.focus).not.toHaveBeenCalled();
});
it('restores focus on the session manager too, not just the palette', async () => {
const { app, priorElement, installSessionManager } = focusHarness();
installSessionManager();
await app.openSessionManager();
app.closeSessionManager();
expect(priorElement.focus).toHaveBeenCalledTimes(1);
});
it('keeps the terminal focus the row menu gave it when the session manager closes', async () => {
// "Switch to session" and "Open folder" (terminal-ui.js) call selectSession(),
// which focuses the terminal on desktop, and only THEN closeSessionManager().
// Opened from its header button, the saved focus is that button, so an
// unconditional restore pulled focus off the session the user just picked.
const { app, priorElement, terminalTextarea, installSessionManager, setActive } = focusHarness();
installSessionManager();
await app.openSessionManager();
app.selectSession = vi.fn(() => setActive(terminalTextarea));
app.selectSession('sess-alpha');
app.closeSessionManager();
expect(priorElement.focus).not.toHaveBeenCalled();
expect(terminalTextarea.focus).not.toHaveBeenCalled();
});
});
describe('panel close helpers', () => {
it('closes panels when the mobile header helper is unavailable', () => {
const CodemanApp = function CodemanApp(this: any) {};
+81 -1
View File
@@ -16,7 +16,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { CronService, clampCronExternalCliConfigs, type CronDeps } from '../src/cron/cron-service.js';
import {
CronService,
clampCronExternalCliConfigs,
deliverCronPrompt,
type CronDeps,
} from '../src/cron/cron-service.js';
import { CRON_PASTE_ENTER_DELAY_MS } from '../src/config/server-timing.js';
import { CronJobSchema } from '../src/web/schemas.js';
import { MAX_CRON_JOBS } from '../src/config/map-limits.js';
import type { CronJob, CronJobRun } from '../src/types/cron.js';
@@ -705,3 +711,77 @@ describe('clampCronExternalCliConfigs', () => {
}
});
});
/**
* Paste mode used to write `<text>\r` in one piece. Claude Code takes a raw burst of
* about a hundred characters as a paste and turns its `\r` into a newline, so the
* prompt sat unsent on the composer while the run said `prompt_sent`.
*/
describe('deliverCronPrompt', () => {
const PROMPT =
'Reply with only the word ok and nothing else, this sentence is padding to reach about one hundred chars.';
function fakeTarget(ok = true) {
const calls: string[] = [];
const target = {
write: vi.fn((d: string) => {
calls.push(`write:${JSON.stringify(d)}`);
return ok;
}),
writeViaMux: vi.fn(async (d: string) => {
calls.push(`mux:${JSON.stringify(d)}`);
return ok;
}),
verifySubmitted: vi.fn((t: string) => {
calls.push(`verify:${JSON.stringify(t)}`);
}),
};
return { target, calls };
}
const noWait = async (): Promise<void> => {};
it('paste mode writes the text and its Enter separately, then arms the composer check', async () => {
const { target, calls } = fakeTarget();
const waits: number[] = [];
const ok = await deliverCronPrompt(target, PROMPT, 'paste', async (ms) => {
waits.push(ms);
calls.push('wait');
});
expect(ok).toBe(true);
expect(calls).toEqual([
`write:${JSON.stringify(PROMPT)}`,
'wait',
'write:"\\r"',
`verify:${JSON.stringify(PROMPT)}`,
]);
expect(waits).toEqual([CRON_PASTE_ENTER_DELAY_MS]);
expect(target.writeViaMux).not.toHaveBeenCalled();
});
it('never puts the Enter in the same write as the text', async () => {
const { target } = fakeTarget();
await deliverCronPrompt(target, `${PROMPT}\r`, 'paste', noWait);
for (const [data] of target.write.mock.calls) {
expect(data === '\r' || !data.includes('\r')).toBe(true);
}
});
it('typed mode is unchanged: one mux write that carries the Enter', async () => {
const { target, calls } = fakeTarget();
await deliverCronPrompt(target, PROMPT, 'typed', noWait);
expect(calls).toEqual([`mux:${JSON.stringify(`${PROMPT}\r`)}`]);
});
it('reports a session it could not write to, instead of claiming the prompt went out', async () => {
const { target } = fakeTarget(false);
expect(await deliverCronPrompt(target, PROMPT, 'paste', noWait)).toBe(false);
expect(target.verifySubmitted).not.toHaveBeenCalled();
});
});
+3 -4
View File
@@ -110,15 +110,14 @@ describe('docker-compose.yaml cap_add covers what entrypoint.sh and init:true ne
});
describe('the runtime-owned CLI prefix never shadows root commands', () => {
it('server.Dockerfile appends /opt/codeman-cli/bin to PATH rather than prepending it', () => {
it('server.Dockerfile appends the runtime-writable CLI dirs to PATH rather than prepending them', () => {
const pathLines = dockerfile.split('\n').filter((l) => /^ENV PATH=/.test(l));
expect(pathLines.length).toBeGreaterThan(0);
for (const line of pathLines) {
expect(line, 'a writable prefix ahead of $PATH lets a planted setpriv run as root').not.toMatch(
/^ENV PATH=\/opt\/codeman-cli/
);
expect(line, 'a writable prefix ahead of $PATH lets a planted setpriv run as root').toMatch(/^ENV PATH=\$PATH:/);
}
expect(pathLines).toContain('ENV PATH=$PATH:/opt/codeman-cli/bin');
expect(pathLines).toContain('ENV PATH=$PATH:/home/${CODEMAN_RUNTIME_USER}/.local/bin');
});
it('entrypoint.sh pins PATH to the system directories before its first command', () => {
+473
View File
@@ -0,0 +1,473 @@
/**
* @fileoverview File Viewer text view: rendered markdown plus Lines/Wrap toggles.
*
* Clicking a `.md` in the Files panel showed wrapped source with no way to see
* it rendered, although the Response Viewer's marked + DOMPurify pipeline
* (`_renderMarkdown`) was already on the page. The viewer now renders markdown
* through that same pipeline, with an MD toggle back to source, and the
* plain-text view gained Lines and Wrap toggles. Pinned here:
*
* 1. `.md` renders into `.rv-text.file-preview-md[data-i18n-skip]` while the
* pref is on and into a `<pre>` of per-line spans while it is off; the MD
* toggle re-renders WITHOUT a second fetch and persists per device.
* 2. Relative image refs are rebased onto the workspace-confined file-raw
* route under the document's directory and a failed load degrades to alt
* text; relative links become `a.rv-path` for the Response Viewer delegate
* and lose the `target` marked gave them, while fragment and http(s) links
* stay untouched.
* 3. Markdown fetches the route's line ceiling; other text keeps 500.
* 4. Lines/Wrap flip classes on the <pre> and persist, and the text the <pre>
* holds is byte-identical to the file; every toggle is hidden for an image
* and while editing.
* 5. `FILE_PREVIEW_EXTENSIONS` gained avif/ico and still has no `md`
* (in-workspace text keeps the tail viewer, see architecture-invariants).
* 6. A preview opened by attachment id under a bare file name has no
* directory to resolve against, so its relative images degrade to alt text
* and its relative links to plain text instead of landing on the workspace
* root's files; an absolute-path attachment keeps resolving.
* 7. A file renders without chat line breaks (`breaks: false`): a paragraph
* hard-wrapped in the source is one paragraph, while the Response Viewer
* keeps a <br> per newline.
*
* Loaded via `vm` with a jsdom document injected (the technique from
* response-viewer-file-links.test.ts): constants.js + panels-ui.js only, with
* the app.js markdown pipeline stubbed to a fixed fragment, except for rule 7,
* which runs the shipping app.js + vendored marked + DOMPurify end to end.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const publicFile = (name: string) => readFileSync(resolve(PUBLIC, name), 'utf8');
const constantsJs = publicFile('constants.js');
const panelsJs = publicFile('panels-ui.js');
// A real origin: vitest's equality walker reaches the window through a node's
// ownerDocument, and jsdom's localStorage getter throws on an opaque one.
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost/' });
const { document } = dom.window;
/** What the stubbed `_renderMarkdown` hands back: every ref shape the rebase pass must classify. */
const MARKDOWN_HTML =
'<h1>Title</h1><p>x</p>' +
'<img src="img/a.png#gh-dark-mode-only" alt="Alt A">' +
'<img src="https://cdn.example.com/r.png" alt="remote">' +
'<a href="guide/x.md#sec" target="_blank" rel="noopener noreferrer">x</a>' +
'<a href="../CHANGELOG.md" target="_blank" rel="noopener noreferrer">up</a>' +
'<a href="#top">t</a>' +
'<a href="https://e.com" target="_blank" rel="noopener noreferrer">e</a>' +
// marked percent-encodes destinations; a query rides along on GitHub-style refs.
'<img src="my%20image.png" alt="space">' +
'<img src="raw.png?raw=true" alt="raw">' +
'<img src="bad%zz.png" alt="bad">' +
'<img src="/assets/root.png" alt="root">' +
'<img src="//cdn.example.com/p.png" alt="protorel">' +
'<a href="%E5%9B%BE%E7%89%87/%E6%88%AA%E5%9B%BE.md" target="_blank" rel="noopener noreferrer">cjk</a>' +
'<a href="/docs/root.md" target="_blank" rel="noopener noreferrer">rootlink</a>';
const MD_CONTENT = '# Title\n\nx\n';
const TXT_CONTENT = 'one\n\n three\tfour\n';
function jsonResponse(body: unknown) {
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
}
/** Answer file-content like the route does: text as JSON, an image as metadata. */
function fetchStub(url: string) {
// An attachment's by-id raw route answers the bytes themselves.
if (url.includes('/attachments/')) return { ok: true, status: 200, text: async () => MD_CONTENT };
const path = decodeURIComponent(new URL(url, 'http://x').searchParams.get('path') || '');
const ext = path.split('.').pop() || '';
if (ext === 'png') {
return jsonResponse({
success: true,
data: { type: 'image', url: `/file-raw?path=${path}`, size: 5, extension: ext },
});
}
const content = ext === 'md' ? MD_CONTENT : TXT_CONTENT;
if (url.includes('edit=1')) {
return jsonResponse({
success: true,
data: { content, hash: 'h', eol: 'lf', totalLines: 3, size: content.length },
});
}
return jsonResponse({
success: true,
data: { path, content, totalLines: 3, size: content.length, truncated: false, extension: ext, editable: true },
});
}
/** The file-preview overlay's elements, as index.html ships them. */
function mountPreviewDom() {
document.body.innerHTML = `
<div id="filePreviewOverlay"></div><span id="filePreviewTitle"></span>
<button id="filePreviewMdBtn" hidden></button>
<button id="filePreviewLinesBtn" hidden></button>
<button id="filePreviewWrapBtn" hidden></button>
<button id="filePreviewEditBtn" hidden></button>
<button id="filePreviewDetachBtn" hidden></button>
<div id="filePreviewBody"></div><div id="filePreviewFooter"></div>`;
}
function loadApp(prefs: Record<string, string> = {}) {
const store = new Map(Object.entries(prefs));
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const fetchMock = vi.fn(async (url: string) => fetchStub(url));
const context = vm.createContext({
CodemanApp,
console: { ...console, warn: vi.fn(), error: vi.fn() },
localStorage: {
getItem: (k: string) => (store.has(k) ? store.get(k) : null),
setItem: (k: string, v: string) => store.set(k, v),
removeItem: (k: string) => store.delete(k),
},
document,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), open: vi.fn() },
MobileDetection: {},
setTimeout,
clearTimeout,
confirm: () => true,
fetch: fetchMock,
});
vm.runInContext(`${constantsJs}\n${panelsJs}\nglobalThis.__exts = FILE_PREVIEW_EXTENSIONS;`, context, {
filename: 'panels-ui.js',
});
mountPreviewDom();
const app = new CodemanApp();
app.$ = (id: string) => document.getElementById(id);
app._resetFilePreviewEdit = () => {};
app._isExternalPreviewPath = () => false;
app.formatFileSize = (n: number) => `${n} B`;
app.showToast = vi.fn();
app.filePreviewContent = '';
app._renderMarkdown = vi.fn(() => MARKDOWN_HTML);
app._linkifyFilePaths = vi.fn();
app._bindResponseViewerInteractions = vi.fn();
const byId = (id: string) => document.getElementById(id) as HTMLButtonElement;
return {
app,
fetchMock,
store,
body: byId('filePreviewBody'),
exts: (context as { __exts: Set<string> }).__exts,
btn: { md: byId('filePreviewMdBtn'), lines: byId('filePreviewLinesBtn'), wrap: byId('filePreviewWrapBtn') },
};
}
describe('file viewer rendered markdown', () => {
it('renders .md through the shared markdown pipeline, inert to i18n, with the viewer delegate bound', async () => {
const { app, body, btn } = loadApp();
await app.openFilePreview('docs/README.md', 's1');
const doc = body.firstElementChild as HTMLElement;
expect(doc.matches('.rv-text.file-preview-md[data-i18n-skip]')).toBe(true);
expect(doc.querySelector('h1')?.textContent).toBe('Title');
// A file, not a chat message: source newlines inside a paragraph are not breaks.
expect(app._renderMarkdown).toHaveBeenCalledWith(MD_CONTENT, { breaks: false });
// Identity, not deep equality: DOM nodes are compared by reference here.
expect(app._linkifyFilePaths.mock.calls[0][0]).toBe(doc);
expect(app._bindResponseViewerInteractions.mock.calls[0][0]).toBe(body);
// The source stays what Copy copies.
expect(app.filePreviewContent).toBe(MD_CONTENT);
// MD is the only toggle that applies to a rendered document; Edit still offered.
expect(btn.md.hidden).toBe(false);
expect(btn.md.getAttribute('aria-pressed')).toBe('true');
expect(btn.lines.hidden).toBe(true);
expect(btn.wrap.hidden).toBe(true);
expect(document.getElementById('filePreviewEditBtn')!.hidden).toBe(false);
});
it('fetches the route ceiling for markdown and the 500-line cap for other text', async () => {
const { app, fetchMock } = loadApp();
await app.openFilePreview('docs/README.md', 's1');
await app.openFilePreview('notes.txt', 's1');
const urls = fetchMock.mock.calls.map((c) => c[0]);
expect(urls[0]).toContain('lines=10000');
expect(urls[1]).toContain('lines=500');
});
it('rebases relative images and links onto the document directory and leaves the rest alone', async () => {
const { app, body } = loadApp();
await app.openFilePreview('docs/README.md', 's1');
const local = body.querySelector('img[alt="Alt A"]')!;
expect(local.getAttribute('src')).toBe(`/api/sessions/s1/file-raw?path=${encodeURIComponent('docs/img/a.png')}`);
expect(body.querySelector('img[alt="remote"]')!.getAttribute('src')).toBe('https://cdn.example.com/r.png');
const rel = body.querySelector('a.rv-path')!;
expect(rel.getAttribute('data-path')).toBe('docs/guide/x.md');
expect(rel.getAttribute('href')).toBe('#');
expect(rel.hasAttribute('target')).toBe(false);
expect(rel.hasAttribute('rel')).toBe(false);
const anchors = Array.from(body.querySelectorAll('a'));
// `..` is collapsed against the document directory, so the title reads
// CHANGELOG.md rather than docs/../CHANGELOG.md.
const up = anchors.find((a) => a.textContent === 'up')!;
expect(up.classList.contains('rv-path')).toBe(true);
expect(up.getAttribute('data-path')).toBe('CHANGELOG.md');
const fragment = anchors.find((a) => a.textContent === 't')!;
expect(fragment.getAttribute('href')).toBe('#top');
expect(fragment.classList.contains('rv-path')).toBe(false);
const external = anchors.find((a) => a.textContent === 'e')!;
expect(external.getAttribute('href')).toBe('https://e.com');
expect(external.getAttribute('target')).toBe('_blank');
});
it('decodes percent-encoded refs, drops the query, and resolves root-relative refs against the workspace', async () => {
const { app, body } = loadApp();
// An absolute path in the document's prose, linked by the Response
// Viewer's linkifier, which knows nothing of the preview's session.
app._linkifyFilePaths.mockImplementation((root: HTMLElement) => {
const a = root.ownerDocument.createElement('a');
a.className = 'rv-path';
a.dataset.path = '/tmp/out/run.log';
root.appendChild(a);
});
await app.openFilePreview('docs/README.md', 's1');
const src = (alt: string) => body.querySelector(`img[alt="${alt}"]`)!.getAttribute('src');
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
// Decoded once here, encoded once for the route: never `my%2520image.png`.
expect(src('space')).toBe(raw('docs/my image.png'));
expect(src('raw')).toBe(raw('docs/raw.png'));
// A malformed escape keeps the ref as written.
expect(src('bad')).toBe(raw('docs/bad%zz.png'));
// Root-relative is the workspace root, as on GitHub; protocol-relative is remote.
expect(src('root')).toBe(raw('assets/root.png'));
expect(src('protorel')).toBe('//cdn.example.com/p.png');
const anchors = Array.from(body.querySelectorAll('a'));
expect(anchors.find((a) => a.textContent === 'cjk')!.getAttribute('data-path')).toBe('docs/图片/截图.md');
expect(anchors.find((a) => a.textContent === 'rootlink')!.getAttribute('data-path')).toBe('docs/root.md');
// Every rebased link, and every path the linkifier found in the prose,
// names the preview's session, so the delegate opens it in that workspace
// even when another tab is active.
const rebased = body.querySelectorAll('a.rv-path');
expect(rebased.length).toBe(5);
for (const a of rebased) expect(a.getAttribute('data-session-id')).toBe('s1');
});
it('degrades relative refs of an attachment opened by bare file name instead of resolving them in the workspace', async () => {
const { app, body, fetchMock } = loadApp();
// An attachment card passes the registry's bare file name: the document's
// directory is unknown, so `img/a.png` must not become the workspace root's.
await app.openFilePreview('report.md', 's1', 'att-1');
expect(fetchMock.mock.calls[0][0]).toContain('/attachments/att-1/raw');
expect(body.innerHTML).not.toContain('file-raw');
// Relative and root-relative images are their alt text, as a text node.
for (const alt of ['Alt A', 'space', 'raw', 'bad', 'root']) {
expect(body.querySelector(`img[alt="${alt}"]`)).toBeNull();
expect(body.textContent).toContain(alt);
}
// Remote images and links keep today's handling.
expect(body.querySelector('img[alt="remote"]')!.getAttribute('src')).toBe('https://cdn.example.com/r.png');
expect(body.querySelector('img[alt="protorel"]')!.getAttribute('src')).toBe('//cdn.example.com/p.png');
// Relative links are unwrapped to their text; fragment and http(s) links stay.
expect(body.querySelectorAll('a.rv-path')).toHaveLength(0);
const anchors = Array.from(body.querySelectorAll('a')).map((a) => a.textContent);
expect(anchors).toEqual(['t', 'e']);
for (const text of ['x', 'up', 'cjk', 'rootlink']) expect(body.textContent).toContain(text);
});
it('keeps resolving refs of an absolute-path attachment against its own directory', async () => {
const { app, body } = loadApp();
await app.openFilePreview('/tmp/out/report.md', 's1', 'att-2');
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
expect(body.querySelector('img[alt="Alt A"]')!.getAttribute('src')).toBe(raw('/tmp/out/img/a.png'));
const rel = Array.from(body.querySelectorAll('a.rv-path')).find((a) => a.textContent === 'x')!;
expect(rel.getAttribute('data-path')).toBe('/tmp/out/guide/x.md');
expect(rel.getAttribute('data-session-id')).toBe('s1');
});
it('degrades an image that fails to load to its alt text', async () => {
const { app, body } = loadApp();
await app.openFilePreview('docs/README.md', 's1');
const remote = body.querySelector('img[alt="remote"]')!;
remote.dispatchEvent(new dom.window.Event('error'));
expect(body.querySelector('img[alt="remote"]')).toBeNull();
expect(body.textContent).toContain('remote');
});
it('MD toggle flips to per-line source and back without refetching, and persists', async () => {
const { app, body, btn, fetchMock, store } = loadApp();
await app.openFilePreview('docs/README.md', 's1');
app.toggleFilePreviewMd();
const pre = body.firstElementChild as HTMLElement;
expect(pre.matches('pre.file-preview-text')).toBe(true);
expect(pre.querySelectorAll('.fp-line')).toHaveLength(MD_CONTENT.split('\n').length);
expect(pre.textContent).toBe(MD_CONTENT);
expect(store.get('codeman:filePreviewMdRendered')).toBe('0');
expect(btn.md.getAttribute('aria-pressed')).toBe('false');
expect(btn.lines.hidden).toBe(false);
expect(btn.wrap.hidden).toBe(false);
app.toggleFilePreviewMd();
expect((body.firstElementChild as HTMLElement).matches('.file-preview-md')).toBe(true);
expect(store.get('codeman:filePreviewMdRendered')).toBe('1');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('opens as source when the device pref says so', async () => {
const { app, body, btn } = loadApp({ 'codeman:filePreviewMdRendered': '0' });
await app.openFilePreview('docs/README.md', 's1');
expect((body.firstElementChild as HTMLElement).matches('pre.file-preview-text')).toBe(true);
expect(btn.md.hidden).toBe(false);
expect(btn.md.getAttribute('aria-pressed')).toBe('false');
});
});
describe('file viewer Lines and Wrap toggles', () => {
it('flip classes on the <pre>, persist, and never alter the text', async () => {
const { app, body, btn, store } = loadApp();
await app.openFilePreview('notes.txt', 's1');
const pre = body.firstElementChild as HTMLElement;
expect(pre.matches('pre.file-preview-text.wrap:not(.show-lines)')).toBe(true);
expect(pre.textContent).toBe(TXT_CONTENT);
expect(btn.md.hidden).toBe(true);
app.toggleFilePreviewLines();
expect(pre.classList.contains('show-lines')).toBe(true);
expect(store.get('codeman:filePreviewLineNumbers')).toBe('1');
expect(btn.lines.getAttribute('aria-pressed')).toBe('true');
app.toggleFilePreviewWrap();
expect(pre.classList.contains('wrap')).toBe(false);
expect(store.get('codeman:filePreviewWrap')).toBe('0');
expect(btn.wrap.getAttribute('aria-pressed')).toBe('false');
// Same element, no re-render: the counter gutter is CSS, not text.
expect(body.firstElementChild).toBe(pre);
expect(pre.textContent).toBe(TXT_CONTENT);
});
it('are hidden for an image and while editing', async () => {
const { app, btn, body } = loadApp();
await app.openFilePreview('shot.png', 's1');
expect(btn.md.hidden && btn.lines.hidden && btn.wrap.hidden).toBe(true);
await app.openFilePreview('notes.txt', 's1');
expect(btn.lines.hidden).toBe(false);
await app.enterFilePreviewEdit();
expect(body.querySelector('textarea.file-preview-editor')).not.toBeNull();
expect(btn.md.hidden && btn.lines.hidden && btn.wrap.hidden).toBe(true);
});
});
/** A vendored UMD build (or sanitize-html.js), evaluated as CommonJS the way the other suites do. */
function loadCommonJs<T>(name: string): T {
const module: { exports: unknown } = { exports: {} };
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
new Function('module', 'exports', publicFile(name))(module, module.exports);
return module.exports as T;
}
/**
* The SHIPPING pipeline end to end: app.js (`_renderMarkdown` and the Response
* Viewer's message builder) with panels-ui.js mixed in, the vendored marked,
* and DOMPurify behind the real sanitize-html.js config. `content` is what the
* file-content route answers for every path.
*/
function loadShippingApp(content: string) {
const createDOMPurify = loadCommonJs<(win: unknown) => unknown>('vendor/dompurify.min.js');
const { createMarkdownSanitizer } = loadCommonJs<{ createMarkdownSanitizer: (dp: unknown) => unknown }>(
'sanitize-html.js'
);
const context = vm.createContext({
console: { ...console, warn: vi.fn(), error: vi.fn() },
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
document,
NodeFilter: dom.window.NodeFilter,
localStorage: { length: 0, key: vi.fn(), getItem: () => null, setItem: vi.fn(), removeItem: vi.fn() },
// _sanitizeHtml fails closed without the page's sanitizer, which would make
// every assertion below vacuous.
window: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
sanitizeMarkdownHtml: createMarkdownSanitizer(createDOMPurify(dom.window)),
},
marked: loadCommonJs('vendor/marked.min.js'),
MobileDetection: {},
confirm: () => true,
fetch: vi.fn(async () =>
jsonResponse({
success: true,
data: { content, totalLines: 2, size: content.length, truncated: false, extension: 'md' },
})
),
});
vm.runInContext(
`${constantsJs}\n${publicFile('app.js')}\n${panelsJs}\nglobalThis.__CodemanApp = CodemanApp;`,
context,
{ filename: 'app.js' }
);
const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp;
mountPreviewDom();
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
app.$ = (id: string) => document.getElementById(id);
app.sessions = new Map();
app.filePreviewContent = '';
return app;
}
describe('file viewer markdown line breaks', () => {
// A README hard-wrapped at the column limit: one paragraph in the source.
const WRAPPED = 'A paragraph hard-wrapped\nat the column limit.';
it('renders a hard-wrapped paragraph as one paragraph in the file view, while chat keeps a break per newline', async () => {
const app = loadShippingApp(`${WRAPPED}\n`);
await app.openFilePreview('docs/README.md', 's1');
const para = document.querySelector('#filePreviewBody .file-preview-md p')!;
expect(para, 'the document rendered through marked').not.toBeNull();
expect(para.querySelector('br')).toBeNull();
expect(para.textContent).toBe(WRAPPED);
// The Response Viewer renders the same text the chat way, a <br> per newline.
const message = app._buildResponseViewerMessage(WRAPPED, 'assistant', 'Claude') as HTMLElement;
const chatPara = message.querySelector('.rv-text p')!;
expect(chatPara.querySelectorAll('br')).toHaveLength(1);
expect(chatPara.textContent).toBe(WRAPPED.replace('\n', ''));
});
});
describe('FILE_PREVIEW_EXTENSIONS', () => {
it('routes avif and ico paths to the viewer and leaves .md with the tail viewer', () => {
const { exts } = loadApp();
expect(exts.has('avif')).toBe(true);
expect(exts.has('ico')).toBe(true);
expect(exts.has('md')).toBe(false);
});
});
+468
View File
@@ -0,0 +1,468 @@
/**
* @fileoverview The pre-push hook that scripts/postinstall.js installs (scripts/git-hooks.mjs).
*
* Two properties matter more than the hook's contents, because the older pre-commit
* installer gets both wrong and this one must not copy it:
* 1. It is MARKER-OWNED: a hook the developer wrote by hand is never overwritten.
* 2. The hooks directory is resolved through git, since in a worktree `.git` is a FILE
* and `<root>/.git/hooks` does not exist, and it is ONLY ever the repo's own
* `<git-common-dir>/hooks`: a `core.hooksPath` elsewhere (typically a global one) is
* never written to.
*
* ⚠️ Every filesystem/git test here runs against THROWAWAY repositories under a temp dir.
* Never point the installer at this checkout: its hooks directory is shared with every
* worktree of it, including whatever the developer is running right now.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execFileSync, spawnSync } from 'node:child_process';
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import {
PRE_PUSH_CHECKS,
PRE_PUSH_MARKER,
PRE_PUSH_WATCHED_PATHS,
installPrePushHook,
planHookInstall,
renderPrePushHook,
resolveGitHooksDir,
} from '../scripts/git-hooks.mjs';
const repoRoot = resolve(import.meta.dirname, '..');
const read = (rel: string) => readFileSync(resolve(repoRoot, rel), 'utf8');
/** git with no user/system config leaking in (a global core.hooksPath would redirect everything). */
const GIT_ENV = {
...process.env,
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_AUTHOR_NAME: 'test',
GIT_AUTHOR_EMAIL: 'test@example.invalid',
GIT_COMMITTER_NAME: 'test',
GIT_COMMITTER_EMAIL: 'test@example.invalid',
CODEMAN_SKIP_PREPUSH: '',
};
function git(cwd: string, args: string[], env: NodeJS.ProcessEnv = GIT_ENV): string {
return execFileSync('git', args, { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
}
let scratch: string;
beforeAll(() => {
scratch = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-git-hooks-')));
});
afterAll(() => {
rmSync(scratch, { recursive: true, force: true });
});
let counter = 0;
function newRepo(): string {
const dir = join(scratch, `repo-${++counter}`);
mkdirSync(dir, { recursive: true });
git(dir, ['init', '-q', '-b', 'main']);
git(dir, ['commit', '-q', '--allow-empty', '-m', 'init']);
return dir;
}
describe('pre-push hook body', () => {
const hook = renderPrePushHook();
it('carries the ownership marker', () => {
expect(hook).toContain(PRE_PUSH_MARKER);
});
it('runs every configured check through npm, and nothing slow', () => {
for (const args of PRE_PUSH_CHECKS) {
expect(hook).toContain(`run_check ${args.join(' ')}`);
}
expect(hook).toContain('npm run --silent "$@"');
// The whole point of the tier: the minutes-long suites stay out of a per-push hook.
expect(hook).not.toMatch(/\btest:(ci|browser|mobile|perf|all)\b/);
});
it('is POSIX sh', () => {
expect(hook.startsWith('#!/bin/sh\n')).toBe(true);
const r = spawnSync('sh', ['-n'], { input: hook });
expect(r.status).toBe(0);
});
});
describe('pre-push checks match the static CI job', () => {
const scripts = JSON.parse(read('package.json')).scripts as Record<string, string>;
const ci = read('.github/workflows/ci.yml');
it.each(PRE_PUSH_CHECKS.map((args) => [args.join(' ')] as const))('%s is a real script that CI runs', (joined) => {
const [name] = joined.split(' ');
expect(scripts[name], `package.json has no "${name}" script`).toBeTypeOf('string');
expect(ci).toContain(`npm run ${joined}`);
});
});
describe('planHookInstall', () => {
const hook = renderPrePushHook();
it('writes when no hook exists', () => {
expect(planHookInstall({ existing: null, next: hook })).toBe('write');
});
it('refuses to clobber a hook it does not own', () => {
expect(planHookInstall({ existing: '#!/bin/sh\nmake lint\n', next: hook })).toBe('skip-foreign');
});
it('refreshes its own hook when the body changed', () => {
expect(planHookInstall({ existing: `#!/bin/sh\n${PRE_PUSH_MARKER}\necho old\n`, next: hook })).toBe('write');
});
it('is idempotent when already current', () => {
expect(planHookInstall({ existing: hook, next: hook })).toBe('up-to-date');
});
it('treats an empty file as absent rather than foreign', () => {
expect(planHookInstall({ existing: ' \n', next: hook })).toBe('write');
});
});
describe('resolveGitHooksDir (temp repos)', () => {
// resolveGitHooksDir runs git with process.env, so an exported GIT_CONFIG_GLOBAL or a system
// gitconfig carrying core.hooksPath would otherwise redirect every expectation below.
// test/setup.ts swaps HOME, which only covers ~/.gitconfig.
const ambient = {
GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL,
GIT_CONFIG_NOSYSTEM: process.env.GIT_CONFIG_NOSYSTEM,
};
beforeAll(() => {
process.env.GIT_CONFIG_NOSYSTEM = '1';
process.env.GIT_CONFIG_GLOBAL = '/dev/null';
});
afterAll(() => {
for (const [k, v] of Object.entries(ambient)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});
it('resolves <root>/.git/hooks in a plain checkout', () => {
const repo = newRepo();
expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks'));
});
it('resolves the SHARED hooks dir from a worktree, where .git is a file', () => {
const repo = newRepo();
const wt = join(scratch, `wt-${counter}`);
git(repo, ['worktree', 'add', '-q', wt, '-b', 'wt-branch']);
expect(statSync(join(wt, '.git')).isFile()).toBe(true);
expect(resolveGitHooksDir(wt)).toBe(join(repo, '.git', 'hooks'));
});
it('returns null outside any git checkout', () => {
const dir = join(scratch, `plain-${++counter}`);
mkdirSync(dir);
expect(resolveGitHooksDir(dir)).toBeNull();
});
it('returns null when a repo-local core.hooksPath points outside the repo', () => {
const repo = newRepo();
const outside = join(scratch, `shared-hooks-${counter}`);
mkdirSync(outside);
git(repo, ['config', 'core.hooksPath', outside]);
expect(resolveGitHooksDir(repo)).toBeNull();
});
it('returns null when core.hooksPath points at a directory that does not exist yet', () => {
const repo = newRepo();
git(repo, ['config', 'core.hooksPath', join(scratch, `missing-${counter}`, 'hooks')]);
expect(resolveGitHooksDir(repo)).toBeNull();
});
it("still resolves when core.hooksPath points at the repo's OWN .git/hooks", () => {
const repo = newRepo();
git(repo, ['config', 'core.hooksPath', join(repo, '.git', 'hooks')]);
expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks'));
});
it('resolves before .git/hooks exists (compares the would-be path)', () => {
const repo = newRepo();
rmSync(join(repo, '.git', 'hooks'), { recursive: true, force: true });
expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks'));
});
it('returns null under a GLOBAL core.hooksPath, from a checkout and from a worktree', () => {
const repo = newRepo();
const wt = join(scratch, `wt-global-${counter}`);
git(repo, ['worktree', 'add', '-q', wt, '-b', 'wt-global']);
const globalHooks = join(scratch, `global-hooks-${counter}`);
mkdirSync(globalHooks);
const globalConfig = join(scratch, `gitconfig-${counter}`);
writeFileSync(globalConfig, `[core]\n\thooksPath = ${globalHooks}\n`);
// resolveGitHooksDir runs git with the ambient environment, so scope the fake global
// config to this test through process.env (never the developer's real ~/.gitconfig).
const saved = {
GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL,
GIT_CONFIG_NOSYSTEM: process.env.GIT_CONFIG_NOSYSTEM,
};
process.env.GIT_CONFIG_GLOBAL = globalConfig;
process.env.GIT_CONFIG_NOSYSTEM = '1';
try {
expect(git(repo, ['rev-parse', '--git-path', 'hooks'], { ...GIT_ENV, GIT_CONFIG_GLOBAL: globalConfig })).toBe(
globalHooks
);
expect(resolveGitHooksDir(repo)).toBeNull();
expect(resolveGitHooksDir(wt)).toBeNull();
} finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
// Control: the same repo resolves again once the global setting is gone.
expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks'));
});
it("returns null for a copy nested inside someone else's repo (e.g. under node_modules)", () => {
const repo = newRepo();
const nested = join(repo, 'node_modules', 'aicodeman');
mkdirSync(nested, { recursive: true });
expect(resolveGitHooksDir(nested)).toBeNull();
});
});
describe('installPrePushHook (temp repos)', () => {
it('writes an executable hook into a fresh repo', () => {
const hooks = join(newRepo(), '.git', 'hooks');
expect(installPrePushHook(hooks)).toBe('write');
const path = join(hooks, 'pre-push');
expect(readFileSync(path, 'utf8')).toBe(renderPrePushHook());
expect(statSync(path).mode & 0o111).not.toBe(0);
expect(installPrePushHook(hooks)).toBe('up-to-date');
});
it('leaves a foreign pre-push hook byte-identical', () => {
const hooks = join(newRepo(), '.git', 'hooks');
const path = join(hooks, 'pre-push');
const mine = '#!/bin/sh\n# my own hook\nexit 0\n';
writeFileSync(path, mine, { mode: 0o755 });
expect(installPrePushHook(hooks)).toBe('skip-foreign');
expect(readFileSync(path, 'utf8')).toBe(mine);
});
it('refreshes a stale managed hook and keeps it executable', () => {
const hooks = join(newRepo(), '.git', 'hooks');
const path = join(hooks, 'pre-push');
writeFileSync(path, `#!/bin/sh\n${PRE_PUSH_MARKER}\necho old\n`, { mode: 0o644 });
expect(installPrePushHook(hooks)).toBe('write');
expect(readFileSync(path, 'utf8')).toBe(renderPrePushHook());
expect(statSync(path).mode & 0o111).not.toBe(0);
});
});
/**
* Drive the rendered hook through a real `git push` to a local bare remote. The repo gets a
* stub package.json whose check scripts only record that they ran, so this exercises the
* hook's control flow (ref parsing, skips, blocking) without running the real checks.
*/
describe('the installed hook on a real push (temp repos)', () => {
function setup(opts: { failing?: string; nodeModules?: boolean } = {}) {
const repo = newRepo();
const remote = join(scratch, `remote-${counter}.git`);
git(scratch, ['init', '-q', '--bare', remote]);
git(repo, ['remote', 'add', 'origin', remote]);
const log = join(repo, 'ran.log');
const scripts: Record<string, string> = {};
for (const [name] of PRE_PUSH_CHECKS) {
scripts[name] =
name === opts.failing ? `echo ${name} >> ran.log && echo boom-${name} && exit 1` : `echo ${name} >> ran.log`;
}
writeFileSync(join(repo, 'package.json'), JSON.stringify({ name: 'hook-fixture', private: true, scripts }));
writeFileSync(join(repo, '.gitignore'), 'node_modules/\nran.log\n');
git(repo, ['add', 'package.json', '.gitignore']);
git(repo, ['commit', '-q', '-m', 'fixture']);
if (opts.nodeModules !== false) mkdirSync(join(repo, 'node_modules'));
installPrePushHook(join(repo, '.git', 'hooks'));
chmodSync(join(repo, '.git', 'hooks', 'pre-push'), 0o755);
const ran = () => {
try {
return readFileSync(log, 'utf8').trim().split('\n').filter(Boolean);
} catch {
return [];
}
};
const push = (args: string[], env: NodeJS.ProcessEnv = {}) =>
spawnSync('git', ['push', ...args], { cwd: repo, env: { ...GIT_ENV, ...env }, encoding: 'utf8' });
return { repo, remote, ran, push };
}
/** What the stubs record: npm appends the args after `--` to the script, so they prove forwarding. */
const expectedRuns = PRE_PUSH_CHECKS.map((args) => args.filter((a) => a !== '--').join(' '));
it('runs every check before a push, in order', () => {
const { ran, push } = setup();
const r = push(['-q', 'origin', 'main']);
expect(r.status, r.stderr + r.stdout).toBe(0);
expect(ran()).toEqual(expectedRuns);
});
it('blocks the push when a check fails, but still runs the rest', () => {
const { ran, push, remote } = setup({ failing: 'lint' });
const r = push(['origin', 'main']);
expect(r.status).not.toBe(0);
expect(r.stdout + r.stderr).toContain('pre-push: FAILED npm run lint');
expect(r.stdout + r.stderr).toContain('boom-lint');
expect(ran()).toEqual(expectedRuns);
expect(spawnSync('git', ['rev-parse', '--verify', '-q', 'refs/heads/main'], { cwd: remote }).status).not.toBe(0);
});
it('CODEMAN_SKIP_PREPUSH=1 skips every check', () => {
const { ran, push } = setup({ failing: 'lint' });
const r = push(['-q', 'origin', 'main'], { CODEMAN_SKIP_PREPUSH: '1' });
expect(r.status, r.stderr).toBe(0);
expect(ran()).toEqual([]);
});
it('a delete-only push skips the checks', () => {
const { ran, push, repo } = setup({ failing: 'lint' });
expect(push(['-q', 'origin', 'main'], { CODEMAN_SKIP_PREPUSH: '1' }).status).toBe(0);
git(repo, ['branch', 'doomed']);
expect(push(['-q', 'origin', 'doomed'], { CODEMAN_SKIP_PREPUSH: '1' }).status).toBe(0);
const r = push(['-q', 'origin', '--delete', 'doomed']);
expect(r.status, r.stderr).toBe(0);
expect(ran()).toEqual([]);
});
it('skips when the pushed ref is not the checked-out HEAD', () => {
const { ran, push, repo } = setup({ failing: 'lint' });
git(repo, ['branch', 'other']);
git(repo, ['commit', '-q', '--allow-empty', '-m', 'only on main']);
git(repo, ['checkout', '-q', 'other']);
// HEAD is `other`; pushing `main` would check a working tree that is not main's.
const r = push(['origin', 'main']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout + r.stderr).toContain(
'pre-push: skipping static checks: refs/heads/main is not the checked-out HEAD'
);
expect(ran()).toEqual([]);
});
it('skips when any one of several pushed refs is not HEAD', () => {
const { ran, push, repo } = setup({ failing: 'lint' });
git(repo, ['branch', 'behind']);
git(repo, ['commit', '-q', '--allow-empty', '-m', 'ahead']);
const r = push(['origin', 'main', 'behind']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout + r.stderr).toContain('is not the checked-out HEAD');
expect(ran()).toEqual([]);
});
it('still checks an annotated tag that points at HEAD (the tag is peeled)', () => {
const { ran, push, repo } = setup();
git(repo, ['tag', '-a', 'v1', '-m', 'v1']);
const r = push(['-q', 'origin', 'v1']);
expect(r.status, r.stderr + r.stdout).toBe(0);
expect(ran()).toEqual(expectedRuns);
});
it.each([
'src/wip.ts',
'config/wip.json',
'scripts/wip.mjs',
'test/wip.test.ts',
'install.sh',
'tsconfig.json',
'.prettierignore',
'.editorconfig',
])('skips when %s is untracked (another session may own it)', (rel) => {
const { ran, push, repo } = setup({ failing: 'lint' });
mkdirSync(join(repo, rel, '..'), { recursive: true });
writeFileSync(join(repo, rel), 'wip\n');
const r = push(['origin', 'main']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout + r.stderr).toContain('pre-push: skipping static checks: uncommitted changes under');
expect(ran()).toEqual([]);
});
it('skips when a tracked package.json has an unstaged edit', () => {
const { ran, push, repo } = setup({ failing: 'lint' });
const pkg = join(repo, 'package.json');
writeFileSync(pkg, readFileSync(pkg, 'utf8') + '\n');
const r = push(['origin', 'main']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout + r.stderr).toContain('uncommitted changes under');
expect(ran()).toEqual([]);
});
it('still checks when the only uncommitted changes are outside the watched paths', () => {
const { ran, push, repo } = setup({ failing: 'lint' });
mkdirSync(join(repo, 'docs'));
writeFileSync(join(repo, 'docs', 'notes.md'), 'draft\n');
writeFileSync(join(repo, 'README.md'), 'draft\n');
const r = push(['origin', 'main']);
expect(r.status).not.toBe(0);
expect(r.stdout + r.stderr).toContain('pre-push: FAILED npm run lint');
expect(ran()).toEqual(expectedRuns);
});
it('watches exactly the paths the checks read', () => {
expect(PRE_PUSH_WATCHED_PATHS).toEqual([
'src',
'config',
'scripts',
'test',
'package.json',
'package-lock.json',
'install.sh',
'tsconfig.json',
'.prettierignore',
'.editorconfig',
]);
});
it('skips (never blocks) when node_modules is absent', () => {
const { ran, push } = setup({ failing: 'lint', nodeModules: false });
const r = push(['origin', 'main']);
expect(r.status, r.stderr).toBe(0);
expect(r.stdout + r.stderr).toContain('node_modules missing');
expect(ran()).toEqual([]);
});
it('skips (never blocks) when npm is not on PATH, as under a GUI git client', () => {
const { ran, push } = setup({ failing: 'lint' });
// A PATH holding only what git and the hook need, and no npm/node. Symlinks rather than
// the real directories, since /usr/bin usually holds npm right next to git.
const bin = join(scratch, `bin-${counter}`);
mkdirSync(bin);
for (const tool of ['git', 'sh', 'mktemp', 'tail', 'rm', 'cat']) {
const found = spawnSync('sh', ['-c', `command -v ${tool}`], { encoding: 'utf8' }).stdout.trim();
expect(found, `${tool} not found on the test PATH`).toMatch(/^\//);
symlinkSync(found, join(bin, tool));
}
expect(spawnSync('sh', ['-c', 'command -v npm'], { env: { PATH: bin } }).status).not.toBe(0);
const r = push(['origin', 'main'], { PATH: bin });
expect(r.status, r.stderr + r.stdout).toBe(0);
expect(r.stdout + r.stderr).toContain('pre-push: npm not on PATH, skipping checks.');
expect(ran()).toEqual([]);
});
});
describe('postinstall wiring', () => {
const postinstall = read('scripts/postinstall.js');
it('installs the pre-push hook through the shared module', () => {
expect(postinstall).toContain("import('./git-hooks.mjs')");
expect(postinstall).toContain('installPrePushHook(gitHooksDir)');
});
it('resolves the hooks dir through git, so worktrees work', () => {
expect(postinstall).toContain('resolveGitHooksDir(');
expect(postinstall).not.toContain("join(import.meta.dirname, '..', '.git', 'hooks')");
});
});
+6 -3
View File
@@ -122,7 +122,7 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
expect(app).not.toContain('earlier output truncated for performance');
});
it('loads a bounded shell tail first and keeps full history user-triggered', () => {
it('loads a bounded shell tail first and keeps unbounded full history user-triggered', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
@@ -131,10 +131,13 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
// an abort deadline (a `?full=1` body can be megabytes and used to hang
// indefinitely on a stalled mobile link). The URL and the full-vs-tail
// decision this guard exists to pin are unchanged.
expect(app).toContain('this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true })');
expect(app).toContain(': `/api/sessions/${sessionId}/terminal?full=1`,\n { full: true }');
expect(app).toContain("if (this.sessions.get(sessionId)?.mode !== 'shell')");
expect(app).toContain("if (session?.mode === 'shell')");
expect(app).toContain("if (!force && session?.mode === 'shell') return;");
// A shell scroll gesture pulls a BOUNDED window of full history; only the
// button pulls all of it (behaviour pinned in shell-scroll-history-pull.test.ts).
expect(app).toContain("const boundedShellPull = !force && session?.mode === 'shell';");
expect(app).toContain('`/api/sessions/${sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`');
expect(app).toContain("trigger: force ? 'full-history-button' : 'full-history-scroll'");
});
+10
View File
@@ -165,6 +165,16 @@ describe('COD-56 markdown sanitizer (DOMPurify allowlist)', () => {
expect(sanitize(html).toLowerCase()).not.toContain(tag);
});
}
// DOM clobbering: <img name="app"> makes document.app that image, and inline
// onclick="app.…()" handlers resolve `app` on the document before the global,
// so a rendered README could break every button until a reload.
it('drops name= (marked never emits it; it clobbers document.<name>)', () => {
const out = sanitize('<img name="app" src="https://example.com/x.png" alt="x"><a name="app" href="#a">a</a>');
expect(out).not.toMatch(/\sname\s*=/i);
expect(out).toContain('src="https://example.com/x.png"');
expect(out).toContain('href="#a"');
});
});
describe('legitimate markdown-rendered HTML survives', () => {
+166
View File
@@ -0,0 +1,166 @@
/**
* @fileoverview The phone header's tab strip must read as live tabs.
*
* It used to render every inactive tab transparent: grey 11px text floating in
* unmarked gaps, a boxed Alt+N digit in each (a phone has no Alt key), names cut
* to 50px so a shared `w1-` prefix was most of what showed, and the tab that did
* not fit chopped mid-word against the connection dot. On a phone it looked like
* a row of disabled labels.
*
* The fix is four small rules in the phone block of mobile.css, and each has a
* way to be silently undone, which is what this file fences:
*
* - The chip rule is written `:where(.header) .session-tab` so it stays at
* (0,1,0). Written `.header .session-tab` it would be (0,2,0), tie with the
* per-colour `.session-tab[data-color="red"]` left border in styles.css, and
* win on source order (mobile.css loads later): every colour-tagged tab would
* lose its identity stripe.
* - The edge fade is scroll-DRIVEN (no JS). Its two widths must be registered
* with @property to interpolate, and @property is only valid at the top
* level: nested inside the phone @media it is dropped, the keyframes stop
* interpolating, and the fade snaps between states instead of following the
* scroll position.
* - `animation` is a shorthand that resets `animation-timeline`, so the
* timeline must be declared AFTER it or the fade silently becomes a 0s time
* animation.
*
* Parsed with postcss because the declarations live in nested at-rules. The
* rendered result (chips on dark and light skins, the fade at both scroll ends)
* was checked in a browser; this is the cheap regression fence. Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import postcss, { type AtRule, type Declaration, type Rule } from 'postcss';
import { describe, expect, it } from 'vitest';
const CSS = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile.css'), 'utf8');
const ROOT = postcss.parse(CSS);
const PHONE_QUERY = '(max-width: 599px)';
/** Declarations of the rule matching `selector` inside the phone block (later rules win). */
function phoneDeclarations(selector: string): Record<string, string> {
const found: Record<string, string> = {};
ROOT.walkAtRules('media', (atRule) => {
if (atRule.params !== PHONE_QUERY) return;
atRule.walkRules((rule: Rule) => {
if (!rule.selectors.map((s) => s.trim()).includes(selector)) return;
rule.walkDecls((decl: Declaration) => {
found[decl.prop] = decl.value.trim();
});
});
});
return found;
}
/** The `@property` rule for `name`, wherever it sits. */
function propertyRule(name: string): AtRule | undefined {
let hit: AtRule | undefined;
ROOT.walkAtRules('property', (atRule) => {
if (atRule.params.trim() === name) hit = atRule;
});
return hit;
}
function declsOf(node: AtRule | Rule): Record<string, string> {
const out: Record<string, string> = {};
node.each((child) => {
if (child.type === 'decl') out[child.prop] = child.value.trim();
});
return out;
}
describe('phone header tab strip', () => {
describe('chips', () => {
const chip = phoneDeclarations(':where(.header) .session-tab');
it('gives every header tab a fill and border from the skin control tokens', () => {
// Tokens, not literals: the four light skins repaint the header with
// --glass-bg and define their own --control-* values.
expect(chip.background).toMatch(/^var\(--control-bg/);
expect(chip['border-color']).toMatch(/^var\(--control-border/);
expect(chip.color).toBe('var(--text)');
});
it('keeps the chip selector at (0,1,0) so per-colour borders still win', () => {
// The lookup above only matches the exact `:where(.header)` spelling, so a
// rewrite to `.header .session-tab` leaves it empty and fails here.
expect(Object.keys(chip).length).toBeGreaterThan(0);
expect(phoneDeclarations('.header .session-tab')).toEqual({});
});
it('hides the Alt+N digit, which a phone has no key for', () => {
expect(phoneDeclarations(':where(.header) .session-tab .tab-number').display).toBe('none');
});
it('drops the empty action container on inactive tabs only', () => {
// The active tab's gear and close live in .tab-actions, so the rule must
// stay scoped to :not(.active).
expect(phoneDeclarations(':where(.header) .session-tab:not(.active) .tab-actions').display).toBe('none');
expect(phoneDeclarations(':where(.header) .session-tab .tab-actions')).toEqual({});
});
it('leaves enough name to get past a shared w1- prefix', () => {
const maxWidth = Number.parseFloat(phoneDeclarations('.session-tab .tab-name')['max-width'] ?? '');
expect(maxWidth).toBeGreaterThanOrEqual(72);
});
});
describe('scroll-driven edge fade', () => {
it('registers both fade widths at the top level, as lengths starting at 0px', () => {
for (const name of ['--tab-strip-fade-start', '--tab-strip-fade-end']) {
const rule = propertyRule(name);
expect(rule, `${name} is not registered`).toBeDefined();
// Nested in @media it is invalid and silently ignored.
expect(rule!.parent?.type, `${name} must be top level`).toBe('root');
const d = declsOf(rule!);
expect(d.syntax).toBe("'<length>'");
expect(d['initial-value']).toBe('0px');
}
});
it('fades only the far edge at the start and only the near edge at the end', () => {
let frames: Record<string, Record<string, string>> = {};
ROOT.walkAtRules('keyframes', (atRule) => {
if (atRule.params.trim() !== 'tab-strip-edge-fade') return;
frames = {};
atRule.each((node) => {
if (node.type !== 'rule') return;
for (const sel of node.selectors) frames[sel.trim()] = declsOf(node);
});
});
expect(frames['0%']?.['--tab-strip-fade-start']).toBe('0px');
expect(Number.parseFloat(frames['0%']?.['--tab-strip-fade-end'] ?? '0')).toBeGreaterThan(0);
expect(frames['100%']?.['--tab-strip-fade-end']).toBe('0px');
expect(Number.parseFloat(frames['100%']?.['--tab-strip-fade-start'] ?? '0')).toBeGreaterThan(0);
});
it('masks the header strip behind a scroll-timeline feature check, timeline after the shorthand', () => {
let strip: Rule | undefined;
ROOT.walkAtRules('media', (media) => {
if (media.params !== PHONE_QUERY) return;
media.walkAtRules('supports', (supports) => {
if (!/animation-timeline:\s*scroll\(\)/.test(supports.params)) return;
supports.walkRules((rule) => {
if (rule.selectors.map((s) => s.trim()).includes('.header .session-tabs')) strip = rule;
});
});
});
expect(strip, 'no @supports-gated .header .session-tabs rule in the phone block').toBeDefined();
const props: string[] = [];
const d: Record<string, string> = {};
strip!.each((node) => {
if (node.type !== 'decl') return;
props.push(node.prop);
d[node.prop] = node.value.replace(/\s+/g, ' ').trim();
});
for (const prop of ['mask-image', '-webkit-mask-image']) {
expect(d[prop]).toContain('var(--tab-strip-fade-start)');
expect(d[prop]).toContain('var(--tab-strip-fade-end)');
}
expect(d.animation).toContain('tab-strip-edge-fade');
expect(d['animation-timeline']).toBe('scroll(self inline)');
expect(props.indexOf('animation-timeline')).toBeGreaterThan(props.indexOf('animation'));
});
});
});
+4 -3
View File
@@ -88,9 +88,10 @@ describe('Tab Navigation', () => {
if (tabNameExists) {
const maxWidth = await getCSSProperty(page, SELECTORS.TAB_NAME, 'max-width');
const maxWidthPx = parseFloat(maxWidth);
// Should be 50px on mobile
expect(maxWidthPx).toBeLessThanOrEqual(60);
expect(maxWidthPx).toBeGreaterThan(0);
// 80px on phones: wide enough to get past a shared `w1-` prefix,
// still short enough that several tabs fit the strip.
expect(maxWidthPx).toBeLessThanOrEqual(96);
expect(maxWidthPx).toBeGreaterThanOrEqual(72);
}
});
+1 -1
View File
@@ -145,6 +145,6 @@ describe('response viewer file-path linkifier', () => {
// either leaves inert paths (no linkify) or dead links (no handler).
expect(APP_SOURCE).toContain('this._linkifyFilePaths(renderedText)');
expect(APP_SOURCE).toMatch(/closest\('a\.rv-path'\)/);
expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, this\.activeSessionId\)/);
expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, pathLink\.dataset\.sessionId \|\| this\.activeSessionId\)/);
});
});
+6
View File
@@ -713,6 +713,12 @@ describe('registry writes are serialized and never clobber a file the reader wou
}
expect(installEnv({ CODEMAN_PASSWORD: 'x', HOME: '/h' })).toEqual({ HOME: '/h' });
});
it('redirects npm installs to the persistent HOME inside the Compose container', () => {
expect(
installEnv({ CODEMAN_IN_CONTAINER: '1', HOME: '/home/codeman', NPM_CONFIG_PREFIX: '/opt/codeman-cli' })
).toEqual({ HOME: '/home/codeman', NPM_CONFIG_PREFIX: '/home/codeman/.local' });
});
});
/**
+25
View File
@@ -681,6 +681,18 @@ describe('file-routes', () => {
expect(body.data.url).toContain('file-raw');
});
it('classifies avif as an image so the viewer renders it instead of dumping bytes', async () => {
mockedStat.mockResolvedValue({ size: 1024 } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=photo.avif`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.type).toBe('image');
});
it('returns audio metadata for audio files', async () => {
mockedStat.mockResolvedValue({ size: 2048 } as never);
@@ -841,6 +853,19 @@ describe('file-routes', () => {
expect(res.headers['content-type']).toBe('image/png');
});
it('serves avif with its image type, since <img> refuses an octet-stream', async () => {
const content = Buffer.from('fake avif data');
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
mockedStat.mockResolvedValue({ size: content.length } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=photo.avif`,
});
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toBe('image/avif');
});
it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => {
const content = Buffer.from('<svg><script>alert("xss")</script></svg>');
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
+105 -1
View File
@@ -14,11 +14,12 @@
import fastifyCookie from '@fastify/cookie';
import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Session } from '../../src/session.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { isPlainPromptInput } from '../../src/web/route-helpers.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
@@ -155,3 +156,106 @@ describe('POST /api/sessions/:id/input rollback wiring', () => {
expect(session.shouldApplyInput('c2', 5)).toBe(true);
});
});
/**
* A plain prompt goes through the mux even when the caller did not say `useMux`.
*
* Measured on Claude Code 2.1.283: a direct write of `<text>\r` arrives as one burst,
* a burst of about a hundred characters is taken as a paste, and its `\r` lands as a
* newline in the composer, so a script's prompt sat there unsent while the route
* answered 200. The mux path types the text, presses Enter on its own, and arms the
* submit verifier.
*/
describe('POST /api/sessions/:id/input plain-prompt routing', () => {
let harness: { app: FastifyInstance; ctx: MockRouteContext };
beforeEach(async () => {
harness = await createEnvelopeHarness();
});
afterEach(async () => {
await harness.app.close();
});
const post = (body: Record<string, unknown>) =>
harness.app.inject({ method: 'POST', url: '/api/sessions/test-session-1/input', payload: body });
const spies = () => {
const session = harness.ctx.sessions.get('test-session-1')!;
return { session, viaMux: vi.spyOn(session, 'writeViaMux'), direct: vi.spyOn(session, 'write') };
};
const LONG_PROMPT =
'Reply with only the word ok and nothing else, this sentence is padding to reach about one hundred chars.\r';
it('sends a prompt with no useMux through the mux, not as one burst', async () => {
const { viaMux, direct } = spies();
const res = await post({ input: LONG_PROMPT });
expect(res.statusCode).toBe(200);
expect(viaMux).toHaveBeenCalledWith(LONG_PROMPT, { fromUser: true });
expect(direct).not.toHaveBeenCalled();
});
it('answers only once the mux write is done, so the next frame cannot overtake it', async () => {
// The browser's POST fallback sends frames one at a time and waits for each 2xx;
// a fire-and-forget write here would let its next keystroke land before the Enter.
const { viaMux } = spies();
let finished = false;
viaMux.mockImplementation(async () => {
await new Promise((r) => setTimeout(r, 30));
finished = true;
return true;
});
await post({ input: 'ok\r', clientId: 'browser-1', seq: 1 });
expect(finished).toBe(true);
});
it('falls back to the direct write when the mux write fails', async () => {
const { viaMux, direct } = spies();
viaMux.mockResolvedValue(false);
await post({ input: 'hello\r' });
expect(direct).toHaveBeenCalledWith('hello\r', { fromUser: true });
});
it('keeps the raw write for an explicit useMux: false', async () => {
const { viaMux, direct } = spies();
await post({ input: LONG_PROMPT, useMux: false });
expect(direct).toHaveBeenCalledWith(LONG_PROMPT, { fromUser: true });
expect(viaMux).not.toHaveBeenCalled();
});
it.each([
['a bare Enter', '\r'],
['text with no Enter', 'hello'],
['an arrow key', '\x1b[A'],
['a bracketed paste frame', '\x1b[200~line one\nline two\x1b[201~'],
['a line feed inside', 'line one\nline two\r'],
['two Enters', 'hello\r\r'],
['a tab', 'a\tb\r'],
])('leaves %s on the direct write', async (_label, input) => {
const { viaMux, direct } = spies();
await post({ input });
expect(direct).toHaveBeenCalledWith(input, { fromUser: true });
expect(viaMux).not.toHaveBeenCalled();
});
});
describe('isPlainPromptInput', () => {
it('accepts printable text ending in exactly one carriage return', () => {
expect(isPlainPromptInput('run the tests\r')).toBe(true);
expect(isPlainPromptInput('ünïcødé and emoji 🚀\r')).toBe(true);
});
it('refuses anything carrying another control character', () => {
for (const input of ['\r', 'x', 'x\n', 'x\r\n', 'x\r\r', '\x1b[Ax\r', 'a\tb\r', 'x\x7f\r', 'x\u009b\r', '\rx']) {
expect(isPlainPromptInput(input), JSON.stringify(input)).toBe(false);
}
});
});
+47
View File
@@ -920,6 +920,53 @@ describe('session-routes', () => {
expect(res.headers['server-timing']).toMatch(/^capture;dur=\d+\.\d, prepare;dur=\d+\.\d, total;dur=\d+\.\d$/);
});
it('full reload with a tail (?full=1&tail=) cuts the full capture to its newest bytes, cursor restore intact', async () => {
// A Shell scroll-to-top asks for exactly this (`_maybeRefetchFullHistory`):
// tmux's whole scrollback, bounded to the tab-switch tail size. The client
// relies on all three answers below, so a refactor that dropped the tail on
// a full capture (an unbounded pull from an ordinary scroll) or cut off the
// closing cursor move (a caret parked below the prompt) must fail here.
const tail = 1024 * 1024;
const oldestMarker = 'BOUNDED_OLDEST_LINE_00001';
const newestMarker = 'BOUNDED_NEWEST_LINE_40000';
const rows: string[] = [oldestMarker];
for (let i = 2; i < 40_000; i++) rows.push(`shell history line ${String(i).padStart(5, '0')} lorem ipsum`);
rows.push(newestMarker);
// What formatCursorRestore appends: up from the last row, then the column.
const cursorRestore = '\x1b[3A\r\x1b[2C';
const fullHistoryCapture = `${rows.join('\r\n')}${cursorRestore}`;
expect(fullHistoryCapture.length).toBeGreaterThan(tail);
harness.ctx._session.mode = 'shell';
harness.ctx._session.terminalBuffer = '';
const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) =>
opts?.fullHistory ? fullHistoryCapture : 'only the visible frame'
);
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1&tail=${tail}`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// Still the scrollback, not the visible frame a plain `?tail=` gets.
expect(captureSpy).toHaveBeenCalledWith(
harness.ctx._session.muxName,
expect.objectContaining({ fullHistory: true })
);
expect(body.data.source).toBe('mux-full-history');
// Recoverable, not 'capped': Load full history can still bring the rest back.
expect(body.data.truncated).toBe(true);
expect(body.data.truncationReason).toBe('tail');
expect(body.data.fullSize).toBe(fullHistoryCapture.length);
expect(body.data.terminalBuffer.length).toBeLessThanOrEqual(tail);
expect(body.data.terminalBuffer).toContain(newestMarker);
expect(body.data.terminalBuffer).not.toContain(oldestMarker);
expect(body.data.terminalBuffer.endsWith(`${newestMarker}${cursorRestore}`)).toBe(true);
});
it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => {
// The full-history capture is the rendered form of everything already in
// the byte buffer; prepending the byte history would replay the whole
+233
View File
@@ -0,0 +1,233 @@
/**
* A session whose turn ended waiting for workers it started counts as working.
*
* The bug this pins: when Claude hands work to background agents or an ultracode
* workflow, it ends its turn and closes it with `✻ Waiting for 1 dynamic workflow to
* finish` instead of `✻ Brewed for 1m 18s`. The pane goes quiet with the composer up, so
* every other signal called the session idle while it was plainly busy, and it resumes
* on its own the moment the workers report back.
*
* The chrome rows below (the closing row, the right-aligned hint, the composer rules, the
* footer, the workflow progress row) are verbatim from a live Claude Code 2.1.283 pane on
* 2026-09-28 (`tmux -L codeman capture-pane -p`, 64 columns). The prose and the names are
* invented.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
import { isAwaitingWorkers, AWAITING_SEARCH_ROWS, IDLE_SILENCE_MS } from '../src/session-activity.js';
/** The registry's own pattern, which is what every consumer runs. */
const CLAUDE_AWAITING = compileVersionRegex(getCli('claude')!.capabilities.workDetect!.awaitingLine!)!;
const RULE = '────────────────────────────────────────────────────────────────';
const NAMED_RULE = '──────────────────────────────────────────────────── w1-demo ─';
/** Everything Claude draws from the composer down while a workflow runs. */
const COMPOSER_AND_FOOTER = [
NAMED_RULE,
'❯ sounds good, go ahead',
RULE,
' Opus 5.5 (1M context) in:285,618 out:581 ctx:29%',
' ⏵⏵ bypass permissions on (shift+tab to cycle) · ← 2 agents',
'',
' ◯ docs-research ▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ ↓ 1.1m',
];
/** A pane whose newest turn closed with `closing`, then the optional hint row. */
function frame(closing: string, { hint = true, body = [] as string[] } = {}): string {
return [
'⏺ The research is running now: three tracks, each checked by',
' a second agent.',
'',
' When it is done I will rewrite the plan.',
'',
...body,
closing,
...(hint ? [' 286199 tokens'] : []),
...COMPOSER_AND_FOOTER,
'',
].join('\n');
}
const WAITING_WORKFLOW = frame('✻ Waiting for 1 dynamic workflow to finish');
const WAITING_BOTH = frame('✻ Waiting for 2 background agents and 1 dynamic workflow to finish');
const WAITING_AGENT = frame('✻ Waiting for 1 background agent to finish', { hint: false });
const DONE = frame('✻ Brewed for 1m 18s · done 3:04 PM');
/**
* The same screen after the workers reported back and the follow-up turn ended. The
* waiting row is a snapshot Claude never redraws, so it is STILL on screen, just no longer
* the newest row.
*/
const FOLLOW_UP_DONE = frame('✻ Cooked for 12s', {
body: [
'✻ Waiting for 1 dynamic workflow to finish',
'',
'⏺ All three tracks are back. The plan is rewritten and pushed.',
'',
],
});
describe('isAwaitingWorkers', () => {
it('reads the closing row of a turn that handed off to a workflow', () => {
expect(isAwaitingWorkers(WAITING_WORKFLOW, CLAUDE_AWAITING, '❯')).toBe(true);
});
it('reads every form Claude builds the row in', () => {
expect(isAwaitingWorkers(WAITING_BOTH, CLAUDE_AWAITING, '❯')).toBe(true);
expect(isAwaitingWorkers(WAITING_AGENT, CLAUDE_AWAITING, '❯')).toBe(true);
expect(isAwaitingWorkers(frame('✻ Waiting for 3 background agents to finish'), CLAUDE_AWAITING, '❯')).toBe(true);
expect(isAwaitingWorkers(frame('✻ Waiting for 2 dynamic workflows to finish'), CLAUDE_AWAITING, '❯')).toBe(true);
});
it('leaves an ordinary turn end alone', () => {
expect(isAwaitingWorkers(DONE, CLAUDE_AWAITING, '❯')).toBe(false);
});
it('ignores a stale waiting row once a newer turn has closed below it', () => {
// The trap the whole positional walk exists for: matching the words anywhere on the
// screen would pin the session busy until they scrolled away.
expect(FOLLOW_UP_DONE).toContain('Waiting for 1 dynamic workflow to finish');
expect(isAwaitingWorkers(FOLLOW_UP_DONE, CLAUDE_AWAITING, '❯')).toBe(false);
});
it('refuses the words when the agent wrote them', () => {
// Claude's own rows start in column 0; the agent's prose sits behind `⏺ ` or is
// indented, so an agent cannot keep itself busy by printing the sentence.
expect(isAwaitingWorkers(frame('⏺ ✻ Waiting for 1 background agent to finish'), CLAUDE_AWAITING, '❯')).toBe(false);
expect(isAwaitingWorkers(frame(' ✻ Waiting for 1 background agent to finish'), CLAUDE_AWAITING, '❯')).toBe(false);
});
it('says nothing about a screen with no composer on it', () => {
const noComposer = WAITING_WORKFLOW.replace('❯ sounds good, go ahead', ' 1. Yes 2. No');
expect(isAwaitingWorkers(noComposer, CLAUDE_AWAITING, '❯')).toBe(false);
expect(isAwaitingWorkers('', CLAUDE_AWAITING, '❯')).toBe(false);
expect(isAwaitingWorkers(null, CLAUDE_AWAITING, '❯')).toBe(false);
});
it('finds the composer in the boxed layout too', () => {
const boxed = [
'✻ Waiting for 1 dynamic workflow to finish',
'╭──────────────────────────────────────╮',
'│ ❯ │',
'╰──────────────────────────────────────╯',
' ⏵⏵ bypass permissions on · ← 1 agent',
].join('\n');
expect(isAwaitingWorkers(boxed, CLAUDE_AWAITING, '❯')).toBe(true);
});
it('reads a coloured capture', () => {
const coloured = WAITING_WORKFLOW.replace(
'✻ Waiting for 1 dynamic workflow to finish',
'\u001b[2m✻\u001b[0m \u001b[2mWaiting for \u001b[1m1\u001b[22m dynamic workflow to finish\u001b[0m'
);
expect(isAwaitingWorkers(coloured, CLAUDE_AWAITING, '❯')).toBe(true);
});
it('stops looking a few rows above the composer', () => {
const farAway = [
'✻ Waiting for 1 dynamic workflow to finish',
...Array.from({ length: AWAITING_SEARCH_ROWS }, () => ''),
...COMPOSER_AND_FOOTER,
].join('\n');
expect(isAwaitingWorkers(farAway, CLAUDE_AWAITING, '❯')).toBe(false);
});
it('survives a pattern handed to it with the global flag set', () => {
const global = new RegExp(CLAUDE_AWAITING.source, 'g');
expect(isAwaitingWorkers(WAITING_WORKFLOW, global, '❯')).toBe(true);
expect(isAwaitingWorkers(WAITING_WORKFLOW, global, '❯')).toBe(true);
});
});
/** A composer repaint: the frame Claude ships roughly once a second while working. */
const COMPOSER_REPAINT =
'\x1b[31;1H\x1b[38;5;246m❯\xa0\x1b[39m\x1b[0m\x1b[33;1H \x1b[38;5;246mOpus 5 in:143,699 out:669 ctx:14%\x1b[39m';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
};
function feed(session: Session, data: string): void {
const internals = session as unknown as SessionInternals;
internals._handleTerminalOutput(data);
internals._detectInteractiveActivity(data);
}
/** A session whose mux reports a scripted screen for the pane probe to read. */
function withFakePane(read: () => string, mode: 'claude' | 'codex' = 'claude'): Session {
const mux = {
isAvailable: () => true,
capturePaneText: () => read(),
} as unknown as NonNullable<ConstructorParameters<typeof Session>[0]>['mux'];
return new Session({
workingDir: '/tmp',
mode,
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as ConstructorParameters<typeof Session>[0]);
}
/** Run one turn and let it end, which is when the probe reads the screen. */
function runAndSettle(session: Session, repaint: string = COMPOSER_REPAINT): void {
for (let i = 0; i < 3; i++) {
feed(session, repaint);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
}
describe('Session status while its workers run', () => {
afterEach(() => {
vi.useRealTimers();
});
it('stays working when the turn ends waiting for a workflow', () => {
vi.useFakeTimers();
const session = withFakePane(() => WAITING_WORKFLOW);
runAndSettle(session);
expect(session.status).toBe('busy');
expect(session.isWorking).toBe(true);
});
it('goes idle once the follow-up turn closes, although the old row is still on screen', () => {
vi.useFakeTimers();
const screen = { text: WAITING_WORKFLOW };
const session = withFakePane(() => screen.text);
runAndSettle(session);
expect(session.status).toBe('busy');
screen.text = FOLLOW_UP_DONE;
// The probe keeps re-reading the screen on its own slow cadence while it says busy,
// with no PTY output needed to trigger it.
vi.advanceTimersByTime(IDLE_SILENCE_MS + 10_000);
expect(session.status).toBe('idle');
expect(session.isWorking).toBe(false);
});
it('reports an ordinary turn end as idle, as before', () => {
vi.useFakeTimers();
const session = withFakePane(() => DONE);
runAndSettle(session);
expect(session.status).toBe('idle');
});
it('does not apply to a CLI whose registry entry declares no awaitingLine', () => {
vi.useFakeTimers();
expect(getCli('codex')?.capabilities.workDetect?.awaitingLine).toBeUndefined();
const codexScreen = ['✻ Waiting for 1 dynamic workflow to finish', '', '› Ask Codex to do anything', ''].join('\n');
const session = withFakePane(() => codexScreen, 'codex');
runAndSettle(session, '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m');
expect(session.status).toBe('idle');
});
});
+6 -4
View File
@@ -5,9 +5,10 @@
* `selectSession()` acknowledges the session's idle approval item server-side
* (`markIdleAlertSeen` → `POST /api/approvals/session/:id/viewed`), which is
* what makes "I checked it" survive a reload and reach the user's other
* devices. Three call sites are the APP choosing a session rather than the
* user: the boot restore, a solo (popped-out) window opening its target, and
* the fallback after the active session is deleted. Those pass `auto: true`
* devices. Four call sites are the APP choosing a session rather than the
* user: the boot restore, a solo (popped-out) window opening its target, a
* `#session=<id>` link from another page, and the fallback after the active
* session is deleted. Those pass `auto: true`
* and must not spend the alert, or a yellow tab would clear itself every time
* the page loaded and the user would never see it.
*
@@ -110,7 +111,7 @@ describe('selectSession acknowledgement gate', () => {
});
describe('the call sites the app drives itself', () => {
// Source guard: these three are the reason the flag exists. If a refactor
// Source guard: these call sites are the reason the flag exists. If a refactor
// moves or reformats them, fail loudly rather than silently going back to
// "every page load clears the user's yellow tab".
it.each([
@@ -118,6 +119,7 @@ describe('selectSession acknowledgement gate', () => {
['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { auto: true });'],
['solo window opening its target', 'this.selectSession(this.soloSessionId, { auto: true });'],
['fallback after the active session is removed', 'this.selectSession(nextSessionId, { auto: true });'],
['a #session=<id> link from another page', 'this.selectSession(id, { auto: true });'],
])('%s passes auto: true', (_label, call) => {
expect(APP_SOURCE).toContain(call);
});
+39 -1
View File
@@ -133,7 +133,6 @@ describe('watchingLabel', () => {
'1 MCP task',
'1 background dynamic workflow',
'2 remote dynamic workflows',
'1 Artifact comment monitor',
'2 teams',
];
for (const label of labels) {
@@ -141,6 +140,45 @@ describe('watchingLabel', () => {
}
});
it('reports no watching while the agent waits for comments on an artifact', () => {
// An agent that publishes an artifact arms a monitor for its comments and ends its
// turn. That monitor waits on the user, so the idle alert has to reach them. The
// singular footer is a live capture from 2026-09-25; the plural is assumed.
expect(
watchingLabel(pane('⏵⏵ bypass permissions on · 1 Artifact comment monitor · ← for agents'), CLAUDE_WATCHING)
).toBeNull();
expect(
watchingLabel(pane('⏵⏵ bypass permissions on · 2 Artifact comment monitors · ← for agents'), CLAUDE_WATCHING)
).toBeNull();
});
it('lets a comment monitor outrank other background work on the same row', () => {
// A shell beside the monitor is still running, but the agent needs the user all the
// same, and the chip order on the footer must not decide that. The second row is
// the one that needs the `^` in front of the lookahead.
expect(
watchingLabel(
pane('⏵⏵ bypass permissions on · 1 shell · 1 Artifact comment monitor · ← for agents'),
CLAUDE_WATCHING
)
).toBeNull();
expect(
watchingLabel(
pane('⏵⏵ bypass permissions on · 1 Artifact comment monitor · 1 shell · ← for agents'),
CLAUDE_WATCHING
)
).toBeNull();
});
it('still refuses a footer cut off in the middle of the comment monitor', () => {
expect(
watchingLabel(pane('⏵⏵ bypass permissions on · 1 shell · 1 Artifact comment moni…'), CLAUDE_WATCHING)
).toBeNull();
// Cut before "comment" is complete: the lookahead keys on "Artifact" alone for these.
expect(watchingLabel(pane('⏵⏵ bypass permissions on · 1 shell · 1 Artifact comm…'), CLAUDE_WATCHING)).toBeNull();
expect(watchingLabel(pane('⏵⏵ bypass permissions on · 1 shell · 1 Artifact…'), CLAUDE_WATCHING)).toBeNull();
});
it('says nothing about a pane that is running nothing', () => {
expect(watchingLabel(NOTHING_RUNNING, CLAUDE_WATCHING)).toBeNull();
expect(watchingLabel('', CLAUDE_WATCHING)).toBeNull();
+360
View File
@@ -0,0 +1,360 @@
/**
* @fileoverview A shell pane's scroll-up must reach the history tmux still holds.
*
* tmux repaints a burst of output instead of scrolling it, so after `cat` of a
* file longer than the screen the browser holds about one screen of scrollback
* while tmux holds all of it. Other modes recover it by re-pulling `?full=1`
* when the wheel reaches the top (`_maybeRefetchFullHistory`, issue #205). Shell
* declined that gesture outright to keep a multi-megabyte capture off xterm's
* main thread, leaving only the "Load full history" button, and that button
* renders only once a replay was truncated. A young shell tab therefore had no
* way to scroll back at all.
*
* The gesture now pulls a BOUNDED window (`?full=1&tail=TERMINAL_TAIL_SIZE`),
* the button stays the unbounded path, and a window the browser already holds
* in full is not rewritten. Neither is one a browser at xterm's scrollback cap
* could never hold, and a window cut from a byte-capped capture is still labelled
* recoverable, since Load full history can reach past it.
*
* ORDER MATTERS: that skip must run BEFORE the downgrade guard. The guard reads
* "smaller than the browser" as "tmux has nothing more to give", which is true of
* an unbounded capture and false of a window cut at the tail size, so a bounded
* window that reached it marked the session exhausted and took Load full history
* off the banner while tmux still held the rest. The second block below drives
* the real `_setHistoryTruncation` and the real `computeHistoryTruncationNotice`
* to pin what the user is actually told.
*
* The method is extracted from app.js and run in a `vm` against stubs (no jsdom
* on this box; see connection-indicator.test.ts), with the REAL row estimators
* from terminal-ui.js, which decide both the downgrade and the no-gain skip.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const TERMINAL_TAIL_SIZE = 1024 * 1024;
function methodSource(source: string, method: string): string {
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
expect(start, `${method} not found`).toBeGreaterThan(-1);
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
}
/** Real terminal-ui.js mixin, for `_estimateReplayRows` / `_replayWouldShrinkBuffer`. */
function loadTerminalMixin(): Record<string, unknown> {
const source = readFileSync(resolve(PUBLIC, 'terminal-ui.js'), 'utf8');
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
const context = vm.createContext({
console,
performance,
setTimeout,
clearTimeout,
setInterval: vi.fn(),
clearInterval: vi.fn(),
requestAnimationFrame: vi.fn(),
CodemanApp: FakeCodemanApp,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
document: { addEventListener: vi.fn() },
});
vm.runInContext(source, context);
return FakeCodemanApp.prototype;
}
function loadRefetch(): (this: unknown, opts?: { force?: boolean }) => Promise<void> {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const body = methodSource(app, '_maybeRefetchFullHistory');
const context = vm.createContext({ performance, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE: 32 * 1024 });
return vm.runInContext(`({ ${body} })._maybeRefetchFullHistory`, context);
}
/** The REAL `_setHistoryTruncation`, so the banner state a pull leaves behind is what production would hold. */
function loadSetHistoryTruncation(): (this: unknown, sessionId: string, payload?: Record<string, unknown>) => void {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const body = methodSource(app, '_setHistoryTruncation');
return vm.runInContext(`({ ${body} })._setHistoryTruncation`, vm.createContext({}));
}
/** The REAL banner decision from constants.js: what the user is told, and whether Load full history is offered. */
function loadNotice() {
const context = vm.createContext({ console, window: {}, document: {}, navigator: { userAgent: 'test' } });
vm.runInContext(
`${readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8')}\n;globalThis.__notice = computeHistoryTruncationNotice;`,
context,
{ filename: 'constants.js' }
);
return (context as { __notice: (s: Record<string, unknown>) => { visible: boolean; canLoadMore: boolean } }).__notice;
}
const mixin = loadTerminalMixin();
const refetch = loadRefetch();
const setHistoryTruncation = loadSetHistoryTruncation();
const computeNotice = loadNotice();
const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i}`).join('\r\n');
/** A `?full=1&tail=` answer whose window was CUT at the tail size: tmux holds ~3 MiB, the window carries 1 MiB. */
const TAIL_CUT = {
truncated: true,
truncationReason: 'tail',
fullSize: 3 * 1024 * 1024,
retainedBytes: TERMINAL_TAIL_SIZE,
source: 'mux-full-history',
};
function makeApp(
mode: string,
{
bufferRows,
capture,
payload = {},
scrollback = 0,
}: { bufferRows: number; capture: string; payload?: Record<string, unknown>; scrollback?: number }
) {
const urls: string[] = [];
const app = {
activeSessionId: 's1',
sessions: new Map([['s1', { mode }]]),
detachedSessions: new Set<string>(),
_fullHistoryRepullInFlight: false,
_isLoadingBuffer: false,
_fullHistoryRepullAt: new Map<string, number>(),
_fullHistoryRepullUseless: new Set<string>(),
terminalBufferCache: new Map<string, string>(),
terminal: {
cols: 80,
rows: 30,
// xterm's scrollback option; 0 leaves the browser-cap check out of a test.
options: { scrollback },
buffer: { active: { length: bufferRows } },
scrollToLine: vi.fn(),
scrollToTop: vi.fn(),
},
_estimateReplayRows: mixin._estimateReplayRows,
_replayWouldShrinkBuffer: mixin._replayWouldShrinkBuffer,
_fetchTerminalCapture: vi.fn(async (url: string) => {
urls.push(url);
return {
headersAt: performance.now(),
headers: { get: () => '' },
json: { data: { terminalBuffer: capture, source: 'mux-full-history', ...payload } },
};
}),
_recordTerminalLoadTiming: vi.fn(),
_logScrollRouting: vi.fn(),
// The real method behind a spy, so a test sees both what it was called with
// and the banner state (`_historyTruncation`) it leaves behind.
_historyTruncation: new Map<string, unknown>(),
_renderHistoryTruncationBanner: vi.fn(),
_setHistoryTruncation: vi.fn((sessionId: string, p?: Record<string, unknown>): void => {
setHistoryTruncation.call(app, sessionId, p);
}),
_resetTerminalForReplay: vi.fn(),
_bufferLoadFinishOpts: vi.fn(() => ({})),
chunkedTerminalWrite: vi.fn(async () => ({ parsedAt: performance.now(), bufferLength: 400, completed: true })),
_syncStickyScrollBaseline: vi.fn(),
};
return { app, urls };
}
describe('shell scroll-up pulls a bounded window of tmux history', () => {
it('a shell scroll gesture requests full history bounded by the tail size', async () => {
const { app, urls } = makeApp('shell', { bufferRows: 40, capture: lines(300) });
await refetch.call(app);
expect(urls).toEqual([`/api/sessions/s1/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`]);
// It then actually replays the recovered history.
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
expect(app.chunkedTerminalWrite).toHaveBeenCalledTimes(1);
});
it('the Load full history button stays unbounded for a shell', async () => {
const { app, urls } = makeApp('shell', { bufferRows: 40, capture: lines(300) });
await refetch.call(app, { force: true });
expect(urls).toEqual(['/api/sessions/s1/terminal?full=1']);
});
it('other modes keep the unbounded scroll pull', async () => {
const { app, urls } = makeApp('claude', { bufferRows: 40, capture: lines(300) });
await refetch.call(app);
expect(urls).toEqual(['/api/sessions/s1/terminal?full=1']);
});
it('a bounded window the browser already holds is not rewritten', async () => {
// Browser already has every row the window carries: resetting to rewrite
// it would jump the viewport on every scroll that outlasts the cooldown.
const { app } = makeApp('shell', { bufferRows: 320, capture: lines(300) });
await refetch.call(app);
expect(app._resetTerminalForReplay).not.toHaveBeenCalled();
expect(app.chunkedTerminalWrite).not.toHaveBeenCalled();
// Not latched as useless: more output can put more history in tmux.
expect(app._fullHistoryRepullUseless.has('s1')).toBe(false);
// Nothing was written, so the banner state is left exactly as it was.
expect(app._setHistoryTruncation).not.toHaveBeenCalled();
// …but the skip is visible to someone diagnosing "scroll-to-top does nothing".
expect(app._logScrollRouting).toHaveBeenCalledWith('repull-skipped-bounded');
});
it("a browser at xterm's scrollback cap stops replaying a window it can never hold, and backs off", async () => {
// xterm keeps at most `scrollback + rows` rows while tmux keeps 100k lines, so
// a 1 MiB window of short lines can carry more rows than the browser ever will.
// `windowRows <= rows held` then never comes true, and every scroll-to-top
// past the cooldown reset and re-parsed the window. Untruncated on purpose:
// the back-off has to come from the full browser, not from `truncated`.
const { app } = makeApp('shell', { bufferRows: 40, capture: lines(2000), scrollback: 1000 });
// The first pull has room to grow, so it replays.
await refetch.call(app);
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
expect(app._fullHistoryRepullUseless.has('s1')).toBe(false);
// xterm kept only the last `scrollback + rows` of the 2000 rows written.
app.terminal.buffer.active.length = 1000 + 30;
// Past the 4 s cooldown: the browser is full, so nothing is replayed and the
// session backs off for a minute.
app._fullHistoryRepullAt.set('s1', Date.now() - 5000);
await refetch.call(app);
expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(2);
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
expect(app.chunkedTerminalWrite).toHaveBeenCalledTimes(1);
expect(app._fullHistoryRepullUseless.has('s1')).toBe(true);
// So a scroll 10 s later does not even ask the server for another capture.
app._fullHistoryRepullAt.set('s1', Date.now() - 10_000);
await refetch.call(app);
expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(2);
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
});
it('a replayed window cut from a byte-capped capture still offers Load full history', async () => {
// The route keeps `truncationReason: 'capped'` through the tail cut when the
// full capture exceeded the byte cap. On a bounded window that is not "gone for
// good": the unbounded pull behind the button returns up to the cap itself.
const capped = { ...TAIL_CUT, truncationReason: 'capped', fullSize: 40 * 1024 * 1024 };
const { app } = makeApp('shell', { bufferRows: 40, capture: lines(300), payload: capped });
await refetch.call(app);
expect(app._resetTerminalForReplay).toHaveBeenCalledTimes(1);
expect(app._setHistoryTruncation).toHaveBeenCalledWith('s1', expect.objectContaining({ truncationReason: 'tail' }));
const notice = computeNotice(app._historyTruncation.get('s1') as Record<string, unknown>);
expect(notice.visible).toBe(true);
expect(notice.canLoadMore).toBe(true);
// The button's own unbounded pull is the one place 'capped' is the truth.
const button = makeApp('shell', { bufferRows: 40, capture: lines(300), payload: capped });
await refetch.call(button.app, { force: true });
expect(computeNotice(button.app._historyTruncation.get('s1') as Record<string, unknown>).canLoadMore).toBe(false);
});
});
describe('a skipped bounded window never damages the Load full history banner', () => {
it('a tail-cut window smaller than the browser is not replayed and never marks the session exhausted', async () => {
// The browser holds far more rows than a 1 MiB window carries, and tmux holds
// ~3 MiB. The downgrade guard reads that as "tmux has nothing more to give",
// which is true of an unbounded capture and false of a window cut at the tail.
const { app } = makeApp('shell', { bufferRows: 5000, capture: lines(300), payload: TAIL_CUT });
// The tab load that put this session on screen left it truncated and recoverable.
app._setHistoryTruncation('s1', TAIL_CUT);
app._setHistoryTruncation.mockClear();
await refetch.call(app);
expect(app._resetTerminalForReplay).not.toHaveBeenCalled();
expect(app.chunkedTerminalWrite).not.toHaveBeenCalled();
// Not even a relabel: a skipped window writes nothing, banner state included.
expect(app._setHistoryTruncation).not.toHaveBeenCalled();
const notice = computeNotice(app._historyTruncation.get('s1') as Record<string, unknown>);
expect(notice.visible).toBe(true);
// "Earlier output is no longer kept" would be a lie: tmux still holds ~2 MiB more.
expect(notice.canLoadMore).toBe(true);
});
it('a skip right after Load full history leaves the banner as that load set it', async () => {
// Load full history replayed everything, so nothing is truncated any more.
const afterLoadFullHistory = {
truncated: false,
fullSize: 3 * 1024 * 1024,
retainedBytes: 3 * 1024 * 1024,
source: 'mux-full-history',
};
const { app } = makeApp('shell', { bufferRows: 5000, capture: lines(300), payload: TAIL_CUT });
app._setHistoryTruncation('s1', afterLoadFullHistory);
const before = structuredClone(app._historyTruncation.get('s1'));
app._setHistoryTruncation.mockClear();
await refetch.call(app);
// Relabelling it from the bounded payload would call a terminal that holds ALL
// of the history "the most recent 1.0 MB".
expect(app._setHistoryTruncation).not.toHaveBeenCalled();
expect(app._historyTruncation.get('s1')).toEqual(before);
expect(computeNotice(app._historyTruncation.get('s1') as Record<string, unknown>).visible).toBe(false);
});
it('backs off for a minute after a truncated skip, and keeps the 4 s cooldown after an untruncated one', async () => {
// Truncated: the gesture cannot reach anything older than the browser shows, and
// every ask costs the server a synchronous capture of the whole history.
// Only just larger than the window, so the downgrade guard does not fire here:
// the back-off has to come from the skip itself.
const cut = makeApp('shell', { bufferRows: 320, capture: lines(300), payload: TAIL_CUT });
await refetch.call(cut.app);
expect(cut.app._fullHistoryRepullUseless.has('s1')).toBe(true);
expect(cut.app._fetchTerminalCapture).toHaveBeenCalledTimes(1);
// Well past 4 s, still inside the minute: no second capture.
cut.app._fullHistoryRepullAt.set('s1', Date.now() - 10_000);
await refetch.call(cut.app);
expect(cut.app._fetchTerminalCapture).toHaveBeenCalledTimes(1);
cut.app._fullHistoryRepullAt.set('s1', Date.now() - 61_000);
await refetch.call(cut.app);
expect(cut.app._fetchTerminalCapture).toHaveBeenCalledTimes(2);
// Untruncated: it IS all of tmux's history, and the next burst can add to it.
const whole = makeApp('shell', { bufferRows: 320, capture: lines(300) });
await refetch.call(whole.app);
expect(whole.app._fullHistoryRepullUseless.has('s1')).toBe(false);
whole.app._fullHistoryRepullAt.set('s1', Date.now() - 5000);
await refetch.call(whole.app);
expect(whole.app._fetchTerminalCapture).toHaveBeenCalledTimes(2);
});
it('the downgrade guard still refuses an unbounded capture smaller than the browser, and still marks it exhausted', async () => {
// Reordering must not weaken the guard it moved above: a repaint-mode pane's
// capture really is one frame, and rewriting with it would destroy history.
const oneFrame = makeApp('claude', { bufferRows: 300, capture: lines(36) });
await refetch.call(oneFrame.app);
expect(oneFrame.app._resetTerminalForReplay).not.toHaveBeenCalled();
expect(oneFrame.app._setHistoryTruncation).toHaveBeenCalledWith('s1', expect.objectContaining({ exhausted: true }));
expect(oneFrame.app._fullHistoryRepullUseless.has('s1')).toBe(true);
// The button is unbounded too, so the same guard governs it for a shell.
const button = makeApp('shell', { bufferRows: 5000, capture: lines(300) });
await refetch.call(button.app, { force: true });
expect(button.app._resetTerminalForReplay).not.toHaveBeenCalled();
expect(button.app._setHistoryTruncation).toHaveBeenCalledWith('s1', expect.objectContaining({ exhausted: true }));
});
});
describe('_replayWouldShrinkBuffer takes rows the caller already estimated', () => {
const shrink = mixin._replayWouldShrinkBuffer as (this: unknown, capture: string, rows?: number) => boolean;
const make = () => ({
terminal: { cols: 80, rows: 30, buffer: { active: { length: 200 } } },
_estimateReplayRows: vi.fn(mixin._estimateReplayRows as (t: string, c: number) => number),
});
it('does not scan the capture again when handed the estimate', () => {
const ctx = make();
expect(shrink.call(ctx, lines(300), 300)).toBe(false);
expect(shrink.call(ctx, lines(300), 5)).toBe(true);
expect(ctx._estimateReplayRows).not.toHaveBeenCalled();
});
it('still estimates for itself when called the old way', () => {
const ctx = make();
expect(shrink.call(ctx, lines(300))).toBe(false);
expect(ctx._estimateReplayRows).toHaveBeenCalledTimes(1);
});
});
+574 -10
View File
@@ -11,17 +11,31 @@
// refresh arriving mid-replay is now coalesced into ONE trailing re-run rather
// than dropped, because the in-flight fetch may predate the drop the new frame
// reports and no further frame comes to correct stale content.
//
// The last block covers the scroll-to-top history pull: a burst of output leaves
// a shell pane's xterm with about one screen of scrollback while tmux holds every
// line, and Pane B (a separate xterm from the primary pane) never went back to
// ask. See _maybeLoadMoreHistory / _pullHistory in terminal-split.js.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const TERMINAL_CHUNK_SIZE = 32 * 1024;
const TERMINAL_TAIL_SIZE = 1024 * 1024;
/** The pane's `performance.now()`, so frame arrival vs. capture time is set by hand, not raced. */
let clock = 0;
type FakeTerminal = {
write: ReturnType<typeof vi.fn>;
clear: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
scrollToLine: ReturnType<typeof vi.fn>;
scrollToTop: ReturnType<typeof vi.fn>;
cols: number;
rows: number;
options: { scrollback: number };
buffer: { active: { type: string; viewportY: number; length: number } };
};
type FakeSocket = {
onopen: unknown;
@@ -36,43 +50,80 @@ type PaneUnderTest = {
_destroyed: boolean;
_bufferLoading: boolean;
_bufferRefreshPending: boolean;
_historyPullAt: number;
_historyPullUseless: boolean;
_liveQueue: unknown[] | null;
_onWheel: unknown;
_wsClosed: boolean;
detachedSessions: Set<string> | undefined;
destroy(): void;
_loadBuffer(): Promise<void>;
_refreshBuffer(): void;
_maybeLoadMoreHistory(): void;
_pullHistory(): Promise<void>;
_onLiveOutput(data: string): void;
_onLiveClear(): void;
_installWheelListener(): void;
_writeDisconnectedMarker(): void;
_onSocketClosed(): void;
};
const fetchMock = vi.fn();
/** requestAnimationFrame stand-in: chunked writes queue here and are drained by hand. */
const rafQueue: Array<() => void> = [];
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-split.js'), 'utf8');
function loadSplitTerminalPane() {
const dir = resolve(import.meta.dirname, '../src/web/public');
const src = readFileSync(resolve(dir, 'terminal-split.js'), 'utf8');
const context = vm.createContext({
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
window: {},
// The primary pane's row estimator, reduced to a line count: the pull only
// compares it with the pane's own row count.
window: {
app: { _estimateReplayRows: (text: string) => text.split('\n').length },
AbortSignal: { timeout: (ms: number) => ({ timeoutMs: ms }) },
},
performance: { now: () => clock },
fetch: (...args: unknown[]) => fetchMock(...args),
requestAnimationFrame: (fn: () => void) => rafQueue.push(fn),
// The constants.js globals the module reads at call time.
TERMINAL_CHUNK_SIZE,
TERMINAL_TAIL_SIZE: 1024 * 1024,
TERMINAL_TAIL_SIZE,
});
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${src}`, context);
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context);
return (context.window as { SplitTerminalPane: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
.SplitTerminalPane;
}
const SplitTerminalPane = loadSplitTerminalPane();
function makePane(mode = 'claude'): PaneUnderTest & { terminal: FakeTerminal } {
const pane = new SplitTerminalPane('s1', {}, { mode });
pane.terminal = { write: vi.fn(), clear: vi.fn(), dispose: vi.fn() };
function makePane(
mode = 'claude',
mount: unknown = {},
opts: { detachedSessions?: Set<string> } = {}
): PaneUnderTest & { terminal: FakeTerminal } {
const pane = new SplitTerminalPane('s1', mount, { mode, ...opts });
pane.terminal = {
// xterm invokes a write's callback once everything before it is parsed.
write: vi.fn((_data: string, done?: () => void) => done?.()),
clear: vi.fn(),
dispose: vi.fn(),
scrollToLine: vi.fn(),
scrollToTop: vi.fn(),
cols: 80,
rows: 30,
// xterm keeps at most `scrollback + rows` rows; small here so a test can fill it.
options: { scrollback: 1000 },
// A pane sitting at the top of a 40-row buffer on the normal screen.
buffer: { active: { type: 'normal', viewportY: 0, length: 40 } },
};
return pane as PaneUnderTest & { terminal: FakeTerminal };
}
function jsonResponse(terminalBuffer: string) {
return { json: async () => ({ data: { terminalBuffer } }) };
const rowsOf = (n: number) => Array.from({ length: n }, (_, i) => `line ${i}`).join('\n');
function jsonResponse(terminalBuffer: string, extra: Record<string, unknown> = {}) {
return { json: async () => ({ data: { terminalBuffer, ...extra } }) };
}
function deferred<T>() {
@@ -83,12 +134,15 @@ function deferred<T>() {
return { promise, resolve };
}
const isMarker = (data: unknown) => typeof data === 'string' && data.includes('Pane B disconnected');
/** Lets every microtask the vm-side promise chain queued run. */
const settle = () => new Promise((r) => setTimeout(r, 0));
beforeEach(() => {
fetchMock.mockReset();
rafQueue.length = 0;
clock = 0;
});
describe('SplitTerminalPane.destroy()', () => {
@@ -232,3 +286,513 @@ describe('SplitTerminalPane server-refresh single-flight', () => {
expect(pane.terminal.write).toHaveBeenCalledWith('back');
});
});
describe('SplitTerminalPane scroll-to-top history pull', () => {
it('a shell pane at the top pulls a bounded window of full history and replays it', async () => {
const pane = makePane('shell');
const term = pane.terminal;
// The replay grows the buffer once xterm has parsed it (the empty write's callback).
term.write.mockImplementation((data: string, done?: () => void) => {
if (data === '' && done) term.buffer.active.length = 140;
done?.();
});
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
pane._maybeLoadMoreHistory();
await settle();
// With a deadline: live output is held for as long as the pull runs, so a
// request that never answers would freeze the pane.
expect(fetchMock).toHaveBeenCalledWith(`/api/sessions/s1/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
signal: { timeoutMs: 10_000 },
});
expect(term.write).toHaveBeenCalledWith('\x1bc');
expect(term.write).toHaveBeenCalledWith(rowsOf(100));
// What was row 0 is now 100 rows down (140 - 40): the reader keeps their
// place with the recovered history above it, instead of being dropped at the bottom.
expect(term.scrollToLine).toHaveBeenCalledWith(100);
expect(pane._bufferLoading).toBe(false);
expect(pane._liveQueue).toBeNull();
});
it('does nothing away from the top, for other modes, or on the alternate screen', async () => {
const midScroll = makePane('shell');
midScroll.terminal.buffer.active.viewportY = 12;
midScroll._maybeLoadMoreHistory();
// A repaint-mode agent CLI keeps no tmux history to recover.
makePane('claude')._maybeLoadMoreHistory();
// nano/vim/less own the wheel; their screen is not scrollback.
const fullScreenApp = makePane('shell');
fullScreenApp.terminal.buffer.active.type = 'alternate';
fullScreenApp._maybeLoadMoreHistory();
await settle();
expect(fetchMock).not.toHaveBeenCalled();
});
it('stands aside for a detached session, mirroring _sendResize()', async () => {
// A detached session's own window already owns its PTY size and
// scrollback (buildSplitPickerSessions() already refuses to open one).
const pane = makePane('shell', {}, { detachedSessions: new Set(['s1']) });
pane._maybeLoadMoreHistory();
await settle();
expect(fetchMock).not.toHaveBeenCalled();
});
it('a flick fires once: overlapping triggers are dropped, then the cooldown holds', async () => {
const pane = makePane('shell');
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
pane._maybeLoadMoreHistory();
const startedAt = pane._historyPullAt;
// The cooldown is cleared between triggers on purpose, so that only the
// in-flight guard can be what drops the overlapping ones.
pane._historyPullAt = 0;
pane._maybeLoadMoreHistory();
pane._historyPullAt = 0;
pane._maybeLoadMoreHistory();
expect(fetchMock).toHaveBeenCalledTimes(1);
pane._historyPullAt = startedAt;
response.resolve(jsonResponse(rowsOf(100)));
await settle();
expect(pane._bufferLoading).toBe(false);
// Nothing in flight any more, so now it is the 4s cooldown alone.
pane._maybeLoadMoreHistory();
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
// Once the cooldown lapses a later scroll-to-top may pull again.
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
pane._historyPullAt = Date.now() - 5000;
pane._maybeLoadMoreHistory();
await settle();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('a window the pane already holds in full is not rewritten, and is not latched as useless', async () => {
const pane = makePane('shell');
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30)));
pane._maybeLoadMoreHistory();
await settle();
// A reset+rewrite here would jump the viewport for no new rows.
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc');
expect(pane.terminal.scrollToLine).not.toHaveBeenCalled();
expect(pane.terminal.scrollToTop).not.toHaveBeenCalled();
// The next burst can put more history in tmux than the pane has.
expect(pane._historyPullUseless).toBe(false);
expect(pane._bufferLoading).toBe(false);
});
it('refuses a downgrade, keeping the 4s cooldown when the window is all of tmux history', async () => {
const pane = makePane('shell');
pane.terminal.buffer.active.length = 500;
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5)));
pane._maybeLoadMoreHistory();
await settle();
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc');
// Untruncated: tmux has nothing older, but the next burst can add history.
expect(pane._historyPullUseless).toBe(false);
});
it('a truncated window that fits in the pane backs off for a minute', async () => {
// Every ask costs the server a capture-pane of the WHOLE history (`tail` is
// cut after the capture), and a window cut at the tail size can never reach
// anything older than what the pane already shows.
const pane = makePane('shell');
pane.terminal.buffer.active.length = 500;
// Within a screen of what the pane holds, so the old downgrade guard never
// latched it: only the truncated-skip rule can back this off.
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(480), { truncated: true, truncationReason: 'tail' }));
pane._maybeLoadMoreHistory();
await settle();
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc');
expect(pane._historyPullUseless).toBe(true);
// Inside the 60s back-off, well past the normal 4s cooldown.
pane._historyPullAt = Date.now() - 10_000;
pane._maybeLoadMoreHistory();
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('a pane already at its scrollback cap skips the window and backs off for a minute', async () => {
// A 1 MiB window of short lines can carry more rows than xterm will ever hold
// (`scrollback + rows`), so `incoming <= rows held` never comes true and every
// scroll-to-top would reset and re-parse it.
const pane = makePane('shell');
pane.terminal.buffer.active.length = 1030;
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5000)));
pane._maybeLoadMoreHistory();
await settle();
expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1bc');
expect(pane.terminal.write).not.toHaveBeenCalledWith(rowsOf(5000));
expect(pane._historyPullUseless).toBe(true);
});
it('a successful replay clears the one-minute back-off', async () => {
const pane = makePane('shell');
pane._historyPullUseless = true;
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100), { truncated: true, truncationReason: 'tail' }));
void pane._pullHistory();
await settle();
expect(pane.terminal.write).toHaveBeenCalledWith(rowsOf(100));
expect(pane._historyPullUseless).toBe(false);
});
it('holds live output during the replay and replays only what arrived after the capture', async () => {
const pane = makePane('shell');
const term = pane.terminal;
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
pane._maybeLoadMoreHistory();
expect(pane._liveQueue).toEqual([]);
// Arrives before the response does: it is IN the capture already.
clock = 1;
pane._onLiveOutput('early');
expect(term.write).not.toHaveBeenCalledWith('early');
await settle();
// 200 rows (more than the pane holds, so it replays) of 400 columns each:
// three chunks, which leaves the replay mid-write once the fetch lands.
const bigReplay = Array.from({ length: 200 }, () => 'y'.repeat(400)).join('\n');
expect(bigReplay.length).toBeGreaterThan(TERMINAL_CHUNK_SIZE * 2);
clock = 2; // the response arrives: this is the cutoff
response.resolve(jsonResponse(bigReplay));
await settle();
expect(rafQueue).toHaveLength(1);
// Arrives while the snapshot is still being written: must not land under it.
clock = 3;
pane._onLiveOutput('late');
expect(term.write).not.toHaveBeenCalledWith('late');
rafQueue.shift()!();
rafQueue.shift()!();
await settle();
const written = term.write.mock.calls.map((call) => call[0]);
expect(written).not.toContain('early');
expect(written.at(-1)).toBe('late');
expect(pane._liveQueue).toBeNull();
expect(pane._bufferLoading).toBe(false);
});
it('writes every held frame when the pull ends without replaying', async () => {
const pane = makePane('shell');
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
pane._maybeLoadMoreHistory();
pane._onLiveOutput('held');
await settle();
response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay
await settle();
// Nothing replaced the terminal, so the frame is news even though it
// arrived before the response did.
expect(pane.terminal.write).toHaveBeenCalledWith('held');
});
it('a failed fetch releases the flag and the queue, so live output flows again', async () => {
const pane = makePane('shell');
fetchMock.mockRejectedValueOnce(new Error('offline'));
pane._maybeLoadMoreHistory();
pane._onLiveOutput('held');
await settle();
expect(pane._bufferLoading).toBe(false);
expect(pane._liveQueue).toBeNull();
expect(pane.terminal.write).toHaveBeenCalledWith('held');
pane._onLiveOutput('after');
expect(pane.terminal.write).toHaveBeenLastCalledWith('after');
});
it('a refresh frame during the pull runs once behind it', async () => {
const pane = makePane('shell');
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise).mockResolvedValueOnce(jsonResponse('refreshed'));
pane._maybeLoadMoreHistory();
pane._refreshBuffer();
expect(pane.terminal.clear).not.toHaveBeenCalled();
expect(pane._bufferRefreshPending).toBe(true);
response.resolve(jsonResponse(rowsOf(30)));
await settle();
expect(pane.terminal.clear).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(pane.terminal.write).toHaveBeenCalledWith('refreshed');
});
it('a clear frame during the pull is queued in order, never applied under the replay', async () => {
const pane = makePane('shell');
const term = pane.terminal;
const order: string[] = [];
term.write.mockImplementation((data: string, done?: () => void) => {
order.push(`write:${data}`);
done?.();
});
term.clear.mockImplementation(() => order.push('clear'));
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
pane._maybeLoadMoreHistory();
pane._onLiveOutput('before');
pane._onLiveClear();
pane._onLiveOutput('after');
// Held: clearing now would wipe a half-written snapshot.
expect(order).toEqual([]);
response.resolve(jsonResponse(rowsOf(30))); // nothing to gain: no replay
await settle();
expect(order).toEqual(['write:before', 'clear', 'write:after']);
expect(pane._liveQueue).toBeNull();
// With nothing in flight a clear frame applies straight away.
pane._onLiveClear();
expect(order.at(-1)).toBe('clear');
});
it('a clear that arrived before the capture is not replayed after it', async () => {
const pane = makePane('shell');
const term = pane.terminal;
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
pane._maybeLoadMoreHistory();
clock = 1;
pane._onLiveClear(); // already reflected in the capture
clock = 2;
response.resolve(jsonResponse(rowsOf(100)));
await settle();
expect(term.write).toHaveBeenCalledWith('\x1bc');
expect(term.clear).not.toHaveBeenCalled();
});
it('destroy() mid-pull leaves nothing running and nothing written to the dead terminal', async () => {
const pane = makePane('shell');
const term = pane.terminal;
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
pane._maybeLoadMoreHistory();
pane._onLiveOutput('held');
pane.destroy();
response.resolve(jsonResponse(rowsOf(100)));
await settle();
expect(pane._bufferLoading).toBe(false);
expect(pane._liveQueue).toBeNull();
expect(pane.terminal).toBeNull();
expect(term.write).not.toHaveBeenCalledWith('\x1bc');
expect(term.write).not.toHaveBeenCalledWith('held');
});
it('a pull whose request is aborted (the deadline) frees the pane', async () => {
const pane = makePane('shell');
fetchMock.mockRejectedValueOnce(new Error('The operation timed out'));
pane._maybeLoadMoreHistory();
pane._onLiveOutput('held');
await settle();
expect(pane._bufferLoading).toBe(false);
expect(pane._liveQueue).toBeNull();
expect(pane.terminal.write).toHaveBeenCalledWith('held');
});
it('the wheel listener is capture-phase, and only a wheel UP can trigger a pull', async () => {
const mount = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
const pane = makePane('shell', mount);
fetchMock.mockResolvedValue(jsonResponse(rowsOf(100)));
pane._installWheelListener();
// Capture phase: xterm's own wheel handler stopPropagation()s the events it
// consumes, so a bubbling listener would never fire while the pane still has
// scrollback to scroll, and the pull would work only from the exact top row.
const [type, listener, options] = mount.addEventListener.mock.calls[0];
expect(type).toBe('wheel');
expect(options).toEqual({ capture: true, passive: true });
listener({ deltaY: 120 }); // wheel down
listener({ deltaY: 0 });
await settle();
expect(fetchMock).not.toHaveBeenCalled();
listener({ deltaY: -120 }); // wheel up, at the top
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('destroy() detaches exactly the wheel listener it registered', () => {
const mount = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
const pane = makePane('shell', mount);
pane._installWheelListener();
const registered = mount.addEventListener.mock.calls[0][1];
pane.destroy();
expect(mount.removeEventListener).toHaveBeenCalledWith('wheel', registered, { capture: true });
expect(pane._onWheel).toBeNull();
});
it('connect() installs the wheel listener (static guard)', () => {
// connect() needs a whole xterm to run, so its wiring is pinned by source
// rather than executed; the listener's behaviour is exercised above.
const connect = SOURCE.slice(SOURCE.indexOf('async connect()'), SOURCE.indexOf('async _loadBuffer()'));
expect(connect).toContain('this._installWheelListener();');
expect(connect).toContain('this._onLiveClear();');
expect(connect).not.toContain('this.terminal.clear();');
// The tests below drive the close through _onSocketClosed() directly.
expect(connect).toContain('this.ws.onclose = () => this._onSocketClosed();');
});
it('a close with no pull running writes the marker straight away', () => {
const pane = makePane('shell');
pane._onSocketClosed();
expect(pane._wsClosed).toBe(true);
expect(pane.terminal.write).toHaveBeenCalledTimes(1);
expect(isMarker(pane.terminal.write.mock.calls[0][0])).toBe(true);
});
it('re-stamps the disconnected marker after a replay if the socket closed before the pull started', async () => {
// onclose already wrote the marker once; a replay's own `\x1bc` would wipe
// it and paint a fresh, current-looking history while onData keeps
// silently dropping every keystroke on the dead socket.
const pane = makePane('shell');
pane._wsClosed = true;
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
void pane._pullHistory();
await settle();
const marker = expect.stringContaining('Pane B disconnected');
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
expect(writes.at(-1)).toEqual(expect.stringMatching(/Pane B disconnected/));
expect(pane.terminal.write).toHaveBeenCalledWith(marker);
});
it('writes the disconnected marker once, after the replay, if the socket closes mid-fetch', async () => {
// The close lands while the capture is in flight, so the HTTP pull still
// succeeds (a Codeman restart drops the WS while the tmux session, and so
// the pull, survives) and the replay that follows is what the marker must
// end up below.
const pane = makePane('shell');
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
const pull = pane._pullHistory();
pane._onSocketClosed(); // the close arrives mid-fetch, before the response
expect(pane.terminal.write).not.toHaveBeenCalled();
response.resolve(jsonResponse(rowsOf(100)));
await pull;
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
expect(writes.filter(isMarker)).toHaveLength(1);
expect(isMarker(writes.at(-1))).toBe(true);
});
it.each([
['a skip', 40, () => fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30)))],
['a downgrade', 500, () => fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(5)))],
['a failed fetch', 40, () => fetchMock.mockRejectedValueOnce(new Error('offline'))],
])(
'a close mid-fetch that ends in %s writes the marker last, after the held frames',
async (_label, rowsHeld, mockFetch) => {
// No replay ever runs here, so nothing would wipe a marker written at the
// close; written straight away it sat ABOVE the output the pull was still
// holding, which the finally block then flushed underneath it.
const pane = makePane('shell');
pane.terminal.buffer.active.length = rowsHeld;
mockFetch();
const pull = pane._pullHistory();
pane._onLiveOutput('frame-A');
pane._onLiveOutput('frame-B');
pane._onSocketClosed();
expect(pane.terminal.write).not.toHaveBeenCalled();
await pull;
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
expect(writes.slice(0, 2)).toEqual(['frame-A', 'frame-B']);
expect(writes).toHaveLength(3);
expect(isMarker(writes[2])).toBe(true);
expect(pane._liveQueue).toBeNull();
}
);
it('a close during the chunked replay writes exactly one marker, at the end', async () => {
const pane = makePane('shell');
const response = deferred<ReturnType<typeof jsonResponse>>();
fetchMock.mockReturnValueOnce(response.promise);
const pull = pane._pullHistory();
// Three chunks, so the replay is still mid-write once the fetch lands.
const bigReplay = Array.from({ length: 200 }, () => 'y'.repeat(400)).join('\n');
response.resolve(jsonResponse(bigReplay));
await settle();
expect(rafQueue).toHaveLength(1);
// Written now, the marker would land between two chunks of recovered history.
pane._onSocketClosed();
rafQueue.shift()!();
rafQueue.shift()!();
await pull;
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
expect(writes[0]).toBe('\x1bc');
expect(writes.filter(isMarker)).toHaveLength(1);
expect(isMarker(writes.at(-1))).toBe(true);
});
it('does not re-stamp the marker when the socket is still open', async () => {
const pane = makePane('shell');
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(100)));
void pane._pullHistory();
await settle();
for (const call of pane.terminal.write.mock.calls) {
expect(call[0]).toEqual(expect.not.stringMatching(/Pane B disconnected/));
}
});
it('does not re-stamp the marker when the pull never replayed (skip/downgrade path)', async () => {
// Nothing erased the marker in this path, so re-stamping it would be a
// second, redundant write.
const pane = makePane('shell');
pane._wsClosed = true;
fetchMock.mockResolvedValueOnce(jsonResponse(rowsOf(30))); // held in full already: no replay
void pane._pullHistory();
await settle();
expect(pane.terminal.write).not.toHaveBeenCalled();
});
});
+24 -4
View File
@@ -47,11 +47,13 @@ function loadTerminalUiHarness() {
}
/** A Claude session whose local buffer holds exactly one screen (baseY 0). */
function hollowClaudeApp(overrides: { cliVersion?: string; rows?: number } = {}) {
function hollowClaudeApp(overrides: { cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) {
const { app, logs } = loadTerminalUiHarness();
const sent: Array<{ id: string; data: string }> = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: overrides.cliVersion }]]);
app.sessions = new Map([
['sess-1', { mode: 'claude', cliVersion: overrides.cliVersion, cliMouseTracking: overrides.cliMouseTracking }],
]);
app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data });
app.terminal = {
cols: 80,
@@ -111,12 +113,20 @@ describe('full-history re-pull downgrade guard (issue #205 round 2)', () => {
// Anchor on the open paren, not the full empty signature: the method takes
// options since #258 ({ force }) and this guard is about ORDER, not arity.
const start = source.indexOf('async _maybeRefetchFullHistory(');
const guard = source.indexOf('this._replayWouldShrinkBuffer(buffer)', start);
// Also anchored on the open paren: the guard is handed the rows the caller
// already estimated, and this test is about ORDER, not the argument list.
const guard = source.indexOf('this._replayWouldShrinkBuffer(buffer', start);
const boundedSkip = source.indexOf('boundedShellPull && (windowRows <= rowsNow || browserFull)', start);
const reset = source.indexOf('this._resetTerminalForReplay()', start);
expect(start).toBeGreaterThan(-1);
expect(guard).toBeGreaterThan(start);
expect(guard).toBeLessThan(reset); // refuse first, only then reset+rewrite
// A bounded shell window is skipped BEFORE the guard sees it: the guard reads
// "smaller than the browser" as "tmux has nothing more", which a window cut at
// the tail size does not mean (see shell-scroll-history-pull.test.ts).
expect(boundedSkip).toBeGreaterThan(start);
expect(boundedSkip).toBeLessThan(guard);
// A hollow pane must also stop re-fetching megabytes on every scroll-up.
expect(source).toContain('this._fullHistoryRepullUseless');
expect(source).toContain('this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000');
@@ -186,7 +196,8 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
// "Wheel scrolls local history" ON pins the wheel to a buffer that, for a
// repaint-mode CLI, is empty — a user who flipped it while hunting for a fix
// on 1.11.x would have ended up with a completely dead wheel on 1.12.0.
const { app, sent } = hollowClaudeApp({ cliVersion: '2.1.223' }); // gate would forward…
// Version and tracking both qualify, so the opt-out is the only thing saying no.
const { app, sent } = hollowClaudeApp({ cliVersion: '2.1.223', cliMouseTracking: true }); // gate would forward…
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); // …but the opt-out wins
@@ -228,10 +239,19 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => {
expect(logs[0]).toContain('cliVersion=2.1.100');
expect(logs[0]).toContain('localScrollbackOptOut=false');
expect(logs[0]).toContain('mouseTracking=none');
// The gate's real tracking input: xterm's own mode above is always 'none'
// for Claude, since the server strips the DECSETs.
expect(logs[0]).toContain('cliMouseTracking=false');
app._logScrollRouting('page-keys'); // a changed route still prints
expect(logs).toHaveLength(2);
expect(logs[1]).toContain('page-keys');
// The CLI turning tracking on changes the gate, so it prints again.
app.sessions.get('sess-1').cliMouseTracking = true;
app._logScrollRouting('page-keys');
expect(logs).toHaveLength(3);
expect(logs[2]).toContain('cliMouseTracking=true');
});
it('reports an unknown CLI version, the false-path that disables forwarding', () => {
+26 -5
View File
@@ -585,7 +585,7 @@ describe('terminal touch tap mouse guard', () => {
it('wheel: forwards to the app for verified sessions without Shift, at ANY scroll position', () => {
const { app } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187' }]]);
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187', cliMouseTracking: true }]]);
app.terminal = {
modes: { mouseTrackingMode: 'none' },
buffer: { active: { viewportY: 50, baseY: 50 } },
@@ -638,7 +638,7 @@ describe('terminal touch tap mouse guard', () => {
buffer: { active: { viewportY: 50, baseY: 50 } },
};
const withVersion = (cliVersion?: string) => {
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion }]]);
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion, cliMouseTracking: true }]]);
return app._shouldForwardWheelToApp({ shiftKey: false });
};
@@ -650,6 +650,25 @@ describe('terminal touch tap mouse guard', () => {
expect(withVersion('garbage')).toBe(false); // unparseable → assume older
});
it('wheel: inline claude (no mouse tracking) keeps the local wheel', () => {
// Claude 2.1.280's default inline renderer never enables mouse tracking and
// keeps its transcript in real scrollback, so SGR wheel reports are ignored.
// Forwarding there made every swipe dead on iOS Safari while codex scrolled.
const { app } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.terminal = {
modes: { mouseTrackingMode: 'none' },
buffer: { active: { viewportY: 50, baseY: 50 } },
};
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.280' }]]);
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.280', cliMouseTracking: false }]]);
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
// Fullscreen (CLAUDE_CODE_NO_FLICKER=1) turns tracking on → forwarding resumes.
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.280', cliMouseTracking: true }]]);
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
});
it('wheel: only claude forwards — codex and gemini keep the local wheel', () => {
const { app } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
@@ -664,17 +683,19 @@ 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);
app.sessions = new Map([['sess-1', { mode: 'codex', cliVersion: '9.9.9' }]]); // no version rescues it
// 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);
app.sessions = new Map([['sess-1', { mode: 'gemini', cliVersion: '9.9.9' }]]); // unverified TUI
app.sessions = new Map([['sess-1', { mode: 'gemini', cliVersion: '9.9.9', cliMouseTracking: true }]]); // unverified TUI
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
});
it('wheel: the local-scrollback opt-out pins the plain wheel to local scrollback (issue #154)', () => {
const { app } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187' }]]);
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.187', cliMouseTracking: true }]]);
app.terminal = {
modes: { mouseTrackingMode: 'none' },
buffer: { active: { viewportY: 50, baseY: 50 } },
+312
View File
@@ -0,0 +1,312 @@
// test/url-session-fragment.test.ts
// Port: N/A (no server/browser — loads constants.js and app.js via `vm`, like session-select-ack-gate.test.ts).
//
// A page that holds the dashboard's window switches its tab with a
// `#session=<id>` link, and sessionIdFromFragment() is what reads the link.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { performance } from 'node:perf_hooks';
import { afterEach, describe, expect, it, vi } from 'vitest';
function loadHelper() {
const context = vm.createContext({ window: {}, globalThis: {}, URLSearchParams });
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
vm.runInContext(source, context, { filename: 'constants.js' });
return (context.window as { CodemanUrlSession: { sessionIdFromFragment: (hash: unknown) => string | null } })
.CodemanUrlSession;
}
describe('CodemanUrlSession.sessionIdFromFragment', () => {
const { sessionIdFromFragment } = loadHelper();
it('reads the id from a #session= fragment', () => {
expect(sessionIdFromFragment('#session=76763752-fa3a-40aa-a025-e1684c82d00e')).toBe(
'76763752-fa3a-40aa-a025-e1684c82d00e'
);
});
it('accepts the fragment without its leading #', () => {
expect(sessionIdFromFragment('session=abc')).toBe('abc');
});
it('decodes an encoded id', () => {
expect(sessionIdFromFragment('#session=' + encodeURIComponent('w1 my/app'))).toBe('w1 my/app');
});
it('finds the id beside other fragment parameters', () => {
expect(sessionIdFromFragment('#tab=2&session=abc')).toBe('abc');
});
it('asks for nothing when the fragment names no session', () => {
expect(sessionIdFromFragment('')).toBeNull();
expect(sessionIdFromFragment('#')).toBeNull();
expect(sessionIdFromFragment('#settings')).toBeNull();
expect(sessionIdFromFragment('#session=')).toBeNull();
expect(sessionIdFromFragment('#session=%20')).toBeNull();
expect(sessionIdFromFragment(undefined)).toBeNull();
});
});
// The dashboard side: reading the link, holding an id it does not list yet,
// and handing the selection over. Loaded like session-select-ack-gate.test.ts,
// on a bare instance whose DOM-touching methods are stubbed. webview-tabs.js
// rides along because opening a web tab is one of the ways a waiting link ends.
function loadApp() {
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
const app = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const webviewTabs = readFileSync(resolve(import.meta.dirname, '../src/web/public/webview-tabs.js'), 'utf8');
const location = { hash: '', pathname: '/', search: '' };
const history = {
state: null,
replaceState: vi.fn((_state: unknown, _title: string, url: string) => {
location.hash = url.includes('#') ? url.slice(url.indexOf('#')) : '';
}),
};
const context = vm.createContext({
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: vi.fn(),
URLSearchParams,
location,
history,
document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: { isTouchDevice: () => false },
});
vm.runInContext(
`${constants}\n${app}\n${webviewTabs}\nglobalThis.__CodemanApp = CodemanApp;\nglobalThis.__waitMs = URL_SESSION_WAIT_MS;`,
context
);
const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp;
const waitMs = (context as { __waitMs: number }).__waitMs;
const make = (ids: string[]) => {
const inst = Object.create(CodemanApp.prototype) as Record<string, any>;
inst.sessions = new Map(ids.map((id) => [id, { id, name: id }]));
inst.sessionOrder = [...ids];
inst.detachedSessions = new Set();
inst.detachedWindows = new Map();
inst.isSoloWindow = false;
inst._urlSessionId = null;
inst._urlSessionWaitTimer = null;
inst.selectSession = vi.fn();
inst.showToast = vi.fn();
for (const stub of [
'saveSessionOrder',
'markSessionTabEntering',
'markTerminalEntering',
'renderSessionTabs',
'updateCost',
'startSystemStatsPolling',
]) {
inst[stub] = vi.fn();
}
return inst;
};
return { make, location, history, CodemanApp, waitMs };
}
describe('dashboard handling of a #session=<id> link', () => {
it('reads the link and removes the fragment, so the same link counts as a change next time', () => {
const { make, location, history } = loadApp();
const app = make(['a']);
location.hash = '#session=a';
expect(app._takeUrlSession()).toBe('a');
expect(history.replaceState).toHaveBeenCalledWith(null, '', '/');
expect(location.hash).toBe('');
});
it('leaves a URL without a session link alone', () => {
const { make, location, history } = loadApp();
location.hash = '#settings';
expect(make([])._takeUrlSession()).toBeNull();
expect(history.replaceState).not.toHaveBeenCalled();
});
it('selects a listed session as an app selection, which leaves its idle alert armed', () => {
const { make } = loadApp();
const app = make(['a']);
app._urlSessionId = 'a';
expect(app._selectUrlSession()).toBe(true);
expect(app.selectSession).toHaveBeenCalledWith('a', { auto: true });
expect(app._urlSessionId).toBeNull();
});
it('holds an unlisted id until session:created names it', () => {
const { make } = loadApp();
const app = make([]);
app._urlSessionId = 'new';
expect(app._selectUrlSession()).toBe(false);
expect(app.selectSession).not.toHaveBeenCalled();
app._onSessionCreated({ id: 'other', name: 'other' });
expect(app.selectSession).not.toHaveBeenCalled();
app._onSessionCreated({ id: 'new', name: 'new' });
expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true });
expect(app._urlSessionId).toBeNull();
});
it('retires a waiting link when you pick another tab yourself', async () => {
const { make, CodemanApp } = loadApp();
const app = make(['a', 'b']);
app.selectSession = (CodemanApp.prototype as Record<string, any>).selectSession;
app._urlSessionId = 'later';
await app.selectSession('b').catch(() => {});
expect(app._urlSessionId).toBeNull();
});
it('keeps a waiting link through a selection the app makes itself', async () => {
const { make, CodemanApp } = loadApp();
const app = make(['a', 'b']);
app.selectSession = (CodemanApp.prototype as Record<string, any>).selectSession;
app._urlSessionId = 'later';
await app.selectSession('b', { auto: true }).catch(() => {});
expect(app._urlSessionId).toBe('later');
});
it('starts the wait for an unlisted link once the page has loaded its session list', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const link = source.indexOf('if (this._urlSessionId && this.sessions.has(this._urlSessionId))');
const wait = source.indexOf('if (this._urlSessionId) this._armUrlSessionWait(this._urlSessionId);');
const restore = source.indexOf("restoreId = localStorage.getItem('codeman-active-session')");
expect(wait).toBeGreaterThan(link);
expect(wait).toBeLessThan(restore);
});
it('puts the link ahead of restoring the last active tab when the page loads', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const link = source.indexOf('if (this._urlSessionId && this.sessions.has(this._urlSessionId))');
const restore = source.indexOf("restoreId = localStorage.getItem('codeman-active-session')");
expect(link).toBeGreaterThan(-1);
expect(link).toBeLessThan(restore);
});
it('never reads the link in a solo window', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
expect(source).toContain('this._urlSessionId = this.isSoloWindow ? null : this._takeUrlSession();');
expect(source).toMatch(/if \(!this\.isSoloWindow\) \{\s*window\.addEventListener\('hashchange'/);
});
});
// A link whose session never turns up: a stale link (the session is closed), a
// typo, or in multi-user mode another user's session, which is never in this
// client's list. It must not wait forever with nothing on screen, and choosing
// something else must end it, or a session turning up later takes the screen.
describe('a #session=<id> link that is still waiting', () => {
afterEach(() => {
vi.useRealTimers();
});
it('waits 30 seconds', () => {
expect(loadApp().waitMs).toBe(30_000);
});
it('is dropped with a toast when its session has not appeared in time', () => {
vi.useFakeTimers();
const { make, waitMs } = loadApp();
const app = make([]);
app._urlSessionId = 'gone';
expect(app._selectUrlSession()).toBe(false);
vi.advanceTimersByTime(waitMs - 1);
expect(app._urlSessionId).toBe('gone');
expect(app.showToast).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(app._urlSessionId).toBeNull();
expect(app._urlSessionWaitTimer).toBeNull();
expect(app.showToast).toHaveBeenCalledWith('Session not found', 'warning');
// Retired for good: the session turning up afterwards does not take the tab.
app._onSessionCreated({ id: 'gone', name: 'gone' });
expect(app.selectSession).not.toHaveBeenCalled();
});
it('still selects a session that arrives before the wait runs out, and stops the timer', () => {
vi.useFakeTimers();
const { make, waitMs } = loadApp();
const app = make([]);
app._urlSessionId = 'new';
app._selectUrlSession();
vi.advanceTimersByTime(waitMs - 1);
app._onSessionCreated({ id: 'new', name: 'new' });
expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true });
expect(app._urlSessionWaitTimer).toBeNull();
vi.advanceTimersByTime(waitMs);
expect(app.showToast).not.toHaveBeenCalled();
});
it('does not restart its wait when handleInit asks again', () => {
vi.useFakeTimers();
const { make, waitMs } = loadApp();
const app = make([]);
app._urlSessionId = 'gone';
app._armUrlSessionWait('gone');
vi.advanceTimersByTime(waitMs - 1000);
app._armUrlSessionWait('gone');
vi.advanceTimersByTime(1000);
expect(app._urlSessionId).toBeNull();
expect(app.showToast).toHaveBeenCalledTimes(1);
});
it('is retired by going Home', () => {
vi.useFakeTimers();
const { make, waitMs } = loadApp();
const app = make([]);
app.terminal = { clear: vi.fn() };
app.showWelcome = vi.fn();
app.renderRalphStatePanel = vi.fn();
app._urlSessionId = 'later';
app._selectUrlSession();
app.goHome();
expect(app._urlSessionId).toBeNull();
expect(app._urlSessionWaitTimer).toBeNull();
app._onSessionCreated({ id: 'later', name: 'later' });
expect(app.selectSession).not.toHaveBeenCalled();
vi.advanceTimersByTime(waitMs);
expect(app.showToast).not.toHaveBeenCalled();
});
function withWebTab(app: Record<string, any>) {
const webview = { id: 'dash', name: 'Dash', url: 'http://127.0.0.1:8080/' };
app.webviews = new Map([['dash', webview]]);
app.webviewOrder = ['dash'];
app._persistWebviewOrder = vi.fn();
app._apiJson = vi.fn(async () => ({ webview, embedUrl: '/webview/cap/' }));
app._mountWebviewFrame = vi.fn();
app.hideWelcome = vi.fn();
app._updateActiveWebviewTab = vi.fn();
app.closeSessionSidebarOnHandheld = vi.fn();
return app;
}
it('is retired by opening a web tab', async () => {
vi.useFakeTimers();
const { make, waitMs } = loadApp();
const app = withWebTab(make([]));
app._urlSessionId = 'later';
app._selectUrlSession();
const opening = app.openWebview('dash');
// Before the open's await: a session:created landing inside it finds no link.
expect(app._urlSessionId).toBeNull();
expect(app._urlSessionWaitTimer).toBeNull();
await opening;
expect(app.activeWebviewId).toBe('dash');
app._onSessionCreated({ id: 'later', name: 'later' });
expect(app.selectSession).not.toHaveBeenCalled();
vi.advanceTimersByTime(waitMs);
expect(app.showToast).not.toHaveBeenCalled();
});
it('survives a web tab the app opens itself', async () => {
const { make } = loadApp();
const app = withWebTab(make([]));
app._urlSessionId = 'later';
await app.openWebview('dash', { auto: true });
expect(app._urlSessionId).toBe('later');
});
});
+217
View File
@@ -0,0 +1,217 @@
/**
* @fileoverview Owner routing of the `webview:changed` SSE event.
*
* Saved web tabs are owner-scoped in multi-user mode (`canAccessOwned` on every
* CRUD route), but their invalidation event used to carry no owner and fell
* through `deriveSseHint`'s global branch, so every connected user learned the
* ids of every other user's web-tab creates, edits and deletes. The event now
* carries the resource owner and routes to that owner plus admins; single-user
* mode (no SSE identity) still delivers it to every client.
*/
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyWebsocket from '@fastify/websocket';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { CleanupManager } from '../src/utils/index.js';
import type { AuthUser } from '../src/types.js';
import { installRouteErrorHandler } from '../src/web/route-error-handler.js';
import { registerWebviewRoutes } from '../src/web/routes/webview-routes.js';
import { WebServer } from '../src/web/server.js';
import { SseEvent } from '../src/web/sse-events.js';
import { SseStreamManager, type SseRoutingHint } from '../src/web/sse-stream-manager.js';
import { deriveWebviewSseHint } from '../src/web/webview-sse.js';
function client() {
const writes: string[] = [];
return {
writes,
reply: { raw: { write: (chunk: string) => (writes.push(chunk), true) } } as unknown as FastifyReply,
};
}
/** The server's real event → routing-hint derivation, without starting the server. */
function serverHint(event: string, data: unknown): SseRoutingHint | undefined {
const server = new WebServer(3999, false, true) as unknown as {
deriveSseHint(event: string, data: unknown): SseRoutingHint | undefined;
};
return server.deriveSseHint(event, data);
}
describe('deriveWebviewSseHint', () => {
it('routes to the exact owner and fails closed when the owner is missing', () => {
expect(deriveWebviewSseHint({ action: 'updated', id: 'w1', owner: 'alice' })).toEqual({
username: 'alice',
sessionScoped: true,
});
expect(deriveWebviewSseHint({ action: 'updated', id: 'w1' })).toEqual({
username: undefined,
sessionScoped: true,
});
});
it('is what the server derives for the webview: family (never the global branch)', () => {
const payload = { action: 'created', id: 'w1', owner: 'alice' };
expect(serverHint(SseEvent.WebviewChanged, payload)).toEqual({ username: 'alice', sessionScoped: true });
expect(serverHint(SseEvent.WebviewChanged, { action: 'deleted', id: 'w1' })).not.toBeUndefined();
});
});
describe('webview:changed delivery', () => {
it('reaches the owner and admins, never another ordinary user', () => {
const cleanup = new CleanupManager();
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
const alice = client();
const bob = client();
const admin = client();
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
const payload = { action: 'deleted', id: 'w1', owner: 'alice' };
manager.broadcast(SseEvent.WebviewChanged, payload, serverHint(SseEvent.WebviewChanged, payload));
expect(alice.writes).toEqual(['event: webview:changed\ndata: {"action":"deleted","id":"w1","owner":"alice"}\n\n']);
expect(admin.writes).toEqual(alice.writes);
expect(bob.writes).toEqual([]);
cleanup.dispose();
});
it('single-user mode: clients without an identity all still receive it', () => {
const cleanup = new CleanupManager();
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
const tabA = client();
const tabB = client();
manager.addClient(tabA.reply, null, false, undefined, undefined);
manager.addClient(tabB.reply, null, false, undefined, undefined);
const payload = { action: 'created', id: 'w1', owner: '@single' };
manager.broadcast(SseEvent.WebviewChanged, payload, serverHint(SseEvent.WebviewChanged, payload));
expect(tabA.writes).toHaveLength(1);
expect(tabB.writes).toEqual(tabA.writes);
cleanup.dispose();
});
});
describe('webview routes → SSE, end to end', () => {
let tmpDir: string;
let savedDataDir: string | undefined;
let savedMode: string | undefined;
let cleanup: CleanupManager;
let manager: SseStreamManager;
const apps: FastifyInstance[] = [];
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-webview-sse-'));
savedDataDir = process.env.CODEMAN_DATA_DIR;
savedMode = process.env.CODEMAN_MULTIUSER;
process.env.CODEMAN_DATA_DIR = tmpDir;
cleanup = new CleanupManager();
manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
});
afterEach(async () => {
for (const app of apps.splice(0)) await app.close();
cleanup.dispose();
if (savedDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
else process.env.CODEMAN_DATA_DIR = savedDataDir;
if (savedMode === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = savedMode;
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
/** A route app acting as `authUser`, whose broadcasts go through the server's routing. */
async function appAs(authUser: AuthUser | undefined): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
await app.register(fastifyWebsocket);
app.decorateRequest('authUser', undefined);
app.addHook('onRequest', async (req) => {
req.authUser = authUser;
});
registerWebviewRoutes(app, {
broadcast: (event: string, data: unknown) => manager.broadcast(event, data, serverHint(event, data)),
tabLayouts: { webviewCreated: async () => {}, webviewDeleted: async () => {} },
} as never);
installRouteErrorHandler(app);
await app.ready();
apps.push(app);
return app;
}
it("multi-user: another user's SSE stream never sees a web-tab create, edit or delete", async () => {
process.env.CODEMAN_MULTIUSER = '1';
const aliceApp = await appAs({ username: 'alice', role: 'user' });
const alice = client();
const bob = client();
const admin = client();
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
const created = await aliceApp.inject({
method: 'POST',
url: '/api/webviews',
payload: { name: 'Grafana', url: 'http://127.0.0.1:4000/' },
});
expect(created.statusCode).toBe(200);
const id = created.json().data.id as string;
const patched = await aliceApp.inject({ method: 'PATCH', url: `/api/webviews/${id}`, payload: { name: 'G2' } });
expect(patched.statusCode).toBe(200);
expect((await aliceApp.inject({ method: 'DELETE', url: `/api/webviews/${id}` })).statusCode).toBe(200);
const expected = ['created', 'updated', 'deleted'].map(
(action) => `event: webview:changed\ndata: ${JSON.stringify({ action, id, owner: 'alice' })}\n\n`
);
expect(alice.writes).toEqual(expected);
expect(admin.writes).toEqual(expected);
expect(bob.writes).toEqual([]);
});
it("multi-user: an admin editing a user's web tab notifies that user, not a bystander", async () => {
process.env.CODEMAN_MULTIUSER = '1';
const aliceApp = await appAs({ username: 'alice', role: 'user' });
const adminApp = await appAs({ username: 'root', role: 'admin' });
const id = (
await aliceApp.inject({
method: 'POST',
url: '/api/webviews',
payload: { name: 'G', url: 'http://127.0.0.1:4000/' },
})
).json().data.id as string;
const alice = client();
const bob = client();
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
expect((await adminApp.inject({ method: 'DELETE', url: `/api/webviews/${id}` })).statusCode).toBe(200);
expect(alice.writes).toEqual([
`event: webview:changed\ndata: ${JSON.stringify({ action: 'deleted', id, owner: 'alice' })}\n\n`,
]);
expect(bob.writes).toEqual([]);
});
it('single-user: every client still receives the event', async () => {
const soloApp = await appAs(undefined);
const tabA = client();
const tabB = client();
manager.addClient(tabA.reply, null, false, undefined, undefined);
manager.addClient(tabB.reply, null, false, undefined, undefined);
const created = await soloApp.inject({
method: 'POST',
url: '/api/webviews',
payload: { name: 'G', url: 'http://127.0.0.1:4000/' },
});
expect(created.statusCode).toBe(200);
expect(tabA.writes).toEqual([
`event: webview:changed\ndata: ${JSON.stringify({ action: 'created', id: created.json().data.id, owner: '@single' })}\n\n`,
]);
expect(tabB.writes).toEqual(tabA.writes);
});
});