test(renderer): 新增 cwd/链接/broadcast 纯函数表驱动测试
CI / typecheck + test + build (windows) (push) Canceled after 0s
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 间接覆盖)
This commit is contained in:
1 parent
64982993f3
commit
12ee779040
4 files changed
+555
-1
No files matched your search
+1
-1
@@ -13,7 +13,7 @@
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
||||
"pretest": "npm run typecheck",
|
||||
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs",
|
||||
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs && node tests/terminal-cwd.mjs && node tests/terminal-links.mjs && node tests/broadcast-store.mjs",
|
||||
"predist": "node scripts/verify-deps.cjs && npm test && npm install --package-lock-only",
|
||||
"dist": "electron-vite build && electron-builder --win nsis",
|
||||
"dist:dir": "electron-vite build && electron-builder --win --dir"
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Broadcast-store self-test (broadcast-store.mjs).
|
||||
*
|
||||
* `src/renderer/src/workspace/broadcastStore.ts` decides who receives a keypress
|
||||
* typed into one pane, and it holds the two rules that are easy to get wrong:
|
||||
*
|
||||
* 1. fan-out only happens when broadcast is on AND the originating session is
|
||||
* itself a target — otherwise the keypress stays in the pane it came from;
|
||||
* 2. losing a target session wipes the whole selection and turns broadcast off
|
||||
* (`pruneOnTargetLoss`), while *unchecking* one by hand deliberately keeps
|
||||
* the remaining targets and only drops `enabled`. The two paths look alike
|
||||
* and are not.
|
||||
*
|
||||
* `pruneOnTargetLoss` is module-private, so its behaviour is pinned through the
|
||||
* only caller that can reach it (`unregisterSession`) instead of by exporting it.
|
||||
* The panel registry is module-level and append-only, so each scenario uses its
|
||||
* own ids and resets `enabled`/`targets` through the store's public `setState`.
|
||||
* The `window.api.writePty` surface is stubbed for the write-path scenarios.
|
||||
*
|
||||
* The import pulls in zustand (a devDependency); no React renderer is involved —
|
||||
* the store's `getState`/`setState` work on their own, which is why this test can
|
||||
* import the module directly instead of going through an esbuild bundle.
|
||||
* Run: node tests/broadcast-store.mjs (must exit 0)
|
||||
*/
|
||||
import { broadcastFanOut, useBroadcastStore, writeBroadcast } from '../src/renderer/src/workspace/broadcastStore.ts'
|
||||
|
||||
let failed = 0
|
||||
const ok = (cond, msg) => {
|
||||
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
||||
if (!cond) failed += 1
|
||||
}
|
||||
|
||||
const store = useBroadcastStore
|
||||
const state = () => store.getState()
|
||||
/** Reset only the fields these scenarios drive; the panel registry is shared. */
|
||||
const reset = () => store.setState({ enabled: false, targets: new Set() })
|
||||
const open = (panelId, id) => state().registerSession({ id, panelId, title: id, isSsh: false })
|
||||
const targets = () => [...state().targets]
|
||||
const openIds = () => state().sessions.map((s) => s.id)
|
||||
|
||||
console.log('broadcastFanOut: who receives the keypress')
|
||||
const fanOutCases = [
|
||||
['a', [], false, ['a']],
|
||||
['a', ['a'], false, ['a']],
|
||||
['a', ['a', 'b'], false, ['a']],
|
||||
['a', [], true, ['a']],
|
||||
['a', ['a'], true, ['a']],
|
||||
['a', ['b', 'c'], true, ['a']],
|
||||
['a', ['a', 'b'], true, ['a', 'b']],
|
||||
['b', ['b', 'a'], true, ['b', 'a']],
|
||||
['c', ['a', 'b', 'c'], true, ['a', 'b', 'c']]
|
||||
]
|
||||
for (const [origin, list, enabled, expected] of fanOutCases) {
|
||||
const got = broadcastFanOut(origin, new Set(list), enabled)
|
||||
const same = JSON.stringify(got) === JSON.stringify(expected)
|
||||
ok(
|
||||
same,
|
||||
`origin ${origin}, targets [${list}], enabled=${enabled} -> [${got}]${same ? '' : ` (want [${expected}])`}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log('broadcastFanOut returns a fresh array')
|
||||
const live = new Set(['a', 'b'])
|
||||
const out = broadcastFanOut('a', live, true)
|
||||
ok(Array.isArray(out) && out !== live, 'the result is not the live Set')
|
||||
out.push('c')
|
||||
ok(live.size === 2 && !live.has('c'), 'mutating the result does not touch the live target set')
|
||||
|
||||
console.log('losing a target session wipes the selection and turns broadcast off')
|
||||
reset()
|
||||
open('a-p1', 'a1')
|
||||
open('a-p2', 'a2')
|
||||
state().toggleTarget('a1')
|
||||
state().toggleTarget('a2')
|
||||
ok(state().enabled === true && targets().join() === 'a1,a2', 'two targets turn broadcast on')
|
||||
state().unregisterSession('a-p2')
|
||||
ok(state().enabled === false, 'closing the pane that showed a target turns broadcast off')
|
||||
ok(targets().length === 0, 'the whole selection is dropped, not just the lost id')
|
||||
ok(!openIds().includes('a2'), 'the closed session left the registry')
|
||||
ok(openIds().includes('a1'), 'the session that is still open stays')
|
||||
|
||||
console.log('unchecking by hand keeps the rest of the selection')
|
||||
reset()
|
||||
open('b-p1', 'b1')
|
||||
open('b-p2', 'b2')
|
||||
open('b-p3', 'b3')
|
||||
for (const id of ['b1', 'b2', 'b3']) state().toggleTarget(id)
|
||||
ok(state().enabled === true && targets().length === 3, 'three targets turn broadcast on')
|
||||
state().toggleTarget('b3')
|
||||
ok(state().enabled === true && targets().join() === 'b1,b2', 'dropping to two targets keeps it on')
|
||||
state().toggleTarget('b2')
|
||||
ok(
|
||||
state().enabled === false && targets().join() === 'b1',
|
||||
'dropping below the minimum turns it off but keeps the remaining target'
|
||||
)
|
||||
|
||||
console.log('a session mirrored in two panes survives one of them closing')
|
||||
reset()
|
||||
open('c-pA', 'c1')
|
||||
open('c-pB', 'c1')
|
||||
open('c-pC', 'c2')
|
||||
ok(openIds().filter((id) => id === 'c1').length === 1, 'two panels showing one session register it once')
|
||||
state().toggleTarget('c1')
|
||||
state().toggleTarget('c2')
|
||||
ok(state().enabled === true && targets().join() === 'c1,c2', 'c1 is mirrored, c2 is not')
|
||||
state().unregisterSession('c-pA')
|
||||
ok(openIds().includes('c1'), 'the mirrored session is still open through the other pane')
|
||||
ok(targets().join() === 'c1,c2' && state().enabled === true, 'and keeps its target slot')
|
||||
state().unregisterSession('c-pB')
|
||||
ok(!openIds().includes('c1'), 'the last pane showing it takes the session out of the registry')
|
||||
ok(state().enabled === false && targets().length === 0, 'losing it prunes the target set')
|
||||
|
||||
console.log('broadcast needs two targets to turn on')
|
||||
reset()
|
||||
open('d-p1', 'd1')
|
||||
state().setEnabled(true)
|
||||
ok(state().enabled === false, 'enabling with no targets is refused')
|
||||
state().toggleTarget('d1')
|
||||
ok(state().enabled === false && targets().join() === 'd1', 'a single target stays off')
|
||||
state().setEnabled(true)
|
||||
ok(state().enabled === false, 'still refused with one target')
|
||||
open('d-p2', 'd2')
|
||||
state().toggleTarget('d2')
|
||||
ok(state().enabled === true, 'the second target turns it on')
|
||||
state().setEnabled(false)
|
||||
ok(state().enabled === false, 'it can always be turned off')
|
||||
state().setEnabled(true)
|
||||
ok(state().enabled === true, 'and back on while two targets remain')
|
||||
|
||||
console.log('unregistering an unknown or already-dropped panel is a no-op')
|
||||
reset()
|
||||
open('e-p1', 'e1')
|
||||
open('e-p2', 'e2')
|
||||
state().toggleTarget('e1')
|
||||
state().toggleTarget('e2')
|
||||
state().unregisterSession('e-never-registered')
|
||||
ok(state().enabled === true && targets().join() === 'e1,e2', 'an unknown panel id changes nothing')
|
||||
ok(openIds().includes('e1') && openIds().includes('e2'), 'and drops no session')
|
||||
state().unregisterSession('e-p1')
|
||||
const snapshot = JSON.stringify({ enabled: state().enabled, targets: targets(), sessions: openIds() })
|
||||
state().unregisterSession('e-p1')
|
||||
ok(
|
||||
JSON.stringify({ enabled: state().enabled, targets: targets(), sessions: openIds() }) === snapshot,
|
||||
'a second unregister of the same panel changes nothing'
|
||||
)
|
||||
|
||||
console.log('writeBroadcast fans out through the live registry')
|
||||
reset()
|
||||
open('f-p1', 'f1')
|
||||
open('f-p2', 'f2')
|
||||
state().toggleTarget('f1')
|
||||
state().toggleTarget('f2')
|
||||
const writes = []
|
||||
globalThis.window = { api: { writePty: (id, data) => writes.push(`${id}:${data}`) } }
|
||||
try {
|
||||
writeBroadcast('f1', 'ls')
|
||||
ok(writes.join(' ') === 'f1:ls f2:ls', 'an enabled origin that is a target reaches every target')
|
||||
writes.length = 0
|
||||
store.setState({ targets: new Set(['f1', 'f2', 'f-ghost']) })
|
||||
writeBroadcast('f1', 'x')
|
||||
ok(writes.join(' ') === 'f1:x f2:x', 'a target with no open session is skipped')
|
||||
writes.length = 0
|
||||
store.setState({ enabled: false })
|
||||
writeBroadcast('f2', 'y')
|
||||
ok(writes.join(' ') === 'f2:y', 'with broadcast off only the origin is written')
|
||||
writes.length = 0
|
||||
store.setState({ enabled: true, targets: new Set(['f1', 'f2']) })
|
||||
open('f-p3', 'f3')
|
||||
writeBroadcast('f3', 'q')
|
||||
ok(writes.join(' ') === 'f3:q', 'an open origin that is not a target writes only to itself')
|
||||
writes.length = 0
|
||||
store.setState({ enabled: false })
|
||||
writeBroadcast('f-gone', 'w')
|
||||
ok(writes.length === 0, 'a closed origin writes nowhere')
|
||||
} finally {
|
||||
delete globalThis.window
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n[broadcast-store] ${failed} check(s) FAILED`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\n[broadcast-store] ALL CHECKS PASSED')
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Working-directory tracking self-test (terminal-cwd.mjs).
|
||||
*
|
||||
* `src/renderer/src/terminal/cwdTracker.ts` is the renderer half of cwd memory:
|
||||
* it turns a submitted command line into a cd argument (or null) and unwraps
|
||||
* ConEmu's OSC 9 payload. Everything platform-specific — path resolution,
|
||||
* existence checks, quote stripping, `cd -` rejection — lives in the main
|
||||
* process (`src/main/cwd.ts`), so the contract pinned here is deliberately
|
||||
* narrow, and the table says which side owns what:
|
||||
*
|
||||
* - the cd family per shell (cd / chdir / sl / Set-Location / pushd /
|
||||
* Push-Location) and the bare `d:` drive switch;
|
||||
* - an EMPTY STRING means "bare cd" (home) and is NOT the same answer as
|
||||
* null ("not a cd line") — the caller only resolves on non-null;
|
||||
* - quotes and trailing arguments are handed to main verbatim: this module is
|
||||
* not a shell parser, so `cd "D:\Program Files"` keeps its quotes and
|
||||
* `cd /tmp && ls` keeps its separator;
|
||||
* - lookalikes (`cdrom`, `cdx`, `sleep 1`, `VAR=x cd y`) never match, since a
|
||||
* false positive would move the remembered directory on an unrelated line.
|
||||
*
|
||||
* Unlike the esbuild-bundled tests, this one imports the .ts module directly:
|
||||
* it has no imports and no runtime-only TS syntax, and Node >= 22.18 strips the
|
||||
* type annotations itself. Run: node tests/terminal-cwd.mjs (must exit 0)
|
||||
*/
|
||||
import { cdArgument, conemuCwd } from '../src/renderer/src/terminal/cwdTracker.ts'
|
||||
|
||||
let failed = 0
|
||||
const ok = (cond, msg) => {
|
||||
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
||||
if (!cond) failed += 1
|
||||
}
|
||||
|
||||
/** Table-driven: [line, expected] — `null` = "not a cd line", `''` = bare cd. */
|
||||
const table = (label, cases, fn) => {
|
||||
console.log(label)
|
||||
for (const [input, expected] of cases) {
|
||||
const got = fn(input)
|
||||
ok(
|
||||
got === expected,
|
||||
`${JSON.stringify(input)} -> ${JSON.stringify(got)}${got === expected ? '' : ` (want ${JSON.stringify(expected)})`}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
table(
|
||||
'the cd family returns the raw argument (main does the path work)',
|
||||
[
|
||||
['cd /path', '/path'],
|
||||
['cd\t/tmp', '/tmp'],
|
||||
['cd\n/tmp', '/tmp'],
|
||||
['cd ~/x', '~/x'],
|
||||
['cd ../x', '../x'],
|
||||
['cd -', '-'],
|
||||
['cd .', '.'],
|
||||
['cd D:\\projects', 'D:\\projects'],
|
||||
['cd "D:\\Program Files"', '"D:\\Program Files"'],
|
||||
["cd '~/src'", "'~/src'"],
|
||||
['cd ""', '""']
|
||||
],
|
||||
cdArgument
|
||||
)
|
||||
|
||||
table(
|
||||
'every shell spelling is recognised, case-insensitively',
|
||||
[
|
||||
['CD /tmp', '/tmp'],
|
||||
['Cd /tmp', '/tmp'],
|
||||
[' cD /tmp ', '/tmp'],
|
||||
['chdir C:\\x', 'C:\\x'],
|
||||
['CHDIR ..', '..'],
|
||||
['sl .', '.'],
|
||||
['sl', ''],
|
||||
['Set-Location /a', '/a'],
|
||||
['set-location /a', '/a'],
|
||||
['pushd ../b', '../b'],
|
||||
['Push-Location ~/c', '~/c']
|
||||
],
|
||||
cdArgument
|
||||
)
|
||||
|
||||
table(
|
||||
'bare cd is the empty-string sentinel, not null',
|
||||
[
|
||||
['cd', ''],
|
||||
['cd ', ''],
|
||||
[' cd ', ''],
|
||||
['cd\t', '']
|
||||
],
|
||||
cdArgument
|
||||
)
|
||||
|
||||
table(
|
||||
'trailing arguments and separators stay in the argument',
|
||||
[
|
||||
['cd /tmp ', '/tmp'],
|
||||
['cd /a b', '/a b'],
|
||||
['cd /tmp extra', '/tmp extra'],
|
||||
['cd /tmp && ls', '/tmp && ls'],
|
||||
['cd /tmp; ls', '/tmp; ls'],
|
||||
['cd /tmp | more', '/tmp | more']
|
||||
],
|
||||
cdArgument
|
||||
)
|
||||
|
||||
table(
|
||||
'the bare drive switch resolves to the drive root',
|
||||
[
|
||||
['d:', 'D:\\'],
|
||||
['D:', 'D:\\'],
|
||||
[' d: ', 'D:\\'],
|
||||
['a:', 'A:\\'],
|
||||
['z:', 'Z:\\']
|
||||
],
|
||||
cdArgument
|
||||
)
|
||||
|
||||
table(
|
||||
'lines that are not a cd are null (no false positives)',
|
||||
[
|
||||
['', null],
|
||||
[' ', null],
|
||||
['cdx /tmp', null],
|
||||
['cdrom', null],
|
||||
['cdemo /x', null],
|
||||
['slack', null],
|
||||
['sleep 1', null],
|
||||
['VAR=x cd y', null],
|
||||
['sudo cd /x', null],
|
||||
['echo cd /x', null],
|
||||
['ls -la', null],
|
||||
['d:\\', null],
|
||||
['..', null],
|
||||
['~', null]
|
||||
],
|
||||
cdArgument
|
||||
)
|
||||
|
||||
console.log('the empty-string answer is distinct from "not a cd line"')
|
||||
ok(cdArgument('cd') === '', 'a bare cd answers with the empty string')
|
||||
ok(cdArgument('cd') !== null, 'and is not null, so the caller still resolves it')
|
||||
ok(cdArgument('ls') === null, 'a non-cd line answers null')
|
||||
|
||||
table(
|
||||
'ConEmu OSC 9;9 carries a bare path',
|
||||
[
|
||||
['9;/home/a', '/home/a'],
|
||||
['9;C:\\Users\\a', 'C:\\Users\\a'],
|
||||
['9;C:\\Program Files', 'C:\\Program Files'],
|
||||
['9;/a\n', '/a'],
|
||||
['9; /a ', '/a'],
|
||||
['9; ', null],
|
||||
['9;', null],
|
||||
['9', null],
|
||||
['90;x', null],
|
||||
['9;;x', ';x'],
|
||||
['', null],
|
||||
['7;file://host/p', null],
|
||||
['8;/a', null]
|
||||
],
|
||||
conemuCwd
|
||||
)
|
||||
|
||||
console.log('OSC 9 payloads that are not ConEmu cwd reports are rejected')
|
||||
ok(conemuCwd('9;1;2') === '1;2', 'the remainder after the convention marker is the path')
|
||||
ok(conemuCwd('9;file://host/p') === 'file://host/p', 'a file:// body is passed through for main to normalize')
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n[terminal-cwd] ${failed} check(s) FAILED`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\n[terminal-cwd] ALL CHECKS PASSED')
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 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')
|
||||
Reference in new issue
Block a user