CI / typecheck + test + build (windows) (push) Waiting to run
基于两轮专项调研(字体/高亮各两路,子代理实测探针交叉印证)实施的完整改进。
## 修复的真实 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 已同步。
1213 lines
46 KiB
JavaScript
1213 lines
46 KiB
JavaScript
/**
|
||
* 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) |