Files
Codeman/test/terminal-cell-wrapped-paths.test.ts
T
Michael GrundbergandClaude Opus 5.5 8d33f53fd0 fix(files): harden the outside-worktree root after review
Keep the 1.49.0 API meaning: liveWorktree and root=live stay inside the
session folder, and an outside worktree is published as outsideWorktree
and walked only with root=live-any.

Refuse a symlinked .git folder as the main worktree, sensitive files
inside a followed worktree, and any outside worktree while attachment
confinement is on or around a blocked tree. The file routes re-check
the guard settings on every request, and a throwing rule check refuses.
The panel keeps its Back to worktree button while pinned to the session
folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:33:14 +01:00

321 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Port: none (pure functions + the link provider in a vm context: no browser, no server).
//
// Absolute file paths codex word-wrapped inside a markdown table cell were not
// clickable: the first fragment has no extension and the continuation has no
// absolute root, so FILE_PATH_LINK_PATTERN matched neither. In a two-column
// table both continuations sit on the same next row, so each path has to be
// followed down its OWN cell (terminalCellWrappedPaths in constants.js).
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const CONSTANTS = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
const TERMINAL_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
type Row = { text: string; isWrapped?: boolean };
type Hit = { path: string; segments: Array<{ row: number; startCol: number; endCol: number }> };
type Link = {
text: string;
range: { start: { x: number; y: number }; end: { x: number; y: number } };
activate: (event: unknown, text: string) => void;
};
const COLS = 200;
function fakeBuffer(rows: Row[], cols: number) {
const lines = rows.map((row) => ({
isWrapped: row.isWrapped ?? false,
translateToString(trimRight?: boolean) {
const padded = row.text.padEnd(cols, ' ');
return trimRight ? padded.trimEnd() : padded;
},
getCell(index: number) {
const padded = row.text.padEnd(cols, ' ');
if (index >= padded.length) return undefined;
return { getChars: () => (padded[index] === ' ' ? '' : padded[index]), getWidth: () => 1 };
},
}));
return { length: lines.length, getLine: (i: number) => lines[i] };
}
function loadPure() {
const context = vm.createContext({ console, window: undefined });
vm.runInContext(`${CONSTANTS}\nglobalThis.__cell = terminalCellWrappedPaths;`, context, {
filename: 'constants.js',
});
return (context as unknown as { __cell: (buffer: unknown, row: number, maxRows?: number) => Hit[] }).__cell;
}
// Shape copied from a real codex pane (synthetic paths).
const ROW1 =
' Before screenshot (/tmp/demo-check/docs/review/ After screenshot (/tmp/demo-check/docs/review/BLDMESH-';
const ROW2 = ' BLDMESH-243/before-overview.png) 243/after-overview.png)';
const TABLE: Row[] = [
{ text: ' No Vision selected Reprocessed' },
{ text: ROW1 },
{ text: ROW2 },
{ text: '' },
];
const LISTINGS = [
['/tmp/x', '/tmp/x/dir', '/tmp/x/dir/a.png'],
[' ⎿ /home/u/proj/src', ' /home/u/proj/src/a.ts'],
['4.0K /tmp/foo/bar/', '8.0K /tmp/foo/baz.png'],
['/home/u/proj/docs:', 'guide.md notes.md'],
['Created /tmp/out', 'report.md written next to it'],
];
// A directory that is the whole value of its cell, over the next record's file
// name in the same column. A wrapper breaks a path after `/` or `-`, never inside
// a name, so none of these is one path: each cell keeps its own link.
const WHOLE_VALUE_SHAPES: Array<{ rows: string[]; dir: string; file: string }> = [
{ rows: [' Repo /home/u/proj', ' Config config.yaml'], dir: '/home/u/proj', file: 'config.yaml' },
{
rows: [' Workspace /home/u/proj/app', ' Entry main.py'],
dir: '/home/u/proj/app',
file: 'main.py',
},
{
rows: ['│ Workspace │ /home/u/proj/app │', '│ Entry │ main.py │'],
dir: '/home/u/proj/app',
file: 'main.py',
},
{ rows: [' Output dir /tmp/build/out', ' Log build.log'], dir: '/tmp/build/out', file: 'build.log' },
{
rows: [' Path Purpose', ' /home/u/proj/src sources', ' index.ts entry'],
dir: '/home/u/proj/src',
file: 'index.ts',
},
];
// The same records where the directory DOES end on a break character: the label
// on the next row (`Entry`, `│ Helper │`) says it is a new record, since a wrapper
// wraps every column of one table row together and leaves the label cell blank.
const LABELLED_RECORD_SHAPES: Array<{ rows: string[]; dir: string; file: string }> = [
{
rows: [' Workspace /home/u/proj/app/', ' Entry main.py'],
dir: '/home/u/proj/app/',
file: 'main.py',
},
{
rows: ['│ Source │ /home/u/proj/src/ │', '│ Helper │ lib/util.ts │'],
dir: '/home/u/proj/src/',
file: 'lib/util.ts',
},
{ rows: [' Prefix /tmp/run-', ' Suffix log.txt'], dir: '/tmp/run-', file: 'log.txt' },
];
const SEPARATE_RECORD_SHAPES = [...WHOLE_VALUE_SHAPES, ...LABELLED_RECORD_SHAPES];
// Known trade-off: with no label to tell them apart, a directory written WITH its
// trailing slash over a relative path in the same column reads exactly like a
// wrap at `/`, so it still joins.
const TRAILING_SLASH_TRADE_OFF = [' /home/u/proj/src/ sources', ' lib/util.ts helpers'];
const BEFORE = '/tmp/demo-check/docs/review/BLDMESH-243/before-overview.png';
const AFTER = '/tmp/demo-check/docs/review/BLDMESH-243/after-overview.png';
describe('terminalCellWrappedPaths', () => {
const find = loadPure();
it('joins each column’s path with its own continuation, from either row', () => {
for (const row of [1, 2]) {
const hits = find(fakeBuffer(TABLE, COLS), row);
expect(hits.map((h) => h.path).sort()).toEqual([AFTER, BEFORE]);
}
});
it.each(LISTINGS.map((lines) => [lines]))('does not join independent listing rows: %j', (lines) => {
const rows = lines.map((text) => ({ text }));
for (let row = 0; row < rows.length; row++) expect(find(fakeBuffer(rows, COLS), row)).toEqual([]);
});
it.each(SEPARATE_RECORD_SHAPES.map((shape) => [shape.rows, shape]))(
'does not join a directory cell onto the next record: %j',
(_rows, shape) => {
const rows = shape.rows.map((text) => ({ text }));
for (let row = 0; row < rows.length; row++) expect(find(fakeBuffer(rows, COLS), row)).toEqual([]);
}
);
it('does not join a fragment that ends on `_` (codex wraps cells only after `/` or `-`)', () => {
const rows = [{ text: ' shot (/tmp/demo/docs/review_' }, { text: ' before.png)' }];
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
expect(find(fakeBuffer(rows, COLS), 1)).toEqual([]);
});
it('still joins an unlabelled trailing-slash directory over a relative path in its column (known trade-off)', () => {
const rows = TRAILING_SLASH_TRADE_OFF.map((text) => ({ text }));
expect(find(fakeBuffer(rows, COLS), 1).map((h) => h.path)).toEqual(['/home/u/proj/src/lib/util.ts']);
});
it('places the segments on the path cells only', () => {
const hits = find(fakeBuffer(TABLE, COLS), 1);
const before = hits.find((h) => h.path === BEFORE)!;
expect(before.segments).toEqual([
{ row: 1, startCol: ROW1.indexOf('/tmp/'), endCol: ROW1.indexOf('/review/') + '/review/'.length },
{ row: 2, startCol: 3, endCol: 3 + 'BLDMESH-243/before-overview.png'.length },
]);
const after = hits.find((h) => h.path === AFTER)!;
const afterCell = ROW1.indexOf('After');
expect(after.segments[0]).toEqual({ row: 1, startCol: ROW1.lastIndexOf('/tmp/'), endCol: ROW1.length });
expect(after.segments[1]).toEqual({
row: 2,
startCol: afterCell,
endCol: afterCell + '243/after-overview.png'.length,
});
});
it('ignores a path already complete on its row', () => {
const rows = [{ text: ' see /tmp/demo/report.md' }, { text: ' next.md' }];
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
});
it('does not glue unrelated text onto a path that is not followed by a matching continuation', () => {
const rows = [{ text: ' saved under /tmp/demo/docs/' }, { text: ' then run the build' }];
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
expect(find(fakeBuffer(rows, COLS), 1)).toEqual([]);
});
it('does not join a continuation that is not aligned to the cell', () => {
const rows = [{ text: ' shot (/tmp/demo/docs/review/' }, { text: ' before.png)' }];
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
});
it('does not join across a soft wrap (the logical line owns that)', () => {
const rows = [{ text: ' shot (/tmp/demo/docs/review/' }, { text: ' before.png', isWrapped: true }];
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
});
it('follows a path across three rows of one cell', () => {
const rows = [{ text: ' a (/tmp/demo/very/' }, { text: ' long/nested/' }, { text: ' file.png)' }];
const hits = find(fakeBuffer(rows, COLS), 1);
expect(hits.map((h) => h.path)).toEqual(['/tmp/demo/very/long/nested/file.png']);
expect(hits[0].segments.map((s) => s.row)).toEqual([0, 1, 2]);
});
it('follows a path down a box-drawn table cell (Claude Code renders tables with │ borders)', () => {
const rows = [
{ text: '│ Before │ /tmp/demo/docs/review/ │ After │ /tmp/demo/docs/review/BLD- │' },
{ text: '│ │ before-overview.png │ │ 243/after-overview.png │' },
];
const hits = find(fakeBuffer(rows, COLS), 1);
expect(hits.map((h) => h.path).sort()).toEqual([
'/tmp/demo/docs/review/BLD-243/after-overview.png',
'/tmp/demo/docs/review/before-overview.png',
]);
const before = hits.find((h) => h.path.endsWith('before-overview.png'))!;
expect(before.segments).toEqual([
{ row: 0, startCol: rows[0].text.indexOf('/tmp/'), endCol: rows[0].text.indexOf('/review/') + 8 },
{ row: 1, startCol: rows[1].text.indexOf('before'), endCol: rows[1].text.indexOf('.png') + 4 },
]);
});
it('does not take a box border or the next cell into a path', () => {
const rows = [{ text: '│ /tmp/demo/docs/ │ x │' }, { text: '│ │ notes.md │ y │' }];
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
});
});
describe('terminal link provider with cell-wrapped paths', () => {
function harness(rows: Row[]) {
const context = vm.createContext({
console: { ...console, debug: vi.fn(), log: vi.fn() },
CodemanApp: class CodemanApp {},
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
document: { addEventListener: vi.fn(), getElementById: vi.fn(), documentElement: { dataset: {} } },
window: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
open: vi.fn(),
document: { documentElement: { dataset: {} } },
},
});
vm.runInContext(`${CONSTANTS}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context);
const proto = (context as unknown as { __proto: Record<string, (...args: unknown[]) => unknown> }).__proto;
let provider: { provideLinks: (line: number, cb: (links?: Link[]) => void) => void } | undefined;
const app = {
terminal: {
cols: COLS,
rows: 24,
registerLinkProvider(value: typeof provider) {
provider = value;
},
buffer: { active: { viewportY: 0, ...fakeBuffer(rows, COLS) } },
},
activeSessionId: 's1',
sessions: new Map([['s1', { workingDir: '/workspace/project' }]]),
openFilePreview: vi.fn(),
openLogViewerWindow: vi.fn(),
_openTerminalUrl: proto._openTerminalUrl,
_resolveLinkedFilePath: proto._resolveLinkedFilePath,
_isExternalPreviewPath: () => false,
_isOutsideWorktreePath: () => false,
registerFilePathLinkProvider: proto.registerFilePathLinkProvider,
};
app.registerFilePathLinkProvider.call(app);
const provide = (line: number): Link[] => {
let links: Link[] | undefined;
provider!.provideLinks(line, (value) => {
links = value;
});
return links ?? [];
};
return { provide, app };
}
it.each(LISTINGS.map((lines) => [lines]))('preserves ordinary listing links: %j', (lines) => {
const { provide } = harness(lines.map((text) => ({ text })));
const expected = [
['/tmp/x', '/tmp/x/dir', '/tmp/x/dir/a.png'],
['/home/u/proj/src', '/home/u/proj/src/a.ts'],
['/tmp/foo/bar/', '/tmp/foo/baz.png'],
['/home/u/proj/docs', 'guide.md'],
['/tmp/out', 'report.md'],
][LISTINGS.indexOf(lines)];
for (let row = 0; row < lines.length; row++) {
expect(provide(row + 1).map((link) => link.text)).toContain(expected[row]);
}
});
it.each(SEPARATE_RECORD_SHAPES.map((shape) => [shape.rows, shape]))(
'keeps the folder link and the file link of the next record: %j',
(_rows, shape) => {
const { provide, app } = harness(shape.rows.map((text) => ({ text })));
const dirLine = shape.rows.findIndex((text) => text.includes(shape.dir)) + 1;
const fileLine = shape.rows.findIndex((text) => text.includes(shape.file)) + 1;
const all = [...provide(dirLine), ...provide(fileLine)].map((link) => link.text);
expect(all.some((text) => text.includes(shape.dir + shape.file))).toBe(false);
const dirLink = provide(dirLine).find((link) => link.text === shape.dir);
expect(dirLink, `${shape.dir} on row ${dirLine}`).toBeDefined();
expect(dirLink!.range.start.y).toBe(dirLine);
const fileLink = provide(fileLine).find((link) => link.text === shape.file);
expect(fileLink, `${shape.file} on row ${fileLine}`).toBeDefined();
expect(fileLink!.range.start.y).toBe(fileLine);
fileLink!.activate(null, fileLink!.text);
expect(app.openFilePreview).not.toHaveBeenCalledWith(shape.dir + shape.file, 's1');
}
);
it('makes both screenshot paths clickable from either row and opens the whole path', () => {
const { provide, app } = harness(TABLE);
for (const line of [2, 3]) {
const links = provide(line);
for (const target of [BEFORE, AFTER]) {
const onRow = links.find((l) => l.text === target && l.range.start.y === line);
expect(onRow, `${target} on row ${line}`).toBeDefined();
onRow!.activate(null, onRow!.text);
expect(app.openFilePreview).toHaveBeenLastCalledWith(target, 's1');
}
}
});
it('leaves no fragment link that would open a partial path', () => {
const { provide } = harness(TABLE);
for (const line of [2, 3]) {
for (const link of provide(line)) expect([BEFORE, AFTER]).toContain(link.text);
}
});
});