diff --git a/package.json b/package.json index 2a5da4a..4f84e71 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/tests/broadcast-store.mjs b/tests/broadcast-store.mjs new file mode 100644 index 0000000..f41c367 --- /dev/null +++ b/tests/broadcast-store.mjs @@ -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') diff --git a/tests/terminal-cwd.mjs b/tests/terminal-cwd.mjs new file mode 100644 index 0000000..f7e70fb --- /dev/null +++ b/tests/terminal-cwd.mjs @@ -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') diff --git a/tests/terminal-links.mjs b/tests/terminal-links.mjs new file mode 100644 index 0000000..c11d215 --- /dev/null +++ b/tests/terminal-links.mjs @@ -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']]], + ['', [[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')