Files
Codeman/test/terminal-url-continuation.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

813 lines
39 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).
//
// Terminal URL links that stopped short.
//
// 1. `)` ended every URL, so a path like `…/Team-Space(QA)Archive/…` linked only as
// far as `(QA`. The grammar now keeps a `)` that closes a `(` inside the URL and
// still leaves out the closing paren of `(https://…)` / `[text](https://…)`.
// 2. A CLI that word-wraps inside a table cell (codex) breaks a long URL at `/`, `?`,
// `-` well short of the right edge and indents the rest to the cell's column.
// Nothing is `isWrapped` and no row is full width, so each row linked a fragment.
//
// All URLs here are synthetic (example.com); none come from real output.
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 UrlRef = { url: string; startIndex: number; endIndex: number; rawEnd: number };
type Extent = { startRow: number; endRow: number } | null;
type Row = { text: string; isWrapped?: boolean };
type Link = {
text: string;
range: { start: { x: number; y: number }; end: { x: number; y: number } };
activate: (event: unknown, text: string) => void;
};
function loadPure() {
const context = vm.createContext({ console, window: undefined });
vm.runInContext(
`${CONSTANTS}\nglobalThis.__urls = findTerminalUrls; globalThis.__extent = terminalUrlContinuationExtent; globalThis.__logical = terminalLogicalLine; globalThis.__linkLine = terminalLinkLine;`,
context,
{ filename: 'constants.js' }
);
const ctx = context as unknown as {
__urls: (text: string) => UrlRef[];
__extent: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent;
__logical: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent;
__linkLine: (buffer: unknown, row: number, cols: number, maxRows?: number) => LinkLine | null;
};
return {
findTerminalUrls: ctx.__urls,
terminalUrlContinuationExtent: ctx.__extent,
terminalLogicalLine: ctx.__logical,
terminalLinkLine: ctx.__linkLine,
};
}
type LinkLine = {
startRow: number;
endRow: number;
text: string;
offsetToCell: (offset: number) => { row: number; col: number };
cellToOffset: (row: number, col: number) => number;
};
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] };
}
// A codex-style markdown table row: number, reason, then a `name (url)` cell whose
// URL the CLI wrapped across three rows, each continuation indented to the cell.
const URL =
'https://docs.example.com/sites/Team-Space(QA)Archive/_layouts/15/Doc.aspx?' +
'sourcedoc=%7B11111111-2222-3333-4444-555555555555%7D&file=Quarterly%20Plan%20(Draft).xlsx&action=default&' +
'mobileredirect=true';
const CELL = 26;
const TABLE: Row[] = [
{ text: ' 11 Unrelated row plain text only' },
{
text: ' 12 New to trending Quarterly Plan.xlsx (https://docs.example.com/sites/Team-Space(QA)Archive/_layouts/15/Doc.aspx?',
},
{
text:
' '.repeat(CELL) +
'sourcedoc=%7B11111111-2222-3333-4444-555555555555%7D&file=Quarterly%20Plan%20(Draft).xlsx&action=default&',
},
{ text: ' '.repeat(CELL) + 'mobileredirect=true)' },
{ text: ' 13 Next row another cell' },
];
const COLS = 160;
const TABLE_CELL_START = TABLE[1].text.indexOf('Quarterly');
const TABLE_WITH_TRAILING_COLUMNS: Row[] = [
TABLE[0],
{
text: TABLE[1].text + ' 2026-09-16 18:00:54 Document Owner',
},
{ text: ' '.repeat(TABLE_CELL_START) + TABLE[2].text.slice(CELL) },
{ text: ' '.repeat(TABLE_CELL_START) + TABLE[3].text.slice(CELL) },
TABLE[4],
];
const TABLE_COLS = 220;
// A wrapped URL with another column to its right. The URL's first row is cut at
// its cell to stitch the URL, and the column after it must still link on that
// row (a release-review regression: only the URL's pieces were offered there).
const WIKI_URL = 'https://wiki.example.com/spaces/ENG/pages/12345/Design-Spec-v2';
const URL_THEN_PATH_COLS = 140;
const URL_THEN_PATH_ROWS: Row[] = [
{ text: ' 1 Design spec https://wiki.example.com/spaces/ENG/pages/12345/ /home/me/proj/docs/design.md' },
{ text: ' Design-Spec-v2' },
{ text: ' 2 Notes n/a /home/me/proj/notes.md' },
];
// The same shape with a relative and a home path in the columns after the URL.
const URL_THEN_PATHS_ROWS: Row[] = [
{
text: ' 1 Design spec https://wiki.example.com/spaces/ENG/pages/12345/ docs/design.md ~/notes/todo.txt',
},
{ text: ' Design-Spec-v2' },
];
// A URL in the column after the wrapped one is the row's LAST URL, and only the
// last URL on a row is continued, so this row is never stitched or cut: both URLs
// link on row 1 as single-row links, as before the wrap joining existed.
const URL_THEN_URL_ROWS: Row[] = [
{
text: ' 1 Design spec https://wiki.example.com/spaces/ENG/pages/12345/ https://other.example.com/notes.md',
},
{ text: ' Design-Spec-v2' },
];
const PROSE_ROWS = [
[
'⏺ The guide lives at https://example.com/guide/',
' self-hosted runners need a token first.',
'https://example.com/guide/',
],
['Docs: https://example.com/guide/', 'Self-hosting is covered in chapter 3.', 'https://example.com/guide/'],
['Open http://localhost:3000/', 'e.g. to check the dashboard', 'http://localhost:3000/'],
['Endpoint https://api.example.com/v1/', 'user_id is required', 'https://api.example.com/v1/'],
['Repo: https://github.com/org/repo/', 'well-known issues are tracked', 'https://github.com/org/repo/'],
['Callback https://example.com/cb?code=', 'Re-run the login', 'https://example.com/cb?code='],
['Branch https://github.com/o/r/tree/feat-', 'x.y happened', 'https://github.com/o/r/tree/feat-'],
];
// Rows that carry URL punctuation but are never the rest of the URL above them:
// a shell prompt, an HTTP status line, a home path, and column-0 prose. Each
// third element is the URL, the fourth the path link expected on the second row.
const NOT_CONTINUATION_ROWS: [string, string, string, string | null][] = [
['https://registry.npmjs.org/', 'arkon@box:~/proj$', 'https://registry.npmjs.org/', '~/proj'],
['https://pypi.org/simple/', 'dev@laptop:/srv/app$', 'https://pypi.org/simple/', '/srv/app'],
['$ curl -sI https://example.com/', 'HTTP/2 200', 'https://example.com/', null],
['$ curl -i http://localhost:3000/api/', 'HTTP/1.1 200 OK', 'http://localhost:3000/api/', null],
['Docs: https://example.com/guide/', '~/notes.md has more', 'https://example.com/guide/', '~/notes.md'],
['fetch https://api.example.com/items/', 'status=200 took=12ms', 'https://api.example.com/items/', null],
['Status page: https://status.example.com/', '24/7 monitoring is on.', 'https://status.example.com/', null],
['Did you mean https://example.com/search?q=', 'really? no.', 'https://example.com/search?q=', null],
];
// The last column of a space-aligned table: the next row opens the next record
// in an earlier column, so its first cell is never the rest of the URL. Each
// row is indented two spaces. The URL on each row, then any path link expected
// on the second row.
const TABLE_LAST_COLUMN_ROWS: [string, string, string, string, string | null][] = [
[
' auth-api https://auth.example.com/',
' billing-api https://billing.example.com/',
'https://auth.example.com/',
'https://billing.example.com/',
null,
],
[
' docs.example.com https://docs.example.com/',
' api.example.com https://api.example.com/',
'https://docs.example.com/',
'https://api.example.com/',
null,
],
[
' Homepage: https://example.com/',
' Bug-reports: https://github.com/org/repo/issues/',
'https://example.com/',
'https://github.com/org/repo/issues/',
null,
],
[
' user_service https://users.example.com/v1/',
' order_service https://orders.example.com/v1/',
'https://users.example.com/v1/',
'https://orders.example.com/v1/',
null,
],
[
' README.md https://example.com/docs/',
' CHANGELOG.md https://example.com/changes/',
'https://example.com/docs/',
'https://example.com/changes/',
'CHANGELOG.md',
],
[
' my-service- https://svc.example.com/api/',
' long-name https://svc2.example.com/',
'https://svc.example.com/api/',
'https://svc2.example.com/',
null,
],
];
// Known trade-offs: a row end counts as a cell end, so a short indented word
// with URL punctuation still continues the URL. Pinned so a change is deliberate.
const TRADE_OFF_ROWS: [string, string, string][] = [
['⏺ Docs live at https://example.com/guide/', ' self-hosted', 'https://example.com/guide/self-hosted'],
[
'⏺ The docs are at https://example.com/guide/',
' and/or the API reference covers it.',
'https://example.com/guide/and/or',
],
];
// Indented rows under a URL ending in `/` that carry URL punctuation but are
// code, a label, a `name=value` field, a measurement or a clock time, or a word
// ending a sentence. Each third element is the URL on the first row.
const FIELD_AND_CODE_ROWS: [string, string, string][] = [
[' └ Listening on http://127.0.0.1:8080/', ' ready.', 'http://127.0.0.1:8080/'],
['⏺ Deployed to https://app.example.com/', ' Done.', 'https://app.example.com/'],
['⏺ Docs live at https://example.com/guide/', ' self-hosted.', 'https://example.com/guide/'],
[' Fetching https://example.com/files/', ' done...', 'https://example.com/files/'],
[
' ● fetch › handles https://api.example.com/v1/',
' expect(received).toBe(expected)',
'https://api.example.com/v1/',
],
[' See https://example.com/api/', ' client.get()', 'https://example.com/api/'],
[
'/home/u/.venv/lib/urllib3/__init__.py:35: Warning: see https://github.com/urllib3/urllib3/issues/',
' warnings.warn(',
'https://github.com/urllib3/urllib3/issues/',
],
[
' 1) Client fetches https://api.example.com/v1/',
' Failure/Error: expect(res.code).to eq(200)',
'https://api.example.com/v1/',
],
[
'Annotations: link.argocd.argoproj.io/external-link: https://grafana.example.com/d/',
' prometheus.io/scrape: true',
'https://grafana.example.com/d/',
],
[' BASE_URL=https://api.example.com/', ' API_KEY=your-key-here', 'https://api.example.com/'],
[' AUTH_URL=https://auth.example.com/', ' LOG_LEVEL=debug', 'https://auth.example.com/'],
[
' level=info msg="request" url=https://api.example.com/v1/',
' level=info msg="done" status=200',
'https://api.example.com/v1/',
],
[' url=https://api.example.com/v1/', ' request_id=abc-123', 'https://api.example.com/v1/'],
[
' Callback: https://example.com/oauth/callback/',
' state=abc123 must match',
'https://example.com/oauth/callback/',
],
[
' Downloading https://huggingface.co/org/model/resolve/main/',
' 45%|████▌ | 450M/1.00G [00:10<00:12, 45.0MB/s]',
'https://huggingface.co/org/model/resolve/main/',
],
[' Downloading https://example.com/files/', ' 12.3MB/45.0MB', 'https://example.com/files/'],
[' GET https://example.com/api/', ' 12.3ms', 'https://example.com/api/'],
[' https://example.com/blog/', ' 10:00:00.123', 'https://example.com/blog/'],
];
// Continuations at the edge of those rules that are still the rest of the URL:
// a wiki title with parens, a file name before a sentence period, a query
// parameter after `?`, `&` or `_`, and percent-encoding that starts with digits.
const STILL_CONTINUES_ROWS: [string, string, string][] = [
[' https://en.wikipedia.org/wiki/', ' Mercury_(planet)', 'https://en.wikipedia.org/wiki/Mercury_(planet)'],
['⏺ The guide is at https://example.com/docs/', ' install.html.', 'https://example.com/docs/install.html'],
[' https://example.com/oauth?client_', ' id=abc&scope=read', 'https://example.com/oauth?client_id=abc&scope=read'],
[' https://example.com/cb?code=abc&', ' state=xyz', 'https://example.com/cb?code=abc&state=xyz'],
[' Docs: https://example.com/guide?', ' x=1&y=2 more text', 'https://example.com/guide?x=1&y=2'],
[' https://example.com/files/', ' 2026%20Plan.xlsx', 'https://example.com/files/2026%20Plan.xlsx'],
];
// A URL row that soft-wraps at 41 columns with a table column on its wrapped
// part, over an indented row: the column and that row are separate links, never
// one glued `/tmp/report.txtccc/x.html`.
const SOFT_WRAP_COLS = 41;
const SOFT_WRAPPED_URL_ROWS: Row[] = [
{ text: ' Doc https://example.com/aaaaaaaaaaaaaa' },
{ text: 'bbb/ /tmp/report.txt', isWrapped: true },
{ text: ' ccc/x.html' },
];
describe('findTerminalUrls (grammar)', () => {
const { findTerminalUrls } = loadPure();
const urls = (text: string) => findTerminalUrls(text).map((u) => u.url);
it('keeps a paren pair that is part of the URL', () => {
expect(urls('see https://example.com/wiki/Widget_(tool) now')).toEqual(['https://example.com/wiki/Widget_(tool)']);
expect(urls('https://example.com/Team-Space(QA)Archive/x')).toEqual([
'https://example.com/Team-Space(QA)Archive/x',
]);
});
it('leaves out the paren that wraps the URL', () => {
expect(urls('(https://example.com/a(b)c)')).toEqual(['https://example.com/a(b)c']);
expect(urls('file (https://example.com/doc?x=1).')).toEqual(['https://example.com/doc?x=1']);
});
it('leaves out the closing paren of a markdown link', () => {
expect(urls('[the docs](https://example.com/docs?a=1&b=2) for details')).toEqual([
'https://example.com/docs?a=1&b=2',
]);
});
it('still keeps query ampersands and stops at the shell &&', () => {
expect(urls('open https://example.com/p?post=1&action=edit&& echo done')).toEqual([
'https://example.com/p?post=1&action=edit',
]);
});
it('trims trailing sentence punctuation', () => {
expect(urls('Go to https://example.com/path.')).toEqual(['https://example.com/path']);
expect(urls('Is it https://example.com/q?')).toEqual(['https://example.com/q']);
});
it('reports where the raw match ended before trimming', () => {
const [ref] = findTerminalUrls('x https://example.com/a) y');
expect(ref.url).toBe('https://example.com/a');
expect(ref.rawEnd).toBe('x https://example.com/a)'.length);
});
});
describe('terminalUrlContinuationExtent (wrap joining)', () => {
const { terminalUrlContinuationExtent, terminalLogicalLine } = loadPure();
it('spans all three rows of a URL wrapped inside a table cell, from any of them', () => {
const buffer = fakeBuffer(TABLE, COLS);
for (const row of [1, 2, 3]) {
expect(terminalUrlContinuationExtent(buffer, row, COLS, 12)).toEqual({ startRow: 1, endRow: 3 });
}
});
it('spans a table URL even when date and owner columns follow its first fragment', () => {
const buffer = fakeBuffer(TABLE_WITH_TRAILING_COLUMNS, TABLE_COLS);
for (const row of [1, 2, 3]) {
expect(terminalUrlContinuationExtent(buffer, row, TABLE_COLS, 12)).toMatchObject({ startRow: 1, endRow: 3 });
}
});
it('starts at the URL row when a full-width table divider precedes it', () => {
const rows = [{ text: '─'.repeat(TABLE_COLS) }, ...TABLE_WITH_TRAILING_COLUMNS.slice(1, 4)];
const buffer = fakeBuffer(rows, TABLE_COLS);
for (const row of [1, 2, 3]) {
expect(terminalUrlContinuationExtent(buffer, row, TABLE_COLS, 12)).toMatchObject({ startRow: 1, endRow: 3 });
}
});
it('does not treat the next numbered table row as a URL continuation', () => {
const rows = [TABLE_WITH_TRAILING_COLUMNS[1], { text: ' 13 New to trending Another document' }];
const buffer = fakeBuffer(rows, TABLE_COLS);
expect(terminalUrlContinuationExtent(buffer, 0, TABLE_COLS, 12)).toBeNull();
expect(terminalUrlContinuationExtent(buffer, 1, TABLE_COLS, 12)).toBeNull();
});
it('leaves the neighbouring table rows alone', () => {
const buffer = fakeBuffer(TABLE, COLS);
expect(terminalUrlContinuationExtent(buffer, 0, COLS, 12)).toBeNull();
expect(terminalUrlContinuationExtent(buffer, 4, COLS, 12)).toBeNull();
});
it.each([
['1. https://example.com/one/', '2. https://example.com/two/'],
['Docs: https://docs.example.com/guide/', '1. Install the package'],
['Fetching https://registry.example.com/pkg/', 'v1.2.3 is the latest'],
['GET http://localhost:3000/api/', '200 OK in 12ms'],
])('keeps list, version and status rows separate after %s', (first, next) => {
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
});
it.each(PROSE_ROWS)('does not join prose after %s', (first, next) => {
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
});
it.each(NOT_CONTINUATION_ROWS)('does not join a prompt, status line, home path or prose after %s', (first, next) => {
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
});
// Indented, so only the prompt / home path / status-line rule can refuse them.
it.each(['arkon@box:~/proj$', 'dev@laptop:/srv/app$', '~/notes.md', 'HTTP/2 200', 'HTTP/1.1 200 OK'])(
'does not join an indented %s either',
(next) => {
const buffer = fakeBuffer([{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + next }], 100);
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
}
);
it.each(TABLE_LAST_COLUMN_ROWS)('does not join the next table record after %s', (first, next) => {
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
});
it.each(FIELD_AND_CODE_ROWS)(
'does not join code, a field, a measurement or a sentence end after %s',
(first, next) => {
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
}
);
it.each(STILL_CONTINUES_ROWS)('still joins the continuation after %s', (first, next) => {
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
for (const row of [0, 1])
expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 });
});
it('does not join an indented email address', () => {
const buffer = fakeBuffer([{ text: ' https://example.com/' }, { text: ' user@example.com' }], 100);
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
});
it.each(TRADE_OFF_ROWS)('still joins the known trade-off after %s', (first, next) => {
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
for (const row of [0, 1])
expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 });
});
it('does not let a URL at the right edge absorb a full-width divider row below it', () => {
const cols = 40;
const url = 'https://example.com/' + 'a'.repeat(cols - 'https://example.com/'.length);
for (const divider of ['─'.repeat(cols), '-'.repeat(cols)]) {
const buffer = fakeBuffer([{ text: url }, { text: divider }, { text: 'next' }], cols);
expect(terminalLogicalLine(buffer, 0, cols, 12)).toMatchObject({ startRow: 0, endRow: 0 });
expect(terminalLogicalLine(buffer, 1, cols, 12)).toMatchObject({ startRow: 1 });
}
});
// Real continuations are indented (codex cells, Claude's two-space paragraph
// indent); a column-0 row is refused, so these fixtures are indented.
it.each(['my-post-title', 'user_id', 'x.html', 'my-post-title ', 'my-post-title Other column'])(
'keeps a weak-punctuation continuation that ends its cell: %s',
(next) => {
const buffer = fakeBuffer([{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + next }], 100);
for (const row of [0, 1])
expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 });
}
);
it.each(['part%20two', 'path/slug', 'query?x=1'])('keeps stronger URL punctuation followed by prose: %s', (token) => {
const buffer = fakeBuffer(
[{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + token + ' more text' }],
100
);
expect(terminalUrlContinuationExtent(buffer, 0, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 });
});
it('does not glue a following prose line onto a URL that ends a line', () => {
const buffer = fakeBuffer(
[{ text: 'Download from https://example.com/files/' }, { text: 'then run the installer' }],
80
);
expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull();
expect(terminalUrlContinuationExtent(buffer, 1, 80, 12)).toBeNull();
});
it('does not join a second URL on the next row', () => {
const buffer = fakeBuffer([{ text: '- https://example.com/one/' }, { text: ' https://example.com/two' }], 80);
expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull();
});
it('does not join a row indented to the right of where the URL started', () => {
const buffer = fakeBuffer([{ text: 'x https://example.com/a/b?' }, { text: ' q=1&r=2' }], 80);
expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull();
});
it('does not join when the URL line ends on a character a wrapper would not break after', () => {
const buffer = fakeBuffer([{ text: 'see https://example.com/page' }, { text: 'v2-notes.txt follows' }], 80);
expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull();
});
it('stays within the row bound', () => {
const rows: Row[] = [{ text: 'u https://example.com/' }];
for (let i = 0; i < 20; i++) rows.push({ text: ` seg${i}-` });
const extent = terminalUrlContinuationExtent(fakeBuffer(rows, 80), 0, 80, 5);
expect(extent).not.toBeNull();
expect(extent!.endRow - extent!.startRow + 1).toBeLessThanOrEqual(5);
});
it('does not continue a URL whose row soft-wraps with a column on the wrapped part', () => {
const buffer = fakeBuffer(SOFT_WRAPPED_URL_ROWS, SOFT_WRAP_COLS);
for (const row of [0, 1, 2]) {
expect(terminalUrlContinuationExtent(buffer, row, SOFT_WRAP_COLS, 12)).toBeNull();
}
});
});
describe('terminalLinkLine (the text the provider scans)', () => {
const { terminalLinkLine } = loadPure();
it('keeps the columns after a wrapped URL, mapped back to their own cells', () => {
const buffer = fakeBuffer(URL_THEN_PATH_ROWS, URL_THEN_PATH_COLS);
const row = URL_THEN_PATH_ROWS[0].text;
const pathCol = row.indexOf('/home/');
const urlCol = row.indexOf('https://');
for (const hovered of [0, 1]) {
const line = terminalLinkLine(buffer, hovered, URL_THEN_PATH_COLS, 12)!;
expect(line).toMatchObject({ startRow: 0, endRow: 1 });
// The URL reads whole, and the path column follows it.
expect(line.text).toContain(WIKI_URL);
const pathOffset = line.text.indexOf('/home/me/proj/docs/design.md');
expect(pathOffset).toBeGreaterThan(line.text.indexOf(WIKI_URL));
expect(line.offsetToCell(pathOffset)).toEqual({ row: 0, col: pathCol });
expect(line.cellToOffset(0, pathCol)).toBe(pathOffset);
expect(line.cellToOffset(0, urlCol)).toBe(line.text.indexOf(WIKI_URL));
// The end of the URL's last row still maps onto that row, never onto the
// first cell of the column moved after it.
const urlEnd = line.text.indexOf(WIKI_URL) + WIKI_URL.length;
expect(line.offsetToCell(urlEnd)).toEqual({ row: 1, col: URL_THEN_PATH_ROWS[1].text.length });
}
});
});
describe('native OSC 8 terminal URLs', () => {
it('opens complete HTTP links through the terminal opener and rejects unsafe schemes', () => {
const open = vi.fn();
const confirm = vi.fn(() => true);
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
document: { addEventListener: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), confirm },
});
vm.runInContext(`${CONSTANTS}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context);
const proto = (
context as unknown as {
__proto: { _terminalOsc8LinkHandler: () => { activate: (event: unknown, url: string) => void } };
}
).__proto;
const handler = proto._terminalOsc8LinkHandler.call({ _openTerminalUrl: open });
const url = 'https://docs.example.com/Doc.aspx?file=Q3%20Plan&action=default&mobileredirect=true';
handler.activate(null, url);
expect(open).toHaveBeenCalledExactlyOnceWith(url);
expect(confirm).toHaveBeenCalledExactlyOnceWith(`Open this link?\n\n${url}`);
confirm.mockReturnValue(false);
handler.activate(null, 'http://localhost:3000/private');
expect(confirm).toHaveBeenLastCalledWith('Open this link?\n\nhttp://localhost:3000/private');
expect(open).toHaveBeenCalledTimes(1);
for (const unsafe of ['javascript:alert(1)', 'data:text/html,hello', 'file:///etc/passwd']) {
handler.activate(null, unsafe);
}
expect(open).toHaveBeenCalledTimes(1);
expect(confirm).toHaveBeenCalledTimes(2);
});
});
describe('terminal link provider with wrapped table URLs', () => {
function harness(rows: Row[], cols: number) {
const openSpy = vi.fn();
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: openSpy,
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,
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, openSpy, app };
}
it.each(PROSE_ROWS)('preserves the original clickable URL before prose: %s', (first, next, url) => {
const { provide, openSpy } = harness([{ text: first }, { text: next }], 100);
const links = provide(1).filter((link) => /^https?:/.test(link.text));
expect(links.map((link) => link.text)).toEqual([url]);
links[0].activate(null, links[0].text);
expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer');
expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]);
});
it.each(NOT_CONTINUATION_ROWS)('keeps the URL and the second row separate: %s', (first, next, url, pathOnNext) => {
const { provide, openSpy } = harness([{ text: first }, { text: next }], 100);
const links = provide(1).filter((link) => /^https?:/.test(link.text));
expect(links.map((link) => link.text)).toEqual([url]);
links[0].activate(null, links[0].text);
expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer');
const second = provide(2);
expect(second.filter((link) => /^https?:/.test(link.text))).toEqual([]);
if (pathOnNext) expect(second.map((link) => link.text)).toContain(pathOnNext);
});
it.each(TABLE_LAST_COLUMN_ROWS)(
'keeps each last-column URL on its own row: %s',
(first, next, url, nextUrl, pathOnNext) => {
const { provide, openSpy } = harness([{ text: first }, { text: next }], 100);
const links = provide(1).filter((link) => /^https?:/.test(link.text));
expect(links.map((link) => link.text)).toEqual([url]);
links[0].activate(null, links[0].text);
expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer');
const second = provide(2);
expect(second.filter((link) => /^https?:/.test(link.text)).map((link) => link.text)).toEqual([nextUrl]);
if (pathOnNext) expect(second.map((link) => link.text)).toContain(pathOnNext);
}
);
it.each(FIELD_AND_CODE_ROWS)(
'keeps code, a field, a measurement or a sentence end separate: %s',
(first, next, url) => {
const { provide, openSpy } = harness([{ text: first }, { text: next }], 100);
const links = provide(1).filter((link) => /^https?:/.test(link.text));
expect(links.map((link) => link.text)).toEqual([url]);
links[0].activate(null, links[0].text);
expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer');
expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]);
}
);
it.each(STILL_CONTINUES_ROWS)('links the whole URL from both rows after %s', (first, next, joined) => {
const { provide, openSpy } = harness([{ text: first }, { text: next }], 100);
for (const line of [1, 2]) {
// One link per row the URL covers, each opening the whole URL.
const links = provide(line).filter((link) => /^https?:/.test(link.text));
expect(new Set(links.map((link) => link.text))).toEqual(new Set([joined]));
}
const [link] = provide(2).filter((l) => /^https?:/.test(l.text));
link.activate(null, link.text);
expect(openSpy).toHaveBeenCalledExactlyOnceWith(joined, '_blank', 'noopener,noreferrer');
});
it('keeps an indented email address out of the URL above it', () => {
const { provide } = harness([{ text: ' https://example.com/' }, { text: ' user@example.com' }], 100);
expect(
provide(1)
.filter((link) => /^https?:/.test(link.text))
.map((link) => link.text)
).toEqual(['https://example.com/']);
expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]);
});
it.each(TRADE_OFF_ROWS)('pins the known trade-off link after %s', (first, next, joined) => {
const { provide } = harness([{ text: first }, { text: next }], 100);
for (const line of [1, 2]) {
const links = provide(line).filter((link) => /^https?:/.test(link.text));
expect(links.map((link) => link.text)).toContain(joined);
}
});
it('keeps a full-width divider row out of a URL that reaches the right edge', () => {
const cols = 40;
const url = 'https://example.com/' + 'a'.repeat(cols - 'https://example.com/'.length);
for (const divider of ['─'.repeat(cols), '-'.repeat(cols)]) {
const { provide } = harness([{ text: url }, { text: divider }], cols);
expect(
provide(1)
.filter((link) => /^https?:/.test(link.text))
.map((link) => link.text)
).toEqual([url]);
expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]);
}
});
it('opens the whole URL from any of its rows', () => {
const { provide, openSpy } = harness(TABLE, COLS);
for (const line of [2, 3, 4]) {
const urlLinks = provide(line).filter((link) => link.text.startsWith('https://'));
expect(urlLinks.length).toBeGreaterThan(0);
for (const link of urlLinks) expect(link.text).toBe(URL);
const onRow = urlLinks.find((link) => link.range.start.y === line);
expect(onRow).toBeDefined();
onRow!.activate(null, onRow!.text);
expect(openSpy).toHaveBeenLastCalledWith(URL, '_blank', 'noopener,noreferrer');
}
});
it('opens the whole table URL without including the adjacent date and owner columns', () => {
const { provide, openSpy } = harness(TABLE_WITH_TRAILING_COLUMNS, TABLE_COLS);
for (const line of [2, 3, 4]) {
const links = provide(line).filter((link) => link.text.startsWith('https://'));
expect(links.length).toBeGreaterThan(0);
for (const link of links) expect(link.text).toBe(URL);
links[0].activate(null, links[0].text);
expect(openSpy).toHaveBeenLastCalledWith(URL, '_blank', 'noopener,noreferrer');
}
const firstRowLink = provide(2).find((link) => link.text === URL && link.range.start.y === 2);
expect(firstRowLink?.range.end.x).toBe(TABLE[1].text.length);
});
it('opens every segment after a full-width table divider', () => {
const rows = [{ text: '─'.repeat(TABLE_COLS) }, ...TABLE_WITH_TRAILING_COLUMNS.slice(1, 4)];
const { provide, openSpy } = harness(rows, TABLE_COLS);
for (const line of [2, 3, 4]) {
const link = provide(line).find((item) => item.text === URL && item.range.start.y === line);
expect(link).toBeDefined();
link!.activate(null, link!.text);
expect(openSpy).toHaveBeenLastCalledWith(URL, '_blank', 'noopener,noreferrer');
}
});
it('links the path in the column right of a wrapped URL on the URL first row', () => {
const { provide, app } = harness(URL_THEN_PATH_ROWS, URL_THEN_PATH_COLS);
const pathStart = URL_THEN_PATH_ROWS[0].text.indexOf('/home/');
const links = provide(1);
const path = links.find((link) => link.text === '/home/me/proj/docs/design.md');
expect(path).toBeDefined();
expect(path!.range.start).toEqual({ x: pathStart + 1, y: 1 });
expect(path!.range.end.y).toBe(1);
// The stitched URL keeps one link per row, and its first-row piece ends
// before the path's column.
const urlPieces = links.filter((link) => /^https?:/.test(link.text));
expect(new Set(urlPieces.map((link) => link.text))).toEqual(new Set([WIKI_URL]));
expect(urlPieces.map((link) => link.range.start.y)).toEqual([1, 2]);
expect(urlPieces[0].range.end.x).toBeLessThan(pathStart + 1);
// Nothing else is linked, so nothing glues the column onto the URL.
expect(links.map((link) => link.text).sort()).toEqual([WIKI_URL, WIKI_URL, '/home/me/proj/docs/design.md'].sort());
path!.activate(null, path!.text);
expect(app.openFilePreview).toHaveBeenCalledExactlyOnceWith('/home/me/proj/docs/design.md', 's1');
// The URL's second row still opens the whole URL, and the next record keeps its path.
expect(
new Set(
provide(2)
.filter((link) => /^https?:/.test(link.text))
.map((link) => link.text)
)
).toEqual(new Set([WIKI_URL]));
expect(provide(3).map((link) => link.text)).toContain('/home/me/proj/notes.md');
});
it('links a relative and a home path in the columns right of a wrapped URL', () => {
const { provide } = harness(URL_THEN_PATHS_ROWS, URL_THEN_PATH_COLS);
const row = URL_THEN_PATHS_ROWS[0].text;
const links = provide(1);
const startOf = (text: string) => links.find((link) => link.text === text)?.range.start;
expect(startOf('docs/design.md')).toEqual({ x: row.indexOf('docs/design.md') + 1, y: 1 });
expect(startOf('~/notes/todo.txt')).toEqual({ x: row.indexOf('~/notes/todo.txt') + 1, y: 1 });
expect(new Set(links.filter((link) => /^https?:/.test(link.text)).map((link) => link.text))).toEqual(
new Set([WIKI_URL])
);
});
it('links a URL in the column right of another URL whole, without stitching either', () => {
const { provide } = harness(URL_THEN_URL_ROWS, URL_THEN_PATH_COLS);
const row = URL_THEN_URL_ROWS[0].text;
const links = provide(1).filter((link) => /^https?:/.test(link.text));
expect(links.map((link) => [link.text, link.range.start.x, link.range.start.y])).toEqual([
['https://wiki.example.com/spaces/ENG/pages/12345/', row.indexOf('https://wiki') + 1, 1],
['https://other.example.com/notes.md', row.indexOf('https://other') + 1, 1],
]);
expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]);
});
it('underlines only the URL cells on each row, never the indent between pieces', () => {
const { provide } = harness(TABLE, COLS);
const segments = provide(3).filter((link) => link.text === URL);
expect(segments.map((link) => link.range.start.y)).toEqual([2, 3, 4]);
for (const link of segments) expect(link.range.start.y).toBe(link.range.end.y);
expect(segments[0].range.start.x).toBe(TABLE[1].text.indexOf('https://') + 1);
expect(segments[0].range.end.x).toBe(TABLE[1].text.length);
expect(segments[1].range.start.x).toBe(CELL + 1);
expect(segments[2].range.start.x).toBe(CELL + 1);
// Ends on `true`, not on the closing paren after it.
expect(segments[2].range.end.x).toBe(TABLE[3].text.length - 1);
});
it('links the column on a soft-wrapped URL row and the row below it separately', () => {
const { provide } = harness(SOFT_WRAPPED_URL_ROWS, SOFT_WRAP_COLS);
const texts = [1, 2, 3].flatMap((line) => provide(line).map((link) => link.text));
expect(texts.filter((text) => text.includes('report.txtccc'))).toEqual([]);
expect(provide(2).map((link) => link.text)).toContain('/tmp/report.txt');
expect(provide(3).map((link) => link.text)).toContain('ccc/x.html');
});
it('keeps a single-row URL with a paren pair whole', () => {
const line = 'Open https://example.com/wiki/Widget_(tool) please';
const { provide } = harness([{ text: line }], 80);
const [link] = provide(1).filter((l) => l.text.startsWith('https://'));
expect(link.text).toBe('https://example.com/wiki/Widget_(tool)');
});
});