mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
Merge remote-tracking branch 'origin/master' into pr/cod-455-xlsx-preview
# Conflicts: # src/web/public/constants.js
This commit is contained in:
@@ -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}"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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')");
|
||||
});
|
||||
});
|
||||
@@ -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'");
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user