feat: 字体与高亮系统改进(CJK 对齐、ReDoS 防护、链接规则、崩溃类设置修复)
CI / typecheck + test + build (windows) (push) Canceled after 0s

基于两轮专项调研(字体/高亮各两路,子代理实测探针交叉印证)实施的完整改进。

## 修复的真实 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 已同步。
This commit is contained in:
Bill committed 2026-10-17 20:23:56 +08:00
1 parent 619e4ac57f
commit cee616a23a
29 files changed
+3071 -216

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
/**
* Font-family quoting self-test (font-stack.mjs).
*
* The font enumerator hands back BARE family names (`disableQuoting: true`), and
* the picked name is stored verbatim in `settings.terminal.fontFamily` — one name
* or a hand-written stack. Passing that straight to xterm's `fontFamily` or to the
* settings preview's inline style only works because parsers are lenient: a name
* with `(` `)` `+`, one starting with a digit, or one that collides with a CSS
* keyword is not a valid CSS family. `quoteFontStack` fixes that at the consumer
* sites, so the contract pinned here is:
*
* - plain identifiers and the family keywords stay bare (quoting the keywords
* would turn `monospace` into a font literally named "monospace");
* - anything else is double-quoted, inner double quotes escaped;
* - splitting is on TOP-LEVEL commas only — a comma inside `'Name, with comma'`
* or `"Name"` is part of that family, not a separator;
* - already-quoted segments survive untouched, which makes the function
* idempotent: second pass over already-processed output is a no-op;
* - empty/blank input is the empty string (not `"\"\""`), so a cleared setting
* does not become a font family named "".
*
* 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/font-stack.mjs (must exit 0)
*/
import { quoteFontStack } from '../src/renderer/src/terminal/fontStack.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed += 1
}
/** Table-driven: [input, expected]. */
const table = (label, cases) => {
console.log(label)
for (const [input, expected] of cases) {
const got = quoteFontStack(input)
ok(
got === expected,
`${JSON.stringify(input)} -> ${JSON.stringify(got)}${got === expected ? '' : ` (want ${JSON.stringify(expected)})`}`
)
}
}
table('plain identifiers and keywords are left alone', [
['Consolas', 'Consolas'],
['JetBrainsMono', 'JetBrainsMono'],
['DejaVu_Sans', 'DejaVu_Sans'],
['-apple-system', '-apple-system'],
['monospace', 'monospace'],
['serif', 'serif'],
['sans-serif', 'sans-serif'],
['cursive', 'cursive'],
['fantasy', 'fantasy'],
['system-ui', 'system-ui'],
['ui-monospace', 'ui-monospace'],
['default', 'default'],
['inherit', 'inherit'],
['initial', 'initial'],
['unset', 'unset'],
['Monospace', 'Monospace'],
// a keyword mixed into a stack stays bare while the neighbours are judged apart
['Consolas,monospace', 'Consolas, monospace']
])
table('names CSS cannot parse bare get double quotes', [
['Cascadia Code (Preview)', '"Cascadia Code (Preview)"'],
['Fira Code + Ligatures', '"Fira Code + Ligatures"'],
['4uto', '"4uto"'],
['.SF NS Mono', '".SF NS Mono"'],
['微軟雅黑', '"微軟雅黑"'],
['微软雅黑', '"微软雅黑"'],
['MS ゴシック', '"MS ゴシック"'],
['My Font!', '"My Font!"'],
['IntelliJ IDEA-like', '"IntelliJ IDEA-like"'],
['name{with}braces', '"name{with}braces"']
])
table('existing quoting is preserved', [
["'JetBrains Mono'", "'JetBrains Mono'"],
['"JetBrains Mono"', '"JetBrains Mono"'],
["Consolas, 'Fira Code', monospace", 'Consolas, \'Fira Code\', monospace'],
['"Noto Sans CJK SC", sans-serif', '"Noto Sans CJK SC", sans-serif']
])
table('only top-level commas separate families', [
["'Name, with comma'", "'Name, with comma'"],
['"Name, with comma", monospace', '"Name, with comma", monospace'],
['"A, B"', '"A, B"'],
["'A, B', 'C, D'", "'A, B', 'C, D'"],
// unquoted commas are separators, so this is two families, not one
['Name, with comma', 'Name, "with comma"']
])
table('segments are trimmed and empty ones dropped', [
[' Consolas , monospace ', 'Consolas, monospace'],
['Consolas,,monospace', 'Consolas, monospace'],
[',Consolas,', 'Consolas'],
['Consolas , , monospace', 'Consolas, monospace'],
[' Cascadia Code (Preview) ', '"Cascadia Code (Preview)"'],
['', ''],
[' ', ''],
[',', ''],
[' , , ', '']
])
table('inner double quotes are escaped, not nested', [
['Say "Hi"', '"Say \\"Hi\\""'],
['"Already" trailing', '"\\"Already\\" trailing"']
])
// Backslashes must be escaped too: escaping only `"` turned a name with a
// trailing backslash into `"a\"`, whose closing quote is itself escaped — the
// CSS string never terminates and the font silently falls back to the browser
// default. Every produced segment must therefore end with an EVEN run of
// backslashes before its closing quote.
table('backslashes are escaped so the CSS string stays terminated', [
['a\\', '"a\\\\"'],
['\\', '"\\\\"'],
['Font Name\\', '"Font Name\\\\"'],
['x\\"; color:red', '"x\\\\\\"; color:red"']
])
console.log('quoting is idempotent')
const stacks = [
'Consolas',
'Cascadia Code (Preview)',
'Consolas, Cascadia Code (Preview), monospace',
"Consolas, 'Fira Code', monospace",
'"Name, with comma", monospace',
'微软雅黑',
''
]
for (const stack of stacks) {
const once = quoteFontStack(stack)
ok(
quoteFontStack(once) === once,
`${JSON.stringify(stack)} survives a second pass (${JSON.stringify(once)})`
)
}
console.log('the stored setting is the caller\'s problem (input is never mutated)')
const original = 'Cascadia Code (Preview)'
quoteFontStack(original)
ok(original === 'Cascadia Code (Preview)', 'the input string is untouched')
if (failed > 0) {
console.error(`\n[font-stack] ${failed} check(s) FAILED`)
}
console.log('\n[font-stack] ALL CHECKS PASSED')
process.exit(failed ? 1 : 0)
+404 -2
View File
@@ -12,7 +12,7 @@
* --outfile=tests/.hl-rules.cjs --alias:@shared=./src/shared
* node tests/hl-rules.mjs
*/
import { compileRules, applyHighlights, __testHooks as hooks } from '../src/renderer/src/terminal/highlightEngine.ts'
import { compileRules, applyHighlights, HighlightStream, __testHooks as hooks } from '../src/renderer/src/terminal/highlightEngine.ts'
import { previewSpans } from '../src/renderer/src/terminal/highlightEngine.ts'
import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
import {
@@ -807,5 +807,407 @@ console.log('[escape sequences]')
ok(hooks.findIncompleteIndex('DONE \x1b7') === -1, 'a trailing DECSC is not held as incomplete')
}
// ---- 20. linux permission strings (ls -l) ----------------------------------
console.log('[permissions]')
{
// A multi-line ls -l dump arrives as one text run: every line's mode string
// must light up, not just the first. The old `^` pattern anchored the run
// start, so line two and down came back uncoloured.
const lsDump = '-rw-r--r-- 1 root root 10 x\n-rwxr-xr-x 1 root root 20 y\n'
ok(colored(lsDump, OK_GREEN, '-rw-r--r--'), 'the first line\'s mode string highlights')
ok(
colored(lsDump, OK_GREEN, '-rwxr-xr-x'),
'the second line\'s mode string highlights too (no run-start anchoring)'
)
// A coloured prompt cuts the run at the escape, so the mode string no longer
// starts the run: the lookbehind must accept the surrounding space instead.
ok(
colored('\x1b[32m$\x1b[0m -rw-r--r-- 1 a b\n', OK_GREEN, '-rw-r--r--'),
'a mode string right after an escape sequence highlights'
)
// ...but glued to a word it is not a permission string at all.
ok(
!colored('x-rw-r--r--y something', OK_GREEN, '-rw-r--r--'),
'a non-space character in front of the mode string keeps it plain'
)
// and a longer-than-10 mode string must not be clipped to its first ten
// characters and highlighted.
ok(
!applyHighlights('drwxr-xr-xr-x file', rules).includes(`${sgr(OK_GREEN)}drwxr-xr-x\x1b[0m`),
'an eleven-character mode string is not trimmed and highlighted'
)
// ACL/SELinux '+' and macOS '@' attribute markers must not break the match:
// the trailing boundary only rejects another mode character, not markers.
ok(
colored('-rw-r--r--+ 1 bill staff 512 Oct 17 10:00 acl.txt', OK_GREEN, '-rw-r--r--'),
'an ACL "+" suffix after the mode string still highlights'
)
ok(
colored('-rw-r--r--@ 1 bill staff 512 Oct 17 10:00 xattr.txt', OK_GREEN, '-rw-r--r--'),
'a macOS "@" suffix after the mode string still highlights'
)
}
// ---- 21. a span must not clear the application's own SGR -------------------
console.log('[sgr restore]')
{
// `wrap` closes every span with a reset. Before the restore, that reset also
// wiped whatever SGR the *application* had set, so the tail of a yellow
// warning came back in the default colour.
const yellow = applyHighlights('\x1b[33mWARNING tail here', rules)
ok(
yellow.includes(`${sgr(WARN_YELLOW)}WARNING\x1b[0m\x1b[33m tail here`),
'the application\u2019s yellow is replayed after the span, not just a bare reset'
)
ok(
!yellow.endsWith('WARNING\x1b[0m tail here'),
'the span no longer leaves the text after it in the default colour'
)
// consecutive SGRs are replayed as a set, in order: dropping the bold would
// silently un-bold everything after the span.
const bold = applyHighlights('\x1b[1m\x1b[31mERR rest', rules)
ok(
bold.includes(`${sgr(BAD_RED)}ERR\x1b[0m\x1b[1m\x1b[31m rest`),
'consecutive SGRs are all replayed, in their original order'
)
// no SGR in front of the run: unchanged, plain reset (the pre-existing shape)
const plain = applyHighlights('ERROR rest', rules)
ok(
plain === `${sgr(BAD_RED)}ERROR\x1b[0m rest`,
'a run with no preceding SGR still closes with a bare reset'
)
// a reset inside the chunk clears the state: only the SGRs after it replay
ok(
applyHighlights('\x1b[33m\x1b[0mWARNING tail', rules) ===
`\x1b[33m\x1b[0m${sgr(WARN_YELLOW)}WARNING\x1b[0m tail`,
'an SGR reset before the run clears what would be replayed'
)
// non-SGR sequences are never replayed — replaying a cursor move would move
// the cursor a second time on every span.
const cursor = applyHighlights('\x1b[2AWARNING tail', rules)
ok(
cursor === `\x1b[2A${sgr(WARN_YELLOW)}WARNING\x1b[0m tail`,
'non-SGR sequences (cursor moves) are passed through but never replayed'
)
ok(
applyHighlights('\x1b]0;title\x07WARNING tail', rules) ===
`\x1b]0;title\x07${sgr(WARN_YELLOW)}WARNING\x1b[0m tail`,
'an OSC title is passed through but never replayed'
)
// Every span in a run restores, not just the first — but the gap between two
// spans now carries the restore instead of the preceding span's tail, so that
// a dense line pays for the restore once per VISIBLE gap. Both spellings put
// the application's yellow back between `WARNING` and `ERROR`.
const two = applyHighlights('\x1b[33mWARNING ERROR tail', rules)
ok(
two.startsWith(`\x1b[33m${sgr(WARN_YELLOW)}WARNING\x1b[0m`),
'the first span keeps its highlight'
)
ok(
two.includes(`${sgr(BAD_RED)}ERROR\x1b[0m\x1b[33m tail`),
'the last span replays the active SGR before the trailing text'
)
// the state survives across chunks: a colour set in one push must still be
// restored by a span that lands in a later one, or split output would differ
// from single-chunk output.
const stream = new HighlightStream(rules)
const streamed = stream.push('\x1b[33m') + stream.push('WARNING tail here') + stream.flush()
ok(
streamed === applyHighlights('\x1b[33mWARNING tail here', rules),
'the SGR state carries across stream pushes (split == single chunk)'
)
// a run split by an escape sequence inside the span keeps the known shape:
// the text before the escape is one run, the text after it another, and only
// the first is coloured.
ok(
applyHighlights('ERR\x1b[0mOR', rules) === `${sgr(BAD_RED)}ERR\x1b[0m\x1b[0mOR`,
'an escape in the middle of a word still cuts the run (only the first half colours)'
)
}
// ---- 22. an over-long line only costs itself -------------------------------
console.log('[long lines]')
{
const LONG = 'A'.repeat(5000)
// regression: one base64 blob used to switch highlighting off for the whole
// run, so the ERROR on the next line came back uncoloured.
const mixed = applyHighlights(`${LONG} ERROR\nERROR still here`, rules)
ok(
mixed === `${LONG} ERROR\n${sgr(BAD_RED)}ERROR\x1b[0m still here`,
'a 5KB line passes through raw while the next line still highlights'
)
// ...and the same holds when the long line is in the middle or at the end
ok(
applyHighlights(`ok\n${LONG}\nerror here`, rules).endsWith(`\n${LONG}\n${sgr(BAD_RED)}error\x1b[0m here`),
'a long line in the middle does not stop the line after it from colouring'
)
ok(
applyHighlights(`ERROR\n${LONG}`, rules) === `${sgr(BAD_RED)}ERROR\x1b[0m\n${LONG}`,
'a long line at the end does not stop the line before it from colouring'
)
// every line over the bound: the run comes back untouched
ok(
applyHighlights(`${LONG}\n${LONG}`, rules) === `${LONG}\n${LONG}`,
'a run whose every line is over-long is returned byte-for-byte unchanged'
)
// the bound is exactly MAX_LINE_LEN (4096) per line, terminator included in
// the count: a line of exactly 4096 is still scanned, 4097 goes through raw.
const atBound = `${'E'.repeat(4096 - ' ERROR'.length)} ERROR`
ok(atBound.length === 4096, 'the boundary sample is exactly 4096 characters')
ok(
applyHighlights(atBound, rules).endsWith(`${sgr(BAD_RED)}ERROR\x1b[0m`),
'a line exactly at the 4KB bound is still scanned'
)
const overBound = `E${atBound}`
ok(
applyHighlights(overBound, rules) === overBound,
'a line one byte over the 4KB bound goes through raw'
)
// no over-long line → one whole-run scan, so rules keep matching across a
// newline (the path rule claims the leading "\n" of the second line).
ok(
applyHighlights('see the file\n/tmp/x here', rules).includes(`${sgr('#58a6ff')}\n/tmp/x\x1b[0m`),
'a run with no over-long line still matches across a newline'
)
// budgets stay shared across the degraded per-line scan: the long line does
// not hand each short line a fresh allowance
const sink = new Map()
applyHighlights(`${LONG}\nERROR\nERROR\nERROR`, rules, sink)
ok(
sink.get('badstate')?.hits === 3,
`the per-rule budget and hit count span every line of a degraded run (hits=${sink.get('badstate')?.hits})`
)
}
// ---- 23. per-line anchoring (user "lineAnchor" switch) ---------------------
console.log('[line anchor]')
{
const TEXT = 'a\nERROR\nERROR b\n'
const plain = compileRules([
{ id: 'anch', pattern: '^ERROR$', enabled: true, priority: 1, color: { fg: BAD_RED } }
])
const perLine = compileRules([
{
id: 'anch',
pattern: '^ERROR$',
enabled: true,
priority: 1,
color: { fg: BAD_RED },
lineAnchor: true
}
])
// Without 'm' the anchors bind to the whole run: `a\nERROR\nERROR b\n` neither
// starts nor ends with ERROR, so nothing matches at all.
ok(
applyHighlights(TEXT, plain) === TEXT,
'without lineAnchor, ^ERROR$ does not match inside a multi-line run'
)
// With 'm' each line is its own anchor: only the bare ERROR line matches —
// `ERROR b` is not followed by end-of-line.
const out = applyHighlights(TEXT, perLine)
ok(
out === `a\n${sgr(BAD_RED)}ERROR\x1b[0m\nERROR b\n`,
'lineAnchor makes ^ERROR$ match the standalone ERROR line only'
)
ok(
!out.includes(`${sgr(BAD_RED)}ERROR\x1b[0m b`),
'lineAnchor still does not claim a longer line (ERROR b)'
)
// Anchoring only moves the boundaries: '$' works the same way.
const tail = compileRules([
{ id: 'tail', pattern: 'ERROR$', enabled: true, priority: 1, color: { fg: BAD_RED }, lineAnchor: true }
])
const tailOut = applyHighlights(TEXT, tail)
ok(
tailOut.includes(`${sgr(BAD_RED)}ERROR\x1b[0m\n`) && tailOut.includes('ERROR b\n') &&
!tailOut.includes(`${sgr(BAD_RED)}ERROR b`),
'lineAnchor anchors $ at each line end as well'
)
// The two flags compose: 'i' and 'm' both apply when both switches are on.
const both = compileRules([
{
id: 'both',
pattern: '^error$',
enabled: true,
priority: 1,
color: { fg: BAD_RED },
caseInsensitive: true,
lineAnchor: true
}
])
ok(
applyHighlights(TEXT, both) === `a\n${sgr(BAD_RED)}ERROR\x1b[0m\nERROR b\n`,
'lineAnchor composes with caseInsensitive (gim flags)'
)
}
// ---- 24. match events (P2-1: `onMatch` outlet) ------------------------------
console.log('[match events]')
{
// Only spans that actually get painted are reported, and each carries the
// rule id plus the raw matched text.
const events = []
const one = compileRules([
{ id: 'ok', pattern: '\\bOK\\b', enabled: true, priority: 1, color: { fg: OK_GREEN } }
])
const out = applyHighlights('all OK here', one, undefined, undefined, (id, text) =>
events.push([id, text])
)
ok(
out === `all ${sgr(OK_GREEN)}OK\x1b[0m here`,
'onMatch does not change the highlighted output'
)
ok(
events.length === 1 && events[0][0] === 'ok' && events[0][1] === 'OK',
`one painted span reports (ruleId, text) (got ${JSON.stringify(events)})`
)
}
{
// A match a higher-priority rule claimed first never paints, so it must not
// report either — otherwise a consumer would alert about colour that is not
// on screen. `done` is the documented case: shellkw beats status words.
const events = []
const overlap = compileRules([
{ id: 'first', pattern: '\\bdone\\b', enabled: true, priority: 1, color: { fg: OK_GREEN } },
{ id: 'second', pattern: '\\b(done|DONE)\\b', enabled: true, priority: 2, color: { fg: BAD_RED } }
])
applyHighlights('job done', overlap, undefined, undefined, (id, text) => events.push([id, text]))
ok(
events.length === 1 && events[0][0] === 'first',
`a span stolen by a higher-priority rule reports nothing (got ${JSON.stringify(events)})`
)
}
{
// The per-rule budget is the other silent case: a rule that hits MAX_PER_RULE
// (300) times in one run reports exactly that many times, not one more.
let count = 0
const many = compileRules([
{ id: 'x', pattern: 'ERROR', enabled: true, priority: 1, color: { fg: BAD_RED } }
])
applyHighlights(Array.from({ length: 301 }, () => 'ERROR').join(' '), many, undefined, undefined, () => {
count++
})
ok(count === 300, `matches past the per-rule budget stop reporting (reported ${count})`)
}
{
// Regression: passing no listener must produce byte-identical output.
const plain = compileRules(DEFAULT_HIGHLIGHT_RULES)
const sample =
'ERROR: rm -rf /tmp/x\nOK 45% http://example.com\nperm -rwxr-xr-x 2026-10-17 12:34\n'
const withHook = applyHighlights(sample, plain, undefined, undefined, () => undefined)
ok(applyHighlights(sample, plain) === withHook, 'a no-op listener leaves the bytes untouched')
}
{
// Stream: the listener is threaded through every push path, so a match that
// only completes once the held carry is flushed still reports exactly once.
const events = []
const stream = new HighlightStream(
compileRules([
{ id: 'ok', pattern: '\\bOK\\b', enabled: true, priority: 1, color: { fg: OK_GREEN } }
]),
(id, text) => events.push([id, text])
)
let out = ''
for (const piece of ['all ', 'OK', ' here\n']) out += stream.push(piece)
out += stream.flush()
ok(
out === `all ${sgr(OK_GREEN)}OK\x1b[0m here\n`,
'split pushes still produce the whole-run output'
)
ok(
events.length === 1 && events[0][1] === 'OK',
`a match split across chunks reports once (got ${JSON.stringify(events)})`
)
}
{
// Rules without the flag are irrelevant to the engine: it reports every painted
// span and leaves the notify policy to the consumer.
const events = []
const mixed = compileRules([
{ id: 'silent', pattern: '\\bWARN\\b', enabled: true, priority: 1, color: { fg: WARN_YELLOW } }
])
applyHighlights('WARN', mixed, undefined, undefined, (id) => events.push(id))
ok(events.length === 1 && events[0] === 'silent', 'the engine reports un-flagged rules too')
}
console.log('[restore cost budget]')
{
// Replaying the app's SGR state after EVERY span costs (state length x spans).
// A dense truecolor TUI line (Kimi-style: ~19 spans) made the engine inflate
// a 9.5KB chunk to 40.8KB — 4.30x, versus 2.78x before the restore existed.
// Downstream xterm could not keep up and the renderer froze for 18.4s. The
// budget is deliberately expressed as a size ratio over a big chunk so that
// raising MAX_REPLAY_COST fails HERE rather than in a user's terminal.
const tuiLine = (i) =>
'\x1b[38;2;120;130;140m' + `item ${i} 68% 2026-10-17T18:30:00 /src/a/b/c.ts:42:7 ERROR ` + '\x1b[0m\n'
const dense = Array.from({ length: 120 }, (_, i) => tuiLine(i)).join('')
const denseOut = applyHighlights(dense, rules)
const ratio = denseOut.length / dense.length
ok(ratio < 3.2, `dense truecolor TUI stays within budget (${ratio.toFixed(2)}x < 3.2x; regression was 4.30x)`)
// The budget must not kill the case it exists for: a single-colour state is
// short enough to replay, so the app's colour survives the span.
const yellow = applyHighlights('\x1b[33mWARNING tail here\x1b[0m\n', rules)
ok(yellow.length > '\x1b[33mWARNING tail here\x1b[0m\n'.length, 'the matched word still gets its highlight')
ok(
yellow.slice(yellow.indexOf('WARNING') + 'WARNING'.length).includes('\x1b[33m'),
'and the application\'s own yellow is replayed after the span (short state still worth it)'
)
}
console.log('[reset spellings and CSI parameters]')
{
// ECMA-48 has many spellings of "reset everything". Recognising only the two
// canonical ones let the other spellings be APPENDED to the SGR accumulator,
// so it grew a few bytes per line until it blew the cap and every later span
// lost its restore. Go/Java log libraries routinely emit ESC[0;32m.
const warn = compileRules([{ id: 'w', pattern: 'WARNING', priority: 1, color: { fg: '#ff0000' } }])
const tailAfter = (input) => {
const out = applyHighlights(input, warn)
return out.slice(out.indexOf('WARNING') + 'WARNING'.length)
}
for (const [label, reset, want] of [
['ESC[0m', '\x1b[0m', '\x1b[32m'],
['ESC[00m', '\x1b[00m', '\x1b[32m'],
['ESC[0;0m', '\x1b[0;0m', '\x1b[32m'],
['ESC[0;32m', '\x1b[0;32m', '\x1b[0;32m']
]) {
const tail = tailAfter(`\x1b[33mA${reset}\x1b[32mWARNING tail`)
ok(tail.includes(want), `${label} clears the accumulated state (tail keeps ${JSON.stringify(want)})`)
}
// CSI parameter bytes include the private prefixes `<`, `>`, `=` and the
// intermediate bytes ` -/`. Without them the whole line was treated as text
// containing a raw ESC, so every keyword after one lost its highlight — and
// these sequences are exactly what vim/tmux emit for mouse and key reporting.
for (const [label, seq] of [
['ESC[>4;1m (modifyOtherKeys)', '\x1b[>4;1m'],
['ESC[<0;1;2M (SGR mouse)', '\x1b[<0;1;2M'],
['ESC[=1;1$y (DECRQM)', '\x1b[=1;1$y']
]) {
ok(applyHighlights(`${seq}WARNING after\n`, warn).includes('255;0;0'), `${label} keeps the rest of the line highlighted`)
}
}
console.log(failed === 0 ? '\n[hl-rules] ALL CHECKS PASSED' : `\n[hl-rules] ${failed} CHECK(S) FAILED`)
process.exit(failed === 0 ? 0 : 1)
process.exit(failed === 0 ? 0 : 1)
+333
View File
@@ -0,0 +1,333 @@
/**
* 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')
+20 -13
View File
@@ -10,12 +10,13 @@
* 2) the :root static fallback block declares exactly the emitted names;
* 3) both semanticVars() branches emit the same key set (a key that only
* exists on one branch leaves the other theme with a stale :root value).
* Also self-tests the kebab converter so a no-op can't make (1) vacuous.
* It also asserts the keys ARE the final CSS names (lowercase --ot-*), so the
* camelCase intermediate naming that broke v1.0.29 can never come back.
* Run: node tests/ot-var-names.mjs (must exit 0)
*/
import fs from 'node:fs'
import path from 'node:path'
import { cssVarName, semanticCssVars } from '../src/renderer/src/theme/semantic.ts'
import { semanticVars } from '../src/renderer/src/theme/semantic.ts'
let failed = 0
const ok = (cond, msg) => {
@@ -24,23 +25,29 @@ const ok = (cond, msg) => {
}
const ROOT = path.resolve('src/renderer/src')
// The naming lives in semantic.ts (cssVarName) and is the SAME function
// chrome.ts calls via semanticCssVars — so a regression there fails here.
// Single naming system: semanticVars() keys ARE the custom property names, so
// what chrome.ts writes is exactly what this test inspects.
const names = (vars) => new Set(Object.keys(vars).filter((k) => k !== 'color-scheme'))
// --- self-test: the converter must actually convert ------------------------
console.log('cssVarName:')
ok(cssVarName('textMuted') === '--ot-text-muted', 'textMuted -> --ot-text-muted')
ok(cssVarName('dangerFg') === '--ot-danger-fg', 'dangerFg -> --ot-danger-fg')
ok(cssVarName('text') === '--ot-text', 'text -> --ot-text (unchanged)')
ok(cssVarName('colorScheme') === 'color-scheme', 'colorScheme -> color-scheme')
// --- what JS emits, per branch ---------------------------------------------
const darkVars = semanticCssVars({ background: '#1e1e1e', foreground: '#cccccc' }, false)
const lightVars = semanticCssVars({ background: '#f6f8fa', foreground: '#383a42' }, true)
const darkVars = semanticVars({ background: '#1e1e1e', foreground: '#cccccc' }, false)
const lightVars = semanticVars({ background: '#f6f8fa', foreground: '#383a42' }, true)
const darkNames = names(darkVars)
const lightNames = names(lightVars)
// --- self-test: the keys must BE the final CSS names ------------------------
// Custom properties are case-sensitive: a camelCase key would never satisfy
// var(--ot-text-muted), so a miss here is the silent-fallback regression.
console.log('keys are final CSS names (lowercase --ot-*):')
for (const [label, vars] of [['dark', darkVars], ['light', lightVars]]) {
const keys = Object.keys(vars).filter((k) => k !== 'color-scheme')
const bad = keys.filter((k) => !/^--ot-[a-z0-9-]+$/.test(k)).sort()
ok(bad.length === 0, `${label}: all ${keys.length} keys match /^--ot-[a-z0-9-]+$/${bad.length ? ` — bad: ${bad.join(', ')}` : ''}`)
ok(keys.length > 0, `${label}: emits names (guard is not vacuous)`)
}
ok(Object.keys(darkVars).some((k) => k === '--ot-text-muted'), 'multi-word name is kebab-case (--ot-text-muted)')
ok(Object.keys(darkVars).some((k) => k === '--ot-danger-fg'), 'compound name is kebab-case (--ot-danger-fg)')
console.log('branches emit the same key set:')
const onlyDark = [...darkNames].filter((n) => !lightNames.has(n)).sort()
const onlyLight = [...lightNames].filter((n) => !darkNames.has(n)).sort()
+11 -11
View File
@@ -23,14 +23,14 @@ ok(isLightTheme('#282a36') === false, '#282a36 is dark')
console.log('dark branch locks anchors (zero regression):')
const dark = semanticVars({ background: '#1e1e1e', foreground: '#cccccc' }, false)
ok(dark['textMuted'] === SEMANTIC.muted, 'textMuted = anchor')
ok(dark['success'] === SEMANTIC.success, 'success = anchor')
ok(dark['danger'] === SEMANTIC.danger, 'danger = anchor')
ok(dark['warning'] === SEMANTIC.warning, 'warning = anchor')
ok(dark['info'] === SEMANTIC.info, 'info = anchor')
ok(dark['scrollbar'] === SEMANTIC.scrollbar, 'scrollbar = anchor')
ok(dark['shadow'] === SEMANTIC.shadow, 'shadow = anchor')
ok(dark['colorScheme'] === 'dark', 'colorScheme dark')
ok(dark['--ot-text-muted'] === SEMANTIC.muted, '--ot-text-muted = anchor')
ok(dark['--ot-success'] === SEMANTIC.success, '--ot-success = anchor')
ok(dark['--ot-danger'] === SEMANTIC.danger, '--ot-danger = anchor')
ok(dark['--ot-warning'] === SEMANTIC.warning, '--ot-warning = anchor')
ok(dark['--ot-info'] === SEMANTIC.info, '--ot-info = anchor')
ok(dark['--ot-scrollbar'] === SEMANTIC.scrollbar, '--ot-scrollbar = anchor')
ok(dark['--ot-shadow'] === SEMANTIC.shadow, '--ot-shadow = anchor')
ok(dark['color-scheme'] === 'dark', 'color-scheme dark')
console.log('light branch meets ratios:')
for (const bg of ['#f6f8fa', '#fafafa', '#fdf6e3']) {
@@ -44,13 +44,13 @@ for (const bg of ['#f6f8fa', '#fafafa', '#fdf6e3']) {
const x = L(a), y = L(b)
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)
}
for (const key of ['textMuted', 'textSecondary', 'successFg', 'dangerFg', 'warningFg', 'infoFg']) {
for (const key of ['--ot-text-muted', '--ot-text-secondary', '--ot-success-fg', '--ot-danger-fg', '--ot-warning-fg', '--ot-info-fg']) {
ok(cr(v[key], bg) >= 4.5, `${bg} ${key} ${v[key]} >= 4.5 (${cr(v[key], bg).toFixed(2)})`)
}
for (const key of ['success', 'danger', 'warning', 'info']) {
for (const key of ['--ot-success', '--ot-danger', '--ot-warning', '--ot-info']) {
ok(cr(v[key], bg) >= 3, `${bg} ${key} ${v[key]} >= 3.0 (${cr(v[key], bg).toFixed(2)})`)
}
ok(v['colorScheme'] === 'light', `${bg} colorScheme light`)
ok(v['color-scheme'] === 'light', `${bg} color-scheme light`)
}
if (failed > 0) {
+93
View File
@@ -20,6 +20,7 @@ import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from 'no
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createRequire } from 'node:module'
import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
const require = createRequire(import.meta.url)
const userData = mkdtempSync(join(tmpdir(), 'ot-settings-'))
@@ -60,6 +61,33 @@ for (const action of ['tray', 'exit', 'ask']) {
ok(back === 'tray', `unknown closeAction falls back to 'tray' (got '${back}')`)
}
// ---- 1b. numeric font settings are clamped on load ---------------------------
// xterm's constructor THROWS on lineHeight < 1 and the throw unmounts the whole
// dockview pane. The value is persisted, so one bad entry bricked every start
// until the file was hand-edited. The sanitizer must repair it instead.
console.log('[font settings clamp]')
{
const term = store.loadSettings().terminal
for (const [key, bad, want] of [
['lineHeight', 0.85, 1],
['lineHeight', 0.95, 1],
['lineHeight', 9, 2],
['fontSize', 4, 8],
['fontSize', 99, 40],
['letterSpacing', -3, 0],
['letterSpacing', 50, 10]
]) {
const back = (await roundTrip({ terminal: { ...term, [key]: bad } })).terminal[key]
ok(back === want, `${key} ${bad} is clamped to ${want} on load (got ${back})`)
}
// A legal value must survive untouched — the clamp is not a reset.
const legal = (await roundTrip({ terminal: { ...term, lineHeight: 1.35, fontSize: 18, letterSpacing: 1.5 } })).terminal
ok(
legal.lineHeight === 1.35 && legal.fontSize === 18 && legal.letterSpacing === 1.5,
'legal font values are preserved verbatim'
)
}
// ---- 2. highlight-rule repair ----------------------------------------------
console.log('[highlight rules]')
{
@@ -177,6 +205,51 @@ console.log('[value bands]')
const dropped = (await roundTrip({ highlightRules: [rule({ basic: 'yes' })] })).highlightRules
ok(dropped.length === 1 && dropped[0].basic === undefined, 'a non-boolean basic flag is dropped')
}
{
// lineAnchor: a rule anchored per line must come back still anchored, and a
// rule that never opted in must stay absent (not `false`) so settings.json
// stays compact.
const kept = (await roundTrip({ highlightRules: [rule({ lineAnchor: true })] })).highlightRules
ok(kept.length === 1 && kept[0].lineAnchor === true, 'a line-anchored rule keeps its flag')
const absent = (await roundTrip({ highlightRules: [rule()] })).highlightRules
ok(absent.length === 1 && absent[0].lineAnchor === undefined, 'a rule without the flag keeps it undefined')
const dropped = (await roundTrip({ highlightRules: [rule({ lineAnchor: 'yes' })] })).highlightRules
ok(
dropped.length === 1 && dropped[0].lineAnchor === undefined,
'a non-boolean lineAnchor flag is dropped'
)
}
{
// notify (per-rule "alert on hit"): same shape as lineAnchor — `true` survives,
// absent stays absent, anything non-boolean is dropped rather than coerced.
const kept = (await roundTrip({ highlightRules: [rule({ notify: true })] })).highlightRules
ok(kept.length === 1 && kept[0].notify === true, 'a notify rule keeps its flag')
const absent = (await roundTrip({ highlightRules: [rule()] })).highlightRules
ok(absent.length === 1 && absent[0].notify === undefined, 'a rule without the flag keeps it undefined')
const dropped = (await roundTrip({ highlightRules: [rule({ notify: 'yes' })] })).highlightRules
ok(
dropped.length === 1 && dropped[0].notify === undefined,
'a non-boolean notify flag is dropped'
)
// An explicit `false` is indistinct from absent after a save: the sanitizer
// normalises it away so settings.json never grows a dead key.
const off = (await roundTrip({ highlightRules: [rule({ notify: false })] })).highlightRules
ok(off.length === 1 && off[0].notify === undefined, 'notify: false is normalised to undefined')
}
{
// The preset rule set ships `secret` with the alert on (a leaked key is what
// the feature exists for) and nothing else flagged. Read from the preset
// constant rather than from disk: the user's stored set may legitimately have
// the flag off (see the upgrade note in the report), so this asserts the ship
// default, not the loaded result.
const flagged = DEFAULT_HIGHLIGHT_RULES.filter((r) => r.notify === true).map((r) => r.id)
ok(
flagged.length === 1 && flagged[0] === 'secret',
`only the secret preset notifies by default (got ${flagged.join(',')})`
)
const secret = DEFAULT_HIGHLIGHT_RULES.find((r) => r.id === 'secret')
ok(secret?.basic === true && secret?.notify === true, 'the secret preset notifies in both highlight modes')
}
{
for (const on of [true, false]) {
const back = await roundTrip({
@@ -196,6 +269,26 @@ console.log('[value bands]')
)
}
}
{
// cjkFontFamily: the CJK fallback slot appended to the font stack. Empty is
// the default (append nothing, unchanged behaviour); a real family name and
// a non-string — the settings file is hand-editable, and a number here would
// otherwise reach `appendCjk` and be concatenated into the stack as text.
ok(store.loadSettings().terminal.cjkFontFamily === '', 'the CJK fallback defaults to empty')
for (const cjk of ['Microsoft YaHei', "'Noto Sans CJK SC'", '']) {
const back = await roundTrip({
terminal: { ...store.loadSettings().terminal, cjkFontFamily: cjk }
})
ok(back.terminal.cjkFontFamily === cjk, `CJK fallback '${cjk}' survives the round trip`)
}
const dropped = await roundTrip({
terminal: { ...store.loadSettings().terminal, cjkFontFamily: 42 }
})
ok(
dropped.terminal.cjkFontFamily === '',
`a non-string CJK fallback is dropped back to empty (got '${dropped.terminal.cjkFontFamily}')`
)
}
{
const rules = [rule({ id: 'keep-1' }), rule({ id: 'keep-2' })]
await roundTrip({ highlightRules: rules })