Files
OpenTerminal/tests/link-rules.mjs
T

361 lines
14 KiB
JavaScript
Raw 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.
/**
* Clickable link rule self-test (link-rules.mjs).
*
* `src/shared/linkRules.ts` is the whole engine behind "regex hits terminal
* output → the match becomes a clickable link": validation on load, template
* expansion, the scheme gate and the span merge that keeps user rules and the
* built-in URL heuristics from overlapping.
*
* The tables below pin behaviour that is easy to break silently:
*
* - sanitize drops what cannot work (no id, an uncompilable regex, an empty
* template, a duplicate id) and keeps what merely defaults (`enabled`
* absent = on);
* - `formatLinkTemplate` expands `$0`–`$9` and `$$`, leaves a `$` before
* anything else alone, and consumes only ONE digit (`$10` = group 1 + "0");
* - a missing / non-participating group expands to nothing rather than the
* literal "undefined";
* - the scheme gate rejects `file:`, `javascript:`, `ms-settings:` and
* anything unparsable — a template is user input and must never hand an
* arbitrary handler to the OS;
* - `findRuleLinks` claims earlier rules first and never emits overlapping
* ranges, including a pattern that can match the empty string;
* - `mergeLinkSpans` keeps the user rule and drops the colliding built-in.
*
* The rules written here are all legitimate, so `proved()` stamps each one with
* the proof the compile gate now requires (a rule without one is inert by
* design — the gate itself is pinned in tests/rule-validation.mjs, not here).
*
* Direct .ts import (no esbuild bundle): the module has no extension-less
* imports and no runtime-only TS syntax, and Node >= 22.18 strips types itself.
* Run: node tests/link-rules.mjs (must exit 0)
*/
import {
LINK_NOTE_MAX,
LINK_PATTERN_MAX,
LINK_RULES_MAX,
LINK_TEMPLATE_MAX,
LINK_TEXT_MAX,
compileLinkRules,
formatLinkTemplate,
findRuleLinks,
isSafeLinkTarget,
mergeLinkSpans,
sanitizeLinkRules,
isSafeLinkTemplate
} from '../src/shared/linkRules.ts'
import { PROBE_VERSION } from '../src/shared/redosProbes.ts'
import { LINK_RULE_FLAGS, setRuleRuntimeKey } from '../src/shared/ruleValidation.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
/**
* The environment the proofs below belong to. The main process installs its own
* fingerprint and the renderer is handed the same string over IPC; here it is a
* literal, and installing it is what makes a proof count for THIS engine.
*/
const RUNTIME = 'test-app|test-electron|test-v8'
setRuleRuntimeKey(RUNTIME)
/** Stamp the proof the gate demands for this exact pattern + flag string. */
const proved = (rule) => ({
...rule,
validation: { status: 'passed', pattern: rule.pattern, flags: LINK_RULE_FLAGS, probeVersion: PROBE_VERSION, runtime: RUNTIME }
})
const compile = (rules) => compileLinkRules(rules.map(proved))
const raw = (over) => ({ id: 'r1', pattern: '\\b(\\d+)\\b', template: 'https://x/$1', ...over })
// ---- sanitize ---------------------------------------------------------------
console.log('[sanitize]')
{
const out = sanitizeLinkRules([raw()])
ok(out.length === 1, 'a well-formed rule is kept')
ok(out[0].enabled === true, 'an absent enabled flag defaults to true')
ok(out[0].note === undefined, 'an absent note stays undefined (not "")')
}
{
const out = sanitizeLinkRules([raw({ enabled: false })])
ok(out[0].enabled === false, 'enabled: false survives')
}
{
const out = sanitizeLinkRules([raw({ note: ' build id ' })])
ok(out[0].note === 'build id', 'the note is trimmed')
}
{
const long = 'n'.repeat(LINK_NOTE_MAX + 20)
const out = sanitizeLinkRules([raw({ note: long })])
ok(out[0].note.length === LINK_NOTE_MAX, `a long note is truncated to ${LINK_NOTE_MAX}`)
}
const dropped = [
['a non-array value', 'nope'],
['null', null],
['an array of scalars', ['x', 1, null]],
['a missing id', [raw({ id: undefined })]],
['an empty id', [raw({ id: '' })]],
['a numeric id', [raw({ id: 7 })]],
['a missing pattern', [raw({ pattern: undefined })]],
['an empty pattern', [raw({ pattern: '' })]],
['a whitespace-only pattern', [raw({ pattern: ' ' })]],
['an uncompilable regex', [raw({ pattern: '(unclosed' })]],
['a missing template', [raw({ template: undefined })]],
['an empty template', [raw({ template: '' })]],
['a whitespace-only template', [raw({ template: ' ' })]],
['an over-long pattern', [raw({ pattern: 'a'.repeat(LINK_PATTERN_MAX + 1) })]]
]
for (const [label, value] of dropped) {
const out = sanitizeLinkRules(value)
ok(out.length === 0, `${label} yields no rules (got ${JSON.stringify(out)})`)
}
{
const warnings = []
sanitizeLinkRules([raw(), raw()], (m) => warnings.push(m))
// two identical entries share id 'r1': the second is a duplicate
const out = sanitizeLinkRules([raw(), raw(), raw({ id: 'r2' })])
ok(out.length === 2 && out[1].id === 'r2', 'a duplicate id is dropped, later distinct ids survive')
ok(warnings.length >= 1, 'the warn callback receives a message for each dropped entry')
}
{
const many = Array.from({ length: LINK_RULES_MAX + 5 }, (_, i) => raw({ id: `r${i}` }))
const out = sanitizeLinkRules(many)
ok(out.length === LINK_RULES_MAX, `no more than ${LINK_RULES_MAX} rules are kept (got ${out.length})`)
}
{
// an unparsable regex would otherwise throw on every line the terminal draws
const out = sanitizeLinkRules([raw({ pattern: 'a{2,1}' })])
ok(out.length === 0, 'a regex with an invalid quantifier range is dropped')
}
// ---- formatLinkTemplate -----------------------------------------------------
console.log('[formatLinkTemplate]')
const expand = (template, source, pattern) =>
formatLinkTemplate(template, new RegExp(pattern, 'g').exec(source))
const formatCases = [
['$0 is the whole match', 'https://x/$0', 'abc 123', '\\d+', 'https://x/123'],
['$1 is the first group', 'https://x/$1', 'abc 123', '(\\d+)', 'https://x/123'],
['$2 is the second group', 'https://x/$2/$1', 'k=v', '=(\\w+)', 'https://x//v'],
['$0 and $1 together', '$0|$1', 'ab12', '([a-z]+)(\\d+)', 'ab12|ab'],
['$9 is the ninth group', 'https://x/$9', '123456789', '(1)(2)(3)(4)(5)(6)(7)(8)(9)', 'https://x/9'],
['a template with no placeholders is literal', 'https://x/static', 'abc', 'abc', 'https://x/static'],
['$$ is a literal $', 'https://x/a$$b', 'abc', 'abc', 'https://x/a$b'],
['a $ before a letter stays literal', 'https://x/$a', 'abc', 'abc', 'https://x/$a'],
['a trailing $ stays literal', 'https://x/$', 'abc', 'abc', 'https://x/$'],
['$10 reads group 1 then a literal 0', 'https://x/$10', 'ab', '([a-z])b', 'https://x/a0'],
[
'a non-participating group expands to nothing',
'https://x/$1$2',
'ab',
'(a)(b)?',
'https://x/ab'
]
]
for (const [label, template, source, pattern, expected] of formatCases) {
const got = expand(template, source, pattern)
ok(got === expected, `${label}: ${JSON.stringify(got)}${got === expected ? '' : ` (want ${JSON.stringify(expected)})`}`)
}
{
// the "undefined" leak that made a $2 on a one-group match render as text
const got = expand('https://x/$2', 'abc', '(\\w+)')
ok(!got.includes('undefined'), 'a group the pattern does not have never renders "undefined"')
}
// ---- isSafeLinkTarget -------------------------------------------------------
console.log('[isSafeLinkTarget]')
const safe = ['https://example.com', 'http://localhost:5000/x', 'HTTPS://EXAMPLE.COM', ' https://a.b ']
for (const url of safe) ok(isSafeLinkTarget(url), `${JSON.stringify(url)} is allowed`)
const unsafe = [
'',
' ',
'file:///c:/windows',
'javascript:alert(1)',
'ms-settings:privacy',
'vscode://file/x',
'ftp://f.example.com',
'//example.com',
'/relative/path',
'example.com',
'mailto:user@example.com'
]
for (const url of unsafe) ok(!isSafeLinkTarget(url), `${JSON.stringify(url)} is refused`)
// ---- isSafeLinkTemplate -----------------------------------------------------
console.log('[isSafeLinkTemplate]')
const templateSafe = [
'https://example.com',
'http://localhost:5000/$1',
' https://a.b/$2 ',
'$0',
'$1',
'$9/x'
]
for (const template of templateSafe) {
ok(isSafeLinkTemplate(template), `template ${JSON.stringify(template)} is allowed`)
}
const templateUnsafe = [
'',
' ',
'file:///c:/windows',
'javascript:alert(1)',
'ms-settings:privacy',
'ftp://f.example.com/$1',
'/relative/$1',
'example.com/$1',
'www.example.com/$1'
]
for (const template of templateUnsafe) {
ok(!isSafeLinkTemplate(template), `template ${JSON.stringify(template)} is refused`)
}
// ---- compileLinkRules -------------------------------------------------------
console.log('[compileLinkRules]')
{
ok(compileLinkRules(undefined).length === 0, 'an absent rule list compiles to nothing')
ok(compileLinkRules([]).length === 0, 'an empty rule list compiles to nothing')
const compiled = compile([
{ id: 'a', pattern: 'x', template: 'https://x/a' },
{ id: 'b', pattern: 'y', template: 'https://x/b', enabled: false },
{ id: 'c', pattern: 'z', template: 'https://x/c', enabled: true }
])
ok(compiled.length === 2, 'disabled rules are not compiled')
ok(compiled[0].regex.flags.includes('g'), 'the compiled regex is global')
// The compile gate is fail-closed: a rule with no proof (or one that does not
// match its pattern) never reaches the terminal. `tests/rule-validation.mjs`
// owns the full truth table; this is the caller-side reminder that `proved()`
// is load-bearing for every table above.
ok(compileLinkRules([{ id: 'a', pattern: 'x', template: 'https://x/a' }]).length === 0, 'an unproven rule is inert')
ok(compiled.every((r) => r.regex.flags === LINK_RULE_FLAGS), `every compiled rule uses '${LINK_RULE_FLAGS}'`)
}
// ---- findRuleLinks ----------------------------------------------------------
console.log('[findRuleLinks]')
const find = (text, rules) => findRuleLinks(text, compile(rules)).map((s) => [s.start, s.end, s.url])
const ruleSet = [{ id: 't', pattern: 'BUG-(\\d+)', template: 'https://tracker/bug/$1' }]
{
ok(
JSON.stringify(find('see BUG-42 please', ruleSet)) ===
JSON.stringify([[4, 10, 'https://tracker/bug/42']]),
'a match becomes a range carrying the expanded target'
)
ok(
JSON.stringify(find('no match here', ruleSet)) === JSON.stringify([]),
'a line without a match yields no ranges'
)
ok(
JSON.stringify(find('BUG-1 BUG-2', ruleSet)) ===
JSON.stringify([
[0, 5, 'https://tracker/bug/1'],
[6, 11, 'https://tracker/bug/2']
]),
'two matches on one line keep their own offsets'
)
}
{
const unSafe = [{ id: 'f', pattern: 'FILE:(\\S+)', template: 'file://$1' }]
ok(
JSON.stringify(find('FILE:/c/windows', unSafe)) === JSON.stringify([]),
'a file:// template produces no range at all (it never becomes clickable)'
)
const js = [{ id: 'j', pattern: 'X(\\d+)', template: 'javascript:alert($1)' }]
ok(JSON.stringify(find('X1', js)) === JSON.stringify([]), 'a javascript: template is refused')
}
{
// no template → the matched text itself may be the target, as long as it is
// already a safe URL
const bare = [{ id: 'b', pattern: 'https?://\\S+', template: '$0' }]
ok(
JSON.stringify(find('open https://a.io/x now', bare)) ===
JSON.stringify([[5, 19, 'https://a.io/x']]),
'$0 with an https match is clickable'
)
}
{
const first = { id: '1', pattern: 'BUG-(\\d+)', template: 'https://one/$1' }
const second = { id: '2', pattern: 'BUG-\\d+', template: 'https://two/$0' }
ok(
JSON.stringify(find('BUG-7', [first, second])) === JSON.stringify([[0, 5, 'https://one/7']]),
'the earlier rule claims a span the later one may not overlap'
)
}
{
// an empty-matching pattern must not loop forever
const empty = [{ id: 'e', pattern: 'a*', template: 'https://x/$0' }]
const out = find('baaa', empty)
ok(out.length >= 1, 'a pattern that can match empty still yields the non-empty matches')
ok(
out.every(([start, end]) => end > start),
'no zero-width range is emitted'
)
}
{
const tooLong = 'x'.repeat(LINK_TEXT_MAX + 1) + 'BUG-1'
ok(
JSON.stringify(find(tooLong, ruleSet)) === JSON.stringify([]),
'a line past the length cap is not matched at all'
)
}
// ---- mergeLinkSpans ---------------------------------------------------------
console.log('[mergeLinkSpans]')
{
const user = [{ start: 0, end: 6, url: 'https://user/1' }]
const builtin = [{ start: 0, end: 5, url: 'http://builtin/1' }]
const merged = mergeLinkSpans(user, builtin)
ok(merged.length === 1 && merged[0].url === 'https://user/1', 'a user rule wins an overlapping built-in')
}
{
const user = [{ start: 0, end: 6, url: 'https://user/1' }]
const builtin = [{ start: 10, end: 20, url: 'http://builtin/2' }]
const merged = mergeLinkSpans(user, builtin)
ok(
merged.length === 2 && merged[0].start === 0 && merged[1].start === 10,
'disjoint ranges from both sources are all kept, sorted by start'
)
}
{
const merged = mergeLinkSpans([], [{ start: 5, end: 9, url: 'http://b' }])
ok(merged.length === 1, 'with no user rules the built-ins pass through unchanged')
ok(mergeLinkSpans([], []).length === 0, 'two empty sources merge to nothing')
ok(mergeLinkSpans([], []) !== mergeLinkSpans([], []), 'each call returns its own array')
}
{
// built-ins arrive from findUrls already sorted and non-overlapping; the merge
// must not disturb a long-scheme match that beat a shorter bare-host one
const merged = mergeLinkSpans([], [
{ start: 0, end: 21, url: 'http://localhost:5000' },
{ start: 22, end: 41, url: 'https://example.com' }
])
ok(merged.length === 2 && merged[0].end <= merged[1].start, 'merged ranges never overlap')
}
if (failed > 0) {
console.error(`\n[link-rules] ${failed} check(s) FAILED`)
process.exit(1)
}
console.log('\n[link-rules] ALL CHECKS PASSED')