Files
OpenTerminal/tests/link-rules.mjs
T
Bill cee616a23a
CI / typecheck + test + build (windows) (push) Waiting to run
feat: 字体与高亮系统改进(CJK 对齐、ReDoS 防护、链接规则、崩溃类设置修复)
基于两轮专项调研(字体/高亮各两路,子代理实测探针交叉印证)实施的完整改进。

## 修复的真实 bug

**设置越界导致窗格永久死亡(用户报告)**
行高设 0.95 → xterm 构造/热更抛错 → 错误边界卸载整个 dockview 窗格;坏值已
落盘,每次重启都崩。根因是一整类缺陷:UI 允许值 / 配置能写值 与 xterm 硬约束
不一致,且主进程不校验。同类还有 scrollback<0(抛错)、cursorStyle 非法值
(更隐蔽:构造期被吞掉、只有热更期抛错,导致「重试」按钮永远无效、窗格死循环)。

修复:FONT_LIMITS/CURSOR_STYLES/RENDERER_MODES 收拢到 shared 层单一来源,
主进程加载时钳制 + 白名单化,healClampedFontSettings() 回写自愈;设置页
InputNumber、字体热键共用同一常量(照 LOCK_AUTO_DELAYS 先例,杜绝再次漂移)。

**perm 预设锚定失效**
^[-dlbcps]... 的 ^ 锚定的是「转义序列之间的 text run 起点」而非行首,ls -l
多行输出只有第一行权限位上色;带颜色的 ls(run 以转义切开)完全不触发。
改为 (?<=^|\s)[-dlbcps][rwxstST-]{9}(?![rwxstST-])——用负向前瞻而不是
(?=\s|$) 是因为后者会让带 ACL `+` / macOS `@` 后缀的真实 ls -l 输出不再染色。
旧 pattern 登记进 LEGACY_BUILTIN_PATTERNS,存量安装下次启动自动升级。

**高亮 span 吃掉应用自身 SGR**
wrap 固定追加 \x1b[0m,把应用设的颜色一并清掉。改为 SgrState 跟踪并重放。
四条护栏(每条都踩过坑,详见 AGENTS.md):
- reset 判定解析参数:ESC[00m/ESC[0;0m/ESC[0;32m(Go/Java 日志库常写)原先
  被追加进累积器,逐行增长直到撞上限,之后所有 span 恢复永久失效
- 回放闸门按序列条数而非字节数:同一个"单个前景色"长度差近 4 倍,按字节卡
  会砍掉真彩色却放过索引色
- 恢复串只插在可见间隙和最后一个 span 后:每 span 都重放会让 Kimi 这类全彩
  TUI(~19 span/行)把 9.5KB 输出膨胀到 40.8KB(4.30x),渲染线程实测卡
  18.4 秒。现为 3.05x 且长间隙着色正常
- 基准测试守门(膨胀 < 3.2x),改参数必须重跑

**CSI 私有参数导致整行失去高亮**
ESCAPE_RE 漏了 <>= 前缀与中间字节,\x1b[>4;1m(modifyOtherKeys)、
\x1b[<0;1;2M(SGR 鼠标)、\x1b[=1;1$y(DECRQM) 让整行成含裸 ESC 的文本,该行
之后所有关键词失去高亮(vim/tmux 常发)。

**超长行拖垮整段高亮**:一条 >4KB 的行让整个 text run 失去高亮。改为逐行降级。

**统计「最后写入者赢」**:模块级 totals 被各 pane 覆盖,设置页数据在窗格间
跳动。改为按 pane 实例 UUID 聚合(不能用 sessionId——SSH 分屏同一 session 挂
两个 pane,正好复制该 bug)。

## 新增能力

- **CJK 对齐三件套**:@xterm/addon-unicode11@0.10.0-beta.301(peer 恰好接受
  锁定的 6.1.0-beta.304,升 xterm 必须同步重配)、rescaleOverlappingGlyphs、
  cjkFontFamily 回退槽位。默认字体栈没有任何 CJK 字体,中文靠 Chromium 回退
  到比例字体导致列错位
- **字体名引号包裹**:quoteFontStack() 逐段判定(含反斜杠转义——只转义 " 会
  让尾部反斜杠把闭合引号转义掉,CSS 字符串不终止)
- **等宽徽标**:canvas measureText 逐字符采样,等宽排前
- **规则按行匹配**(lineAnchor → m flag)
- **命中提醒**(notify + onMatch 事件出口,60s 冷却,只显示规则名不显示原文)
- **可点击链接规则**(WezTerm hyperlink_rules 式,安全闸门在生成 span 时就
  丢弃非 http/https 目标)

## 其它

- 主题语义变量改为单一命名体系:semanticVars() 键就是最终 CSS 属性名,字面量
  键联合让拼错成为编译错误;删除 cssVarName()/semanticCssVars() 中间层
  (自定义属性名大小写敏感,转换被绕过会静默回落到深色兜底值)
- 删除死代码:automaticFontFallback 与顶层 overviewRuler 在 6.1.0-beta.304
  中均不存在,且移除了让任何伪造键都能通过 tsc 的 as 断言
- ReDoS 防护(MAX_PATTERN_LEN + 编辑器 backtracksBadly 实时探测)——注意这
  只是止血,(a+)+$ 配 30 字符实测仍卡 8.2 秒,根治需把正则执行挪进 Worker

测试 29 → 32(新增 font-stack、link-rules),全部通过;AGENTS.md 已同步。
2026-10-17 20:23:56 +08:00

334 lines
12 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.
*
* Direct .ts import (no esbuild bundle): the module has no 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'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
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 = compileLinkRules([
{ 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')
}
// ---- findRuleLinks ----------------------------------------------------------
console.log('[findRuleLinks]')
const find = (text, rules) =>
findRuleLinks(text, compileLinkRules(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')