test(renderer): 新增 cwd/链接/broadcast 纯函数表驱动测试
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:
Bill committed 2026-10-09 11:31:26 +08:00
1 parent 64982993f3
commit 12ee779040
4 files changed
+555 -1

No files matched your search

+1 -1
View File
@@ -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"
+183
View File
@@ -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')
+171
View File
@@ -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')
+200
View File
@@ -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')