CI / typecheck + test + build (windows) (push) Canceled after 0s
渲染层纯逻辑此前零测试覆盖。三个新测试直接 import .ts 源文件(Node 22.18+ 原生类型剥离,不经 esbuild bundle): - terminal-cwd(69 断言):cdArgument 全部 shell 变体与 conemuCwd 载荷 - terminal-links(92 断言):findUrls 真实合同——尾随标点剥离、重叠 收敛、裸 host 补 scheme、36 条不变式 - broadcast-store(39 断言):broadcastFanOut 扇出与 pruneOnTargetLoss (经 unregisterSession 间接覆盖)
201 lines
7.9 KiB
JavaScript
201 lines
7.9 KiB
JavaScript
/**
|
|
* Clickable-URL self-test (terminal-links.mjs).
|
|
*
|
|
* `src/renderer/src/terminal/urlLinks.ts` feeds xterm's link provider: it scans
|
|
* one *logical* line (the provider rebuilds it across wrapped rows first) and
|
|
* returns half-open `[start, end)` ranges plus a scheme-carrying target. The
|
|
* ranges are what the terminal underlines and what a click opens, so the table
|
|
* pins the real contract rather than an idealised one:
|
|
*
|
|
* - `http(s)://` and `ftp://` are handed over as written (the match is
|
|
* case-insensitive, the target keeps the original case); `www.…`,
|
|
* `localhost:port`, `127.0.0.1:port` and `0.0.0.0:port` get an `http://`
|
|
* target added;
|
|
* - a bare host WITHOUT a port is not a link, and a bare domain without `www.`
|
|
* never is — the two shapes that would otherwise make every `foo.com` in
|
|
* build output clickable;
|
|
* - trailing sentence punctuation (`. , ; : ! ? ) ] } > ' "` plus the CJK
|
|
* forms) is stripped from the URL *and* from `end`, so the closing bracket
|
|
* of a sentence is not underlined. Brackets, quotes and whitespace cannot
|
|
* appear inside the body at all;
|
|
* - ranges never overlap: `http://localhost:5000` also matches the bare-host
|
|
* pattern, and the longer, scheme-carrying match wins;
|
|
* - the body is greedy up to the next whitespace, so `http://a/http://b` is
|
|
* one link — a documented limitation, not an accident to be "fixed" here.
|
|
*
|
|
* Direct .ts import (no esbuild bundle needed): the module has no imports and no
|
|
* runtime-only TS syntax, and Node >= 22.18 strips types itself.
|
|
* Run: node tests/terminal-links.mjs (must exit 0)
|
|
*/
|
|
import { findUrls } from '../src/renderer/src/terminal/urlLinks.ts'
|
|
|
|
let failed = 0
|
|
const ok = (cond, msg) => {
|
|
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
|
if (!cond) failed += 1
|
|
}
|
|
|
|
/** [start, end, url] triples — compact enough to keep the tables readable. */
|
|
const triples = (text) => findUrls(text).map((l) => [l.start, l.end, l.url])
|
|
|
|
const table = (label, cases) => {
|
|
console.log(label)
|
|
for (const [text, expected] of cases) {
|
|
const got = triples(text)
|
|
ok(
|
|
JSON.stringify(got) === JSON.stringify(expected),
|
|
`${JSON.stringify(text)} -> ${JSON.stringify(got)}${JSON.stringify(got) === JSON.stringify(expected) ? '' : ` (want ${JSON.stringify(expected)})`}`
|
|
)
|
|
}
|
|
}
|
|
|
|
table('fully qualified http/https URLs are handed over as written', [
|
|
['https://example.com', [[0, 19, 'https://example.com']]],
|
|
['http://example.com/a/b?c=1#frag', [[0, 31, 'http://example.com/a/b?c=1#frag']]],
|
|
['see https://example.com/a.', [[4, 25, 'https://example.com/a']]],
|
|
['https://user:pass@host.com:8443/x', [[0, 33, 'https://user:pass@host.com:8443/x']]],
|
|
['\t\thttps://example.com/x', [[2, 23, 'https://example.com/x']]]
|
|
])
|
|
|
|
table('matching is case-insensitive, the target keeps the original case', [
|
|
['HTTP://EXAMPLE.COM', [[0, 18, 'HTTP://EXAMPLE.COM']]],
|
|
['Visit HTTPS://Example.COM/Path', [[6, 30, 'HTTPS://Example.COM/Path']]]
|
|
])
|
|
|
|
table('trailing sentence punctuation is not part of the link', [
|
|
['go to https://example.com.', [[6, 25, 'https://example.com']]],
|
|
['https://example.com, then', [[0, 19, 'https://example.com']]],
|
|
['https://example.com/a; ls', [[0, 21, 'https://example.com/a']]],
|
|
['https://example.com/a,;:!?', [[0, 21, 'https://example.com/a']]],
|
|
['(https://example.com/a)', [[1, 22, 'https://example.com/a']]],
|
|
['<https://example.com/a>', [[1, 22, 'https://example.com/a']]],
|
|
['"https://example.com/a"', [[1, 22, 'https://example.com/a']]],
|
|
["'https://example.com/a'", [[1, 22, 'https://example.com/a']]],
|
|
['[https://example.com/a]', [[1, 22, 'https://example.com/a']]],
|
|
['https://example.com/a).', [[0, 21, 'https://example.com/a']]]
|
|
])
|
|
|
|
table('CJK punctuation ends the link too', [
|
|
['中文 https://example.com/路径。然后', [[3, 25, 'https://example.com/路径']]],
|
|
['见 https://example.com/路径,还有', [[2, 24, 'https://example.com/路径']]],
|
|
['https://example.com/a、b', [[0, 21, 'https://example.com/a']]]
|
|
])
|
|
|
|
table('several URLs on one line keep their own offsets', [
|
|
[
|
|
'go to http://a.com and https://b.org/x?y=1!',
|
|
[
|
|
[6, 18, 'http://a.com'],
|
|
[23, 42, 'https://b.org/x?y=1']
|
|
]
|
|
],
|
|
[
|
|
'x https://a.com https://a.com y',
|
|
[
|
|
[2, 15, 'https://a.com'],
|
|
[16, 29, 'https://a.com']
|
|
]
|
|
]
|
|
])
|
|
|
|
table('overlapping matches collapse to the longest one', [
|
|
['http://localhost:5000', [[0, 21, 'http://localhost:5000']]],
|
|
['http://localhost:5000/x', [[0, 23, 'http://localhost:5000/x']]],
|
|
['localhost:3000 http://localhost:3000', [
|
|
[0, 14, 'http://localhost:3000'],
|
|
[15, 36, 'http://localhost:3000']
|
|
]],
|
|
['http://127.0.0.1:8080/x', [[0, 23, 'http://127.0.0.1:8080/x']]]
|
|
])
|
|
|
|
table('ftp is recognised as a scheme of its own', [
|
|
['ftp://files.example.com/pub', [[0, 27, 'ftp://files.example.com/pub']]],
|
|
['ftp://files.example.com/pub.', [[0, 27, 'ftp://files.example.com/pub']]]
|
|
])
|
|
|
|
table('scheme-less dev-server forms get an http:// target', [
|
|
['localhost:5000', [[0, 14, 'http://localhost:5000']]],
|
|
['localhost:5000/x', [[0, 16, 'http://localhost:5000/x']]],
|
|
[' Listening on http://0.0.0.0:3000', [[15, 34, 'http://0.0.0.0:3000']]],
|
|
['127.0.0.1:8080/x', [[0, 16, 'http://127.0.0.1:8080/x']]],
|
|
['www.example.com/path.', [[0, 20, 'http://www.example.com/path']]],
|
|
['WWW.EXAMPLE.COM', [[0, 15, 'http://WWW.EXAMPLE.COM']]],
|
|
['see www.example.com:8080', [[4, 24, 'http://www.example.com:8080']]]
|
|
])
|
|
|
|
table('shapes that must NOT become links', [
|
|
['no links here', []],
|
|
['', []],
|
|
['a.com', []],
|
|
['localhost', []],
|
|
['0.0.0.0', []],
|
|
['127.0.0.1', []],
|
|
['localhost:', []],
|
|
['xhttps://a.com', []],
|
|
['https://', []],
|
|
['mailto:user@example.com', []],
|
|
['file:///c:/x', []],
|
|
['ssh://git@host/repo', []],
|
|
['C:\\Users\\me', []],
|
|
['www.', []],
|
|
['www.example', []]
|
|
])
|
|
|
|
table('documented greedy behaviour: the body runs to the next whitespace', [
|
|
['http://a.com/http://b.com', [[0, 25, 'http://a.com/http://b.com']]],
|
|
['https://a.com/x\tand', [[0, 15, 'https://a.com/x']]],
|
|
['https://a.com/x and', [[0, 15, 'https://a.com/x']]]
|
|
])
|
|
|
|
console.log('the optional path group of a bare host needs a body character')
|
|
ok(
|
|
JSON.stringify(triples('localhost:5000/')) === JSON.stringify([[0, 14, 'http://localhost:5000']]),
|
|
'a trailing slash with nothing behind it is left out of the range'
|
|
)
|
|
|
|
console.log('every range is consistent with its own text')
|
|
const corpus = [
|
|
'go to http://a.com and https://b.org/x?y=1!',
|
|
'(https://a.com/a)',
|
|
'http://a.com/http://b.com',
|
|
'plain output, no links',
|
|
'http://localhost:5000/x and www.example.com/y, then https://c.jp/z。',
|
|
'ftp://f.io/a https://g.io/b',
|
|
'https://example.com/path).',
|
|
'localhost:3000/',
|
|
'https://'
|
|
]
|
|
for (const text of corpus) {
|
|
const found = findUrls(text)
|
|
const ranges = found.map((l) => `${l.start}-${l.end}`)
|
|
ok(
|
|
found.every((l, i) => i === 0 || found[i - 1].end <= l.start),
|
|
`${JSON.stringify(text)}: ranges do not overlap (${ranges.join(' ')})`
|
|
)
|
|
ok(
|
|
found.every((l) => l.start >= 0 && l.end <= text.length && l.end > l.start),
|
|
`${JSON.stringify(text)}: every range is inside the line and non-empty`
|
|
)
|
|
ok(
|
|
found.every((l) => l.url.length === l.end - l.start || l.url.endsWith(text.slice(l.start, l.end))),
|
|
`${JSON.stringify(text)}: the target matches the range it covers`
|
|
)
|
|
ok(
|
|
found.every((l) => /^[a-z][a-z0-9+.-]*:\/\//i.test(l.url)),
|
|
`${JSON.stringify(text)}: every target carries a scheme`
|
|
)
|
|
}
|
|
|
|
console.log('the target of a bare host is the matched text behind http://')
|
|
ok(findUrls('localhost:5000')[0].url === 'http://localhost:5000', 'the matched text is kept verbatim behind the scheme')
|
|
ok(
|
|
findUrls('go to https://example.com.')[0].url === 'https://example.com',
|
|
'a scheme URL is not prefixed a second time'
|
|
)
|
|
|
|
if (failed > 0) {
|
|
console.error(`\n[terminal-links] ${failed} check(s) FAILED`)
|
|
process.exit(1)
|
|
}
|
|
console.log('\n[terminal-links] ALL CHECKS PASSED')
|