Files
OpenTerminal/tests/hl-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

1213 lines
46 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.
/**
* Preset highlight-rule self-test (hl-rules.mjs).
*
* The shipped presets are regexes over arbitrary program output, so their word
* boundaries are the whole game: `invalid` must not light up `valid`,
* `disabled` must not light up `enabled`, and the `ok` in `not ok` must stay out
* of the success rule. Every assertion goes through the real engine, so it also
* covers the ANSI wrapping a terminal actually receives.
*
* Build + run:
* npx esbuild tests/hl-rules.mjs --bundle --platform=node --format=cjs \
* --outfile=tests/.hl-rules.cjs --alias:@shared=./src/shared
* node tests/hl-rules.mjs
*/
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 {
exportHighlightRules,
HIGHLIGHT_FILE_KIND,
HIGHLIGHT_FILE_VERSION,
mergeRules,
parseHighlightRules
} from '../src/shared/highlightIO.ts'
import {
excludedByProfile,
rulesForProfile,
sanitizeProfiles
} from '../src/shared/highlightProfiles.ts'
import {
applyThemeColors,
paletteMatch,
themeColorFor
} from '../src/renderer/src/theme/highlightColors.ts'
let failed = 0
const ok = (cond, msg) => {
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
if (!cond) failed++
}
const rules = compileRules(DEFAULT_HIGHLIGHT_RULES)
/** truecolor prefix for a hex colour, as the engine emits it */
const sgr = (hex) => {
const n = parseInt(hex.slice(1), 16)
return `\x1b[38;2;${(n >> 16) & 255};${(n >> 8) & 255};${n & 255}m`
}
const OK_GREEN = '#3fb950'
const BAD_RED = '#f85149'
const WARN_YELLOW = '#e3b341'
const DANGER_RED = '#ff7b72'
const DEL_ORANGE = '#ffa657'
const NEW_GREEN = '#56d364'
/** true when `word` comes back from `text` wrapped in `hex` */
const colored = (text, hex, word) => applyHighlights(text, rules).includes(`${sgr(hex)}${word}\x1b[0m`)
// ---- 1. presets compile at all ----------------------------------------------
console.log('[presets]')
{
ok(
rules.length === DEFAULT_HIGHLIGHT_RULES.length,
`every preset rule compiles (${rules.length}/${DEFAULT_HIGHLIGHT_RULES.length})`
)
const ids = DEFAULT_HIGHLIGHT_RULES.map((r) => r.id)
ok(new Set(ids).size === ids.length, 'preset ids are unique')
const bad = DEFAULT_HIGHLIGHT_RULES.filter((r) => !/^#[0-9a-fA-F]{6}$/.test(r.color.fg))
ok(bad.length === 0, 'every preset has a #rrggbb foreground colour')
const statuses = ['okstate', 'warnstate', 'badstate', 'danger']
ok(
statuses.every((id) => DEFAULT_HIGHLIGHT_RULES.find((r) => r.id === id)?.caseInsensitive === true),
'status presets match case-insensitively'
)
}
// ---- 2. success words, in any case ------------------------------------------
console.log('[success]')
{
const words = [
'SUCCESS',
'Success',
'success',
'successful',
'succeeded',
'SUCCEEDS',
'PASS',
'passed',
'Passes',
'ok',
'Ok',
'OK',
'okay',
'done',
'DONE',
'finished',
'complete',
'completed',
'good',
'Good',
'GOOD',
'fine',
'true',
'enabled',
'active',
'online',
'ready',
'healthy',
'matched',
'found',
'accepted',
'approved',
'valid',
'allowed'
]
const missed = words.filter((w) => !colored(`${w} 1`, OK_GREEN, w))
ok(missed.length === 0, `all success words highlight (missed: ${missed.join(', ') || 'none'})`)
}
// ---- 3. failure words, in any case ------------------------------------------
console.log('[failure]')
{
const words = [
'ERROR',
'error',
'Error',
'ERR',
'err',
'errno',
'NG',
'ng',
'BUG',
'bug',
'buggy',
'FAILED',
'failed',
'FAILURE',
'fatal',
'CRITICAL',
'critical',
'PANIC',
'exception',
'Traceback',
'aborted',
'crashed',
'segfault',
'denied',
'refused',
'rejected',
'invalid',
'illegal',
'mismatch',
'timeout',
'unreachable',
'unhealthy',
'conflict',
'corrupted',
'missing',
'bad',
'wrong',
'offline'
]
const missed = words.filter((w) => !colored(`${w} 1`, BAD_RED, w))
ok(missed.length === 0, `all failure words highlight (missed: ${missed.join(', ') || 'none'})`)
// negated success words belong to the failure rule as a whole phrase
for (const phrase of ['not ok', 'NOT OK', 'not done', 'not found', 'Not Passed']) {
ok(colored(`${phrase} 1`, BAD_RED, phrase), `"${phrase}" highlights as a failure`)
}
}
// ---- 4. warnings ------------------------------------------------------------
console.log('[warning]')
{
const words = ['WARN', 'warn', 'WARNING', 'Warnings', 'caution', 'notice', 'DEPRECATED', 'TODO', 'FIXME']
const missed = words.filter((w) => !colored(`${w} 1`, WARN_YELLOW, w))
ok(missed.length === 0, `all warning words highlight (missed: ${missed.join(', ') || 'none'})`)
ok(!colored('warning 1', BAD_RED, 'warning'), 'a warning is not also painted as an error')
}
// ---- 5. status symbols (checks, crosses, warning sign) ----------------------
console.log('[symbols]')
{
const glyphs = [
['\u2713', OK_GREEN], // ✓ check mark
['\u2714', OK_GREEN], // ✔ heavy check mark
['\u2705', OK_GREEN], // ✅ white heavy check mark
['\u2717', BAD_RED], // ✗ ballot x
['\u2718', BAD_RED], // ✘ heavy ballot x
['\u2716', BAD_RED], // ✖ heavy multiplication x
['\u274C', BAD_RED], // ❌ cross mark
['\u26A0', WARN_YELLOW] // ⚠ warning sign
]
const missed = glyphs.filter(([glyph, hex]) => !colored(`build ${glyph} ok`, hex, glyph))
ok(
missed.length === 0,
`every status symbol highlights (missed: ${missed.map(([g]) => g).join(' ') || 'none'})`
)
// an emoji variation selector rides along inside the coloured span
ok(
colored('build \u2705\uFE0F ok', OK_GREEN, '\u2705\uFE0F'),
'the emoji variation selector stays inside the coloured span'
)
// the two families never cross over
ok(!colored('\u2713', BAD_RED, '\u2713'), 'a check mark is never painted as a failure')
ok(!colored('\u2717', OK_GREEN, '\u2717'), 'a cross mark is never painted as a success')
}
// ---- 6. no false positives inside longer words ------------------------------
console.log('[word boundaries]')
{
const cases = [
['valid', 'invalid', OK_GREEN],
['enabled', 'disabled', OK_GREEN],
['active', 'inactive', OK_GREEN],
['healthy', 'unhealthy', OK_GREEN],
['pass', 'passwd', OK_GREEN],
['passed', 'bypassed', OK_GREEN],
['found', 'foundation', OK_GREEN],
['ok', 'okapi', OK_GREEN],
['fine', 'define', OK_GREEN]
]
for (const [inner, word, hex] of cases) {
ok(!colored(word, hex, inner), `"${word}" does not highlight the "${inner}" inside it`)
}
ok(!colored('not ok', OK_GREEN, 'ok'), 'the ok in "not ok" is never green')
ok(!colored('not ok', OK_GREEN, 'not ok'), '"not ok" is not green as a whole phrase')
}
// ---- 7. destructive commands ------------------------------------------------
console.log('[danger]')
{
const cases = [
'rm -rf /tmp/x',
'sudo rm -fr build',
'mkfs.ext4 /dev/sdb1',
'dd if=/dev/zero of=/dev/sda',
'git reset --hard HEAD~3',
'git push --force',
'drop table users'
]
const missed = cases.filter((c) => !applyHighlights(c, rules).includes(sgr(DANGER_RED)))
ok(missed.length === 0, `destructive commands are flagged (missed: ${missed.join(' | ') || 'none'})`)
// ...while ordinary output that merely contains such letters is left alone
const quiet = ['reformat the disk image', 'the alarm was halted', 'remember to remount it']
const wrong = quiet.filter((t) => applyHighlights(t, rules).includes(sgr(DANGER_RED)))
ok(
wrong.length === 0,
`non-command text is not flagged (flagged: ${wrong.join(' | ') || 'none'})`
)
}
// ---- 8. case-insensitive flag is opt-in per rule ----------------------------
console.log('[case flag]')
{
const plain = compileRules([
{ id: 'cs', pattern: '\\bERROR\\b', enabled: true, priority: 1, color: { fg: BAD_RED } }
])
ok(!applyHighlights('error', plain).includes(sgr(BAD_RED)), 'a rule without the flag still respects case')
const folded = compileRules([
{ id: 'ci', pattern: '\\bERROR\\b', enabled: true, priority: 1, color: { fg: BAD_RED }, caseInsensitive: true }
])
ok(applyHighlights('error', folded).includes(sgr(BAD_RED)), 'the flag makes a rule case-insensitive')
}
// ---- 9. file operations (create vs delete) ---------------------------------
console.log('[file ops]')
{
const deleted = [
'delete',
'deleted',
'Deleted',
'deleting',
'remove',
'removed',
'removal',
'rm',
'rmdir',
'unlink',
'purge',
'DESTROYED',
'truncate',
'wipe',
'revoked',
'unmounted',
'killed',
'drop',
'dropped',
'cleanup',
'moved',
'renamed',
'overwritten'
]
const missedDelete = deleted.filter((w) => !colored(`${w} 1`, DEL_ORANGE, w))
ok(
missedDelete.length === 0,
`delete/side-effect words highlight (missed: ${missedDelete.join(', ') || 'none'})`
)
const created = [
'create',
'created',
'Created',
'creating',
'creation',
'mkdir',
'touch',
'add',
'added',
'Added',
'install',
'installed',
'clone',
'cloned',
'generate',
'generated',
'init',
'initialized',
'import',
'upload',
'downloaded',
'saved',
'wrote',
'written',
'mounted'
]
const missedCreate = created.filter((w) => !colored(`${w} 1`, NEW_GREEN, w))
ok(
missedCreate.length === 0,
`create/install words highlight (missed: ${missedCreate.join(', ') || 'none'})`
)
// neighbours that merely contain the letters stay untouched
for (const [text, hex, inner] of [
['address', NEW_GREEN, 'add'],
['initial', NEW_GREEN, 'init'],
['movie', DEL_ORANGE, 'move'],
['form', DEL_ORANGE, 'rm'],
['keeper', DEL_ORANGE, 'keep']
]) {
ok(!colored(text, hex, inner), `"${text}" does not highlight the "${inner}" inside it`)
}
// silent-e stems: delete→deleting, save→saving, create→creating. These are the
// forms a naive `DELETE(?:D|S|ING)?` misses, so they get their own sweep.
const gerunds = [
['deleting', DEL_ORANGE],
['moving', DEL_ORANGE],
['renaming', DEL_ORANGE],
['truncating', DEL_ORANGE],
['erasing', DEL_ORANGE],
['purging', DEL_ORANGE],
['wiping', DEL_ORANGE],
['revoking', DEL_ORANGE],
['terminating', DEL_ORANGE],
['creating', NEW_GREEN],
['cloning', NEW_GREEN],
['saving', NEW_GREEN],
['generating', NEW_GREEN],
['initializing', NEW_GREEN],
['installing', NEW_GREEN],
['adds', NEW_GREEN],
['completing', OK_GREEN],
['succeeding', OK_GREEN],
['passing', OK_GREEN],
['finishing', OK_GREEN],
['enabling', OK_GREEN],
['failing', BAD_RED],
['aborting', BAD_RED],
['crashing', BAD_RED],
['refusing', BAD_RED],
['corrupting', BAD_RED]
]
const missedForms = gerunds.filter(([w, hex]) => !colored(`${w} 1`, hex, w))
ok(
missedForms.length === 0,
`inflected forms highlight (missed: ${missedForms.map(([w]) => w).join(', ') || 'none'})`
)
// the two families never cross over, and danger still owns destructive commands
ok(!colored('deleted 1', NEW_GREEN, 'deleted'), 'a delete word is never painted as a create')
ok(!colored('created 1', DEL_ORANGE, 'created'), 'a create word is never painted as a delete')
ok(
applyHighlights('drop table users', rules).includes(sgr(DANGER_RED)),
'danger still claims "drop table" ahead of the delete rule'
)
ok(
applyHighlights('export PATH=/usr/bin:$PATH', rules).includes(sgr('#d2a8ff')),
'export stays a shell keyword, not a file operation'
)
}
// ---- 10. value bands (percentages) -----------------------------------------
console.log('[value bands]')
{
const BAND_LIGHT_GREEN = '#7ee787'
const at = (value) => applyHighlights(`${value}% done`, rules)
const expected = [
['0', BAD_RED],
['5', BAD_RED],
['19', BAD_RED],
['19.9', BAD_RED],
['20', WARN_YELLOW],
['35', WARN_YELLOW],
['49.9', WARN_YELLOW],
['50', BAND_LIGHT_GREEN],
['79', BAND_LIGHT_GREEN],
['80', OK_GREEN],
['99', OK_GREEN],
['100', OK_GREEN]
]
const wrong = expected.filter(([value, hex]) => !at(value).includes(`${sgr(hex)}${value}%\x1b[0m`))
ok(
wrong.length === 0,
`percentages pick their band by value (wrong: ${wrong.map(([v]) => `${v}%`).join(', ') || 'none'})`
)
// a rule without bands keeps one colour whatever the number
const flat = compileRules([
{ id: 'flat', pattern: '\\d+%', enabled: true, priority: 1, color: { fg: BAD_RED } }
])
ok(applyHighlights('77%', flat).includes(sgr(BAD_RED)), 'a rule without bands stays flat')
// with bands: the match's own number decides, and no number falls back
const banded = compileRules([
{
id: 'banded',
pattern: '\\bDONE\\b|\\d+%',
enabled: true,
priority: 1,
color: { fg: OK_GREEN },
bands: [
{ min: 0, fg: BAD_RED },
{ min: 90, fg: OK_GREEN }
]
}
])
ok(applyHighlights('DONE', banded).includes(sgr(OK_GREEN)), 'no number in the match → the rule colour')
ok(applyHighlights('5%', banded).includes(sgr(BAD_RED)), 'a number below the second band → the first band')
ok(applyHighlights('95%', banded).includes(sgr(OK_GREEN)), 'a number above the last min → the last band')
// bands stored out of order still resolve lowest-first
const unsorted = compileRules([
{
id: 'unsorted',
pattern: '\\d+%',
enabled: true,
priority: 1,
color: { fg: OK_GREEN },
bands: [
{ min: 80, fg: OK_GREEN },
{ min: 0, fg: BAD_RED },
{ min: 50, fg: BAND_LIGHT_GREEN }
]
}
])
ok(
applyHighlights('10%', unsorted).includes(sgr(BAD_RED)) &&
applyHighlights('60%', unsorted).includes(sgr(BAND_LIGHT_GREEN)) &&
applyHighlights('90%', unsorted).includes(sgr(OK_GREEN)),
'bands are applied lowest-first regardless of stored order'
)
}
// ---- 11. log levels, exit codes, HTTP codes, durations, secrets -------------
console.log('[ops & levels]')
{
const LOG_BLUE = '#79c0ff'
const ROOT_ORANGE = '#ffa657'
const HTTP_GRAY = '#8b949e'
for (const word of ['INFO', 'DEBUG', 'TRACE', 'VERBOSE']) {
ok(colored(`${word} starting up`, LOG_BLUE, word), `"${word}" highlights as a log level`)
}
ok(!colored('for more information see docs', LOG_BLUE, 'INFO'), 'lowercase "information" is not a log level')
// NOTICE belongs to the warning rule (it is a warning level), not this one
ok(colored('NOTICE disk almost full', WARN_YELLOW, 'NOTICE'), 'NOTICE stays with the warning rule')
ok(colored('root@web01:~# ls', ROOT_ORANGE, 'root@web01'), 'a root prompt is flagged')
ok(!colored('admin@web01:~$ ls', ROOT_ORANGE, 'admin@web01'), 'a normal user prompt is not')
ok(colored('exit code 0', OK_GREEN, 'exit code 0'), 'a zero exit code is green')
ok(colored('Process exited with code 1', BAD_RED, 'exited with code 1'), 'a non-zero exit code is red')
ok(colored('exit 2', BAD_RED, 'exit 2'), 'a bare "exit 2" is red')
ok(colored('HTTP/1.1 404 Not Found', WARN_YELLOW, '404'), 'a 4xx code is yellow')
ok(
!applyHighlights('HTTP/1.1 404 Not Found', rules).includes(`${sgr(WARN_YELLOW)}1.1`),
'the HTTP version is not swept into the status-code span'
)
ok(colored('status: 500', BAD_RED, '500'), 'status: 500 is red')
ok(colored('status 201', OK_GREEN, '201'), 'status 201 is green')
ok(colored('301 Moved Permanently', '#58a6ff', '301'), 'a 3xx with its reason phrase is blue')
ok(colored('204 No Content', OK_GREEN, '204'), '204 No Content is green')
ok(colored('HTTP/2 100', HTTP_GRAY, '100'), 'a 1xx code is grey')
ok(
!applyHighlights('processed 404 items', rules).includes(sgr(BAD_RED)) &&
!applyHighlights('404', rules).includes(sgr(BAD_RED)),
'a bare 3-digit number is not treated as a status code'
)
ok(colored('20ms', OK_GREEN, '20ms'), 'a fast duration is green')
ok(colored('120ms', WARN_YELLOW, '120ms'), 'a middling duration is yellow')
ok(colored('900ms', BAD_RED, '900ms'), 'a slow duration is red')
ok(!applyHighlights('took 2s', rules).includes(sgr(BAD_RED)), 'plain seconds are left to the number rule')
ok(colored('sk-abcdefghijklmnopqrstuvwxyz012345', DANGER_RED, 'sk-abcdefghijklmnopqrstuvwxyz012345'), 'an API key is flagged')
ok(colored('AKIAIOSFODNN7EXAMPLE', DANGER_RED, 'AKIAIOSFODNN7EXAMPLE'), 'an AWS key id is flagged')
ok(
colored('-----BEGIN RSA PRIVATE KEY-----', DANGER_RED, '-----BEGIN RSA PRIVATE KEY-----'),
'a PEM private key header is flagged'
)
ok(
colored('Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijk', DANGER_RED, 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijk'),
'a bearer token is flagged'
)
for (const cmd of [
'curl -fsSL https://x.sh | sh',
'wget -qO- https://x.sh | sudo bash',
'eval "$(ssh-agent)"',
'base64 -d payload.txt | sh',
'chmod +x install.sh'
]) {
ok(applyHighlights(cmd, rules).includes(sgr(DANGER_RED)), `"${cmd}" is flagged as dangerous`)
}
ok(!applyHighlights('npm run build', rules).includes(sgr(DANGER_RED)), 'an ordinary command stays unflagged')
}
// ---- 12. import / export round trip ----------------------------------------
console.log('[import / export]')
{
const exported = exportHighlightRules(DEFAULT_HIGHLIGHT_RULES)
const back = parseHighlightRules(exported)
ok(
back.error === undefined && back.rules.length === DEFAULT_HIGHLIGHT_RULES.length,
'an exported set parses back whole'
)
ok(
back.rules.every((r) => r.builtin !== true),
'imported rules are never marked builtin (they are the user’s now)'
)
ok(
new Set(back.rules.map((r) => r.id)).size === back.rules.length,
'imported rules get unique ids'
)
ok(
back.rules.some((r) => r.caseInsensitive === true) && back.rules.some((r) => Array.isArray(r.bands)),
'the case flag and value bands survive the round trip'
)
ok(parseHighlightRules('not json at all').error === 'not-json', 'garbage is refused as JSON')
ok(
parseHighlightRules('{"kind":"openterminal.layout","rules":[]}').error === 'wrong-kind',
'a foreign envelope is refused'
)
ok(
parseHighlightRules('{"rules":[]}').error === 'wrong-kind',
'an envelope with no kind at all is refused'
)
ok(
parseHighlightRules(JSON.stringify({ kind: HIGHLIGHT_FILE_KIND, version: HIGHLIGHT_FILE_VERSION + 1, rules: [] }))
.error === 'wrong-version',
'an envelope from another version is refused'
)
ok(
parseHighlightRules(JSON.stringify({ kind: HIGHLIGHT_FILE_KIND, rules: [] })).error === 'wrong-version',
'an envelope with no version at all is refused'
)
ok(parseHighlightRules('{"nope":1}').error === 'no-rules', 'JSON without a rules array is refused')
ok(parseHighlightRules('[]').error === undefined, 'a bare empty array is accepted')
const partial = parseHighlightRules(
JSON.stringify({
kind: HIGHLIGHT_FILE_KIND,
version: HIGHLIGHT_FILE_VERSION,
rules: [
{ pattern: '\\bOK\\b', priority: 500, color: { fg: 'red' } },
{ pattern: '[' },
{ nope: true }
]
})
)
ok(
partial.rules.length === 1 &&
partial.rules[0].priority === 100 &&
partial.rules[0].color.fg === '#3fb950',
'a repairable rule is imported, its priority clamped and colour defaulted'
)
ok(partial.warnings.length === 2, 'unusable entries are counted as skipped')
const mine = { id: 'mine', pattern: '\\bX\\b', enabled: true, priority: 1, color: { fg: '#ffffff' } }
const appended = mergeRules([mine], back.rules, 'append')
ok(appended.length === DEFAULT_HIGHLIGHT_RULES.length + 1, 'append keeps the existing rules')
ok(new Set(appended.map((r) => r.id)).size === appended.length, 'append never leaves duplicate ids')
ok(mergeRules([mine], back.rules, 'replace').length === DEFAULT_HIGHLIGHT_RULES.length, 'replace drops the old set')
}
// ---- 13. editor preview spans ----------------------------------------------
console.log('[preview]')
{
const spans = previewSpans('SUCCESS 85% ERROR', rules)
ok(spans.map((s) => s.text).join('') === 'SUCCESS 85% ERROR', 'preview spans reassemble the input exactly')
ok(spans.some((s) => s.text === 'SUCCESS' && s.fg === OK_GREEN), 'a preview span carries its colour')
ok(spans.some((s) => s.text === 'ERROR' && s.fg === BAD_RED), 'a failure span carries its colour too')
ok(
spans.some((s) => s.text === '85%' && s.fg === OK_GREEN),
'the preview honours value bands (85% is in the green band)'
)
ok(
previewSpans('\x1b[31mERROR\x1b[0m', rules).map((s) => s.text).join('') === 'ERROR',
'escape sequences in the sample are stripped before previewing'
)
ok(previewSpans('nothing here', []).length === 1, 'no rules → the sample stays one plain span')
}
// ---- 14. the basic-mode set -------------------------------------------------
console.log('[basic mode]')
{
const basic = DEFAULT_HIGHLIGHT_RULES.filter((r) => r.basic === true).map((r) => r.id)
ok(basic.length === 5, `the basic set stays small (${basic.join(', ') || 'empty'})`)
for (const id of ['danger', 'secret', 'okstate', 'warnstate', 'badstate']) {
ok(basic.includes(id), `"${id}" survives basic mode`)
}
ok(
!basic.includes('numbers') && !basic.includes('url') && !basic.includes('path'),
'the noisy rules are not part of basic mode'
)
ok(
DEFAULT_HIGHLIGHT_RULES.every((r) => r.basic === undefined || r.basic === true),
'basic is a flag, never explicitly false'
)
}
// ---- 15. preset categories -------------------------------------------------
console.log('[categories]')
{
const missing = DEFAULT_HIGHLIGHT_RULES.filter((r) => r.category === undefined).map((r) => r.id)
ok(missing.length === 0, `every preset carries a category (missing: ${missing.join(', ') || 'none'})`)
const known = new Set(['safety', 'status', 'file', 'net', 'text', 'metric'])
ok(
DEFAULT_HIGHLIGHT_RULES.every((r) => known.has(r.category)),
'every category is one of the known values'
)
const safety = DEFAULT_HIGHLIGHT_RULES.filter((r) => r.category === 'safety').map((r) => r.id)
ok(safety.length >= 2 && safety.includes('danger') && safety.includes('secret'), 'safety groups danger + secrets')
const basic = DEFAULT_HIGHLIGHT_RULES.filter((r) => r.basic === true)
ok(
basic.every((r) => r.category === 'safety' || r.category === 'status'),
'basic mode only ever contains safety/status rules'
)
}
// ---- 16. optional stats sink ----------------------------------------------
console.log('[stats]')
{
const sink = new Map()
applyHighlights('SUCCESS 85% ERROR 85%', rules, sink)
ok(sink.get('okstate')?.hits === 1, `the sink counts hits per rule (okstate=${sink.get('okstate')?.hits})`)
ok(sink.get('percent')?.hits === 2, 'the same rule counts every one of its matches')
ok(typeof sink.get('okstate')?.ms === 'number' && sink.get('okstate').ms >= 0, 'the sink records a duration')
ok(
applyHighlights('SUCCESS', rules) === applyHighlights('SUCCESS', rules, undefined),
'passing no sink produces byte-identical output'
)
const untouched = new Map()
applyHighlights('nothing to see here', rules, untouched)
ok(
[...untouched.values()].every((stat) => stat.hits === 0),
'rules that match nothing report zero hits'
)
}
// ---- 17. theme colour mapping ---------------------------------------------
console.log('[theme colours]')
{
const theme = {
colors: {
red: '#ff0000',
brightRed: '#ff8888',
green: '#00ff00',
brightGreen: '#88ff88',
yellow: '#ffff00',
brightYellow: '#ffff88',
blue: '#0000ff',
brightBlue: '#8888ff',
cyan: '#00ffff',
brightCyan: '#88ffff',
magenta: '#ff00ff',
brightMagenta: '#ff88ff'
}
}
ok(paletteMatch('#3fb950')?.name === 'green' && paletteMatch('#3fb950').bright === false, 'the success green buckets to green')
ok(paletteMatch('#7ee787')?.name === 'green' && paletteMatch('#7ee787').bright === true, 'the light green takes the bright entry')
ok(paletteMatch('#f85149')?.name === 'red', 'the error red buckets to red')
ok(paletteMatch('#e3b341')?.name === 'yellow', 'the warning yellow buckets to yellow')
ok(paletteMatch('#d2a8ff')?.name === 'magenta', 'shell keywords bucket to magenta')
ok(paletteMatch('#58a6ff')?.name === 'blue', 'links bucket to blue')
ok(paletteMatch('#8b949e') === null, 'the neutral grey keeps its own colour')
ok(themeColorFor('#3fb950', theme) === '#00ff00', 'green resolves to the theme green')
ok(themeColorFor('#7ee787', theme) === '#88ff88', 'light green resolves to the bright green')
ok(themeColorFor('#8b949e', theme) === '#8b949e', 'a neutral colour is returned untouched')
ok(themeColorFor('#3fb950', { colors: {} }) === '#3fb950', 'a theme missing that entry falls back to the rule colour')
const mapped = applyThemeColors(DEFAULT_HIGHLIGHT_RULES, theme)
const percent = mapped.find((r) => r.id === 'percent')
ok(
percent.bands.length === 4 && percent.bands[2].fg === '#88ff88' && percent.bands[3].fg === '#00ff00',
'value bands are mapped as well (light green vs green stays distinguishable)'
)
ok(
mapped.every((r) => /^#[0-9a-fA-F]{6}$/.test(r.color.fg)),
'every mapped colour is still a hex colour'
)
ok(
DEFAULT_HIGHLIGHT_RULES.find((r) => r.id === 'okstate').color.fg === '#3fb950',
'mapping never mutates the preset set itself'
)
}
// ---- 18. per-host profile selection ---------------------------------------
console.log('[profiles]')
{
const presets = DEFAULT_HIGHLIGHT_RULES
const profiles = [
{ id: 'p1', name: 'prod', ruleIds: ['okstate', 'badstate'] },
{ id: 'p2', name: 'empty', ruleIds: [] }
]
ok(rulesForProfile(presets, undefined, profiles).length === presets.length, 'no binding → every rule')
ok(rulesForProfile(presets, 'missing', profiles).length === presets.length, 'a dangling binding → every rule')
ok(rulesForProfile(presets, 'p2', profiles).length === presets.length, 'an empty profile → every rule')
const subset = rulesForProfile(presets, 'p1', profiles)
ok(
subset.length === 2 && subset.every((r) => ['okstate', 'badstate'].includes(r.id)),
'a bound profile narrows the session to its own rules'
)
const known = new Set(presets.map((r) => r.id))
const cleaned = sanitizeProfiles(
[
{ id: 'a', name: 'ok', ruleIds: ['okstate', 'ghost', 'okstate'] },
{ id: 'a', name: 'dupe', ruleIds: [] },
{ name: 'no id' },
'nope'
],
known,
() => undefined
)
ok(
cleaned.length === 1 && cleaned[0].ruleIds.length === 1,
'profiles are cleaned: ghost rule ids, duplicate ids and junk entries dropped'
)
ok(sanitizeProfiles('nope', known, () => undefined).length === 0, 'a non-array profile list is ignored')
ok(
excludedByProfile(presets, profiles[0]).length === presets.length - 2,
'excludedByProfile reports what a profile leaves out'
)
}
// ---- 19. escape sequences pass through untouched ---------------------------
console.log('[escape sequences]')
{
// DCS / APC / PM / SOS bodies and single-char Fe escapes must keep their
// bytes raw — never wrapped in SGR — while text around them stays highlighted.
const families = [
['DCS (ST)', '\x1bP0;1;0q#0;2;0;0;0~..\x1b\\'],
['DCS (BEL)', '\x1bP1;2|9/9\x07'],
['APC (kitty graphics)', '\x1b_Gf=32,s=10,v=10,m=1\x1b\\'],
['PM', '\x1b^weather\x1b\\'],
['SOS', '\x1bXsos-frame\x1b\\']
]
// one coloured span, exactly as the engine emits it (ESC-free span text)
const SPAN_RE = /\x1b\[38;2;\d+;\d+;\d+(?:;48;2;\d+;\d+;\d+)?m[^\x1b]*\x1b\[0m/g
for (const [name, seq] of families) {
const out = applyHighlights(`DONE ${seq} ERROR`, rules)
ok(out.includes(`${sgr(OK_GREEN)}DONE\x1b[0m`), `${name}: text before it keeps its highlight`)
ok(out.includes(`${sgr(BAD_RED)}ERROR\x1b[0m`), `${name}: text after it keeps its highlight`)
ok(
out.replace(SPAN_RE, '').includes(seq),
`${name}: sequence bytes pass through raw, never wrapped in SGR`
)
}
const fe = applyHighlights('\x1b7DONE\x1b8 1', rules)
ok(fe.includes(`${sgr(OK_GREEN)}DONE\x1b[0m`), 'text between DECSC/DECRC keeps its highlight')
ok(fe.includes('\x1b7') && fe.includes('\x1b8'), 'DECSC/DECRC bytes pass through byte-for-byte')
// backstop: a text run holding a residual (unterminated) ESC passes through
// raw byte-for-byte — never partially highlighted, never corrupted
const raw = 'DONE \x1bP0;1;0qcut ERROR'
ok(applyHighlights(raw, rules) === raw, 'an unterminated DCS keeps its run byte-for-byte raw')
// the stream treats a cut DCS/APC/PM/SOS tail as incomplete, like OSC
ok(hooks.isIncompleteEscape('\x1b') === true, 'a lone trailing ESC stays incomplete')
ok(hooks.isIncompleteEscape('\x1bP0;1;0qcut') === true, 'a cut DCS tail counts as incomplete')
ok(hooks.isIncompleteEscape('\x1b_Gf=32') === true, 'a cut APC tail counts as incomplete')
ok(
hooks.isIncompleteEscape('\x1b^w') === true && hooks.isIncompleteEscape('\x1bXs') === true,
'cut PM/SOS tails count as incomplete'
)
ok(hooks.isIncompleteEscape('\x1bP0;1;0qx\x1b\\') === false, 'a terminated DCS is complete')
ok(hooks.isIncompleteEscape('\x1b7') === false, 'a complete two-byte Fe escape is complete')
ok(hooks.findIncompleteIndex('DONE \x1bP0;1;0qcut') === 5, 'the stream holds from the DCS initiator')
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)