Files
Codeman/test/terminal-cell-wrapped-paths.test.ts
T
Codeman maintainer c04fa97f78 chore: replace the em-dashes this release added with plain punctuation
The merged PRs (mostly #714's git fetch) added about 120 em-dashes in
comments, docs, test names and three settings strings. Each one is now a
comma, colon, semicolon or parentheses. The English settings strings in
index.html and their i18n.js keys change together, and the zh-CN value
for the fetch interval uses full-width parentheses instead of a double
dash. The one left is the regex in constants.js that matches a dash
typed as a word, which is behaviour, not punctuation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:11:19 +01:00

320 lines
14 KiB
TypeScript
Raw 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,
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);
}
});
});