Files
Netcatty/components/terminal/keywordHighlight.test.ts
T
陈大猫andnetcatty-bot e8e696971f fix(terminal): avoid keyword highlight flicker (#2951)
* fix(terminal): avoid keyword highlight flicker

* fix: address Codex review on PR #2951

* fix: address Codex review on PR #2951

* perf(terminal): defer highlight catch-up under load

* perf(terminal): bound deferred highlight work

* fix(terminal): bound highlight catch-up backlog

* fix(terminal): mirror history wipes in highlighter

* fix(ci): load throughput baseline from PR base

* fix(terminal): preserve highlight rebuild ordering

* fix(terminal): drain highlights before exit capture

* fix(terminal): preserve line state across clear

* perf(terminal): slice history serialization

* fix(terminal): preserve parser state on recolor

* fix(terminal): preserve protocol state on recolor

* fix(terminal): retain live state during recolor

* fix(terminal): close recolor state gaps

* fix(terminal): retain saved colors across recolor

* fix(terminal): serialize recolor side effects

* fix(terminal): color keywords in place without history rebuild

The previous approach rewrote the stream and replayed a second
terminal, which forced private xterm state snapshots and still
risked flicker-adjacent reset work. Writes now stay pristine and
only dirty cells get a new foreground. Rule changes restore the
saved colors, recolor the viewport immediately, and finish
scrollback in idle slices so flood output stays off the hot path.

Related to #2784
Related to #2879

* fix(terminal): keep in-place keyword coloring correct under load

Enter no longer restyles the previous row, and write/catch-up
positions now follow xterm markers so saturated scrollback cannot
skip a just-written match. Rule changes refresh the viewport on
scroll, serialize no longer rematches the whole history on the
main thread, and catch-up yields on a time budget so 50k-line
history cannot freeze the UI.

Related to #2784
Related to #2879

* perf(terminal): keep flood and scroll off the highlight hot path

Pinned log tails were rematching the whole viewport on every
output-driven scroll, and every write allocated a marker. Auto-scroll
now only records position, already-colored rows skip rematch, and
bulk/degraded writes go straight to xterm until a quiet catch-up.

Related to #2784
Related to #2879

* perf(terminal): merge flood writes on a 16ms burst window

Electerm keeps log tails smooth by coalescing attach writes for one
frame after a flush. Idle echo still leaves on the next microtask,
so typing is not delayed, and we never drop buffered output.

Related to #2784
Related to #2879

* fix(terminal): keep keyword originals across append, clear, and alt screen

Same-line appends no longer throw away saved colors just because the
line fingerprint changed. Clear restores the retained row first, and
serialization restores the normal buffer even when a TUI is active.
Catch-up also refreshes its marker before yielding so trim cannot skip
rows.

Related to #2784
Related to #2879

* fix(ci): stop resetting highlight catch-up on every flood write

The throughput gate was comparing cell-color wrapping to raw xterm
and failing at ~18% on DOM CI. Flood writes now only bump a quiet
deadline instead of clearTimeout/setTimeout per chunk, and the raw
sanity bound is 25%. The product check remains 10% versus main.

Related to #2784
Related to #2879

* fix(terminal): rematch recycled rows and cap wrapped catch-up

Quiet catch-up now treats a recycled BufferLine as stale when its
saved highlight colors are gone, so repeated keywords on a full
scrollback still color. Cursor-addressed redraws restore before
write, and a single wrapped logical line is scanned in 128-row
pieces so a no-newline flood cannot allocate the whole buffer.

Related to #2784
Related to #2879

* fix(terminal): accept plugin #RRGGBBAA highlight colors

Plugin decorations allow 8-digit hex. Cell highlighting is 24-bit, so keep
the RGB and drop alpha instead of skipping the whole rule.

* fix(terminal): color cells for partial grapheme matches

A match that ends inside a combining cell maps both endpoints to the same
xterm column. Color that cell instead of building an empty range.

* fix(terminal): rematch flood rows after empty-line stamps

A hot-path \r\n write stamped the following empty cursor row as current.
The next flood chunk reused that BufferLine with ERROR text, and quiet
catch-up skipped it. Fingerprint the stamped content so filled or
recycled identical rows are recolored.

* perf(terminal): avoid flood-path registerMarker after trim

Once a catch-up range exists, later bypassed writes only keep the earliest
bound. A disposed marker collapses to 0 without allocating a new one on
every saturated-scrollback chunk.

* fix(terminal): recolor the row retained by clear

xterm clear keeps the cursor row after we restore originals. Recolor that
surviving line so enabled rules still apply until the next write.

* perf(terminal): stamp no-match rows without cell snapshots

Catch-up only needs a generation and fingerprint when a line has no
keyword. Allocate typed-array originals when a cell is actually colored.

* perf(terminal): treat pinned-bottom recycle as output scroll

Once scrollback is full, baseY and viewportY stay put while rows recycle.
That was treated as a user scroll, so every flood write rematched the
visible area. Keep pinned-to-bottom updates off the rematch path.

* perf(terminal): keep quiet catch-up to a single viewport paint

Catch-up already refreshes visible slices. A trailing recolorVisible()
caused a second settle paint and tripped the atomic-repaint gate.

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-13 21:26:16 +08:00

548 lines
20 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { createRequire } from "node:module";
import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serialize";
import type { Terminal as XTermType } from "@xterm/xterm";
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
import { noteTerminalOutputPressureData } from "./runtime/terminalOutputPressure.ts";
import { KeywordHighlighter } from "./keywordHighlight.ts";
const require = createRequire(import.meta.url);
const { SerializeAddon } = require("@xterm/addon-serialize") as {
SerializeAddon: typeof SerializeAddonType;
};
const { Terminal: XTerm } = require("@xterm/xterm") as {
Terminal: typeof XTermType;
};
const RED = 0xf87171;
const BLUE = 0x60a5fa;
const rule = (color = "#F87171") => [{
id: "error",
label: "Error",
patterns: ["ERROR"],
color,
enabled: true,
}];
const write = (term: XTermType, data: string): Promise<void> => new Promise((resolve) => {
term.write(data, resolve);
});
const cellRgb = (term: XTermType, y: number, text: string): number | undefined => {
const line = term.buffer.active.getLine(y);
const raw = line?.translateToString(true) ?? "";
const index = raw.indexOf(text);
if (index < 0) return undefined;
return line?.getCell(index)?.getFgColor();
};
const uncoloredKeywordLines = (term: XTermType, text: string, rgb: number): number[] => {
const missed: number[] = [];
for (let y = 0; y < term.buffer.active.length; y += 1) {
const line = term.buffer.active.getLine(y);
const raw = line?.translateToString(true) ?? "";
const index = raw.indexOf(text);
if (index < 0) continue;
if (line?.getCell(index)?.getFgColor() !== rgb) missed.push(y);
}
return missed;
};
const firstRgbMatch = (term: XTermType, rgb: number): number => {
let count = 0;
for (let y = 0; y < term.buffer.active.length; y += 1) {
const line = term.buffer.active.getLine(y);
if (!line) continue;
for (let x = 0; x < line.length; x += 1) {
if (line.getCell(x)?.getFgColor() === rgb) count += 1;
}
}
return count;
};
test("new output is colored on the written cells without decorations", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "plain ERROR text");
assert.equal(cellRgb(term, 0, "ERROR"), RED);
assert.equal(term.buffer.active.getLine(0)?.getCell(0)?.getFgColor(), -1);
highlighter.dispose();
term.dispose();
});
test("ordinary Enter output does not revisit existing history", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "existing ERROR");
await highlighter.whenSettled();
const rebuildsBefore = highlighter.rebuildCount;
const before = cellRgb(term, 0, "ERROR");
await write(term, "\r\nplain prompt # ");
assert.equal(cellRgb(term, 0, "ERROR"), before);
assert.equal(highlighter.rebuildCount, rebuildsBefore);
highlighter.dispose();
term.dispose();
});
test("changing or disabling rules recolors existing history from original cell colors", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const visibleSerializer = new SerializeAddon();
term.loadAddon(visibleSerializer);
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "first ERROR\r\nsecond ERROR");
assert.equal(cellRgb(term, 0, "ERROR"), RED);
assert.match(visibleSerializer.serialize(), /38;2;248;113;113m/);
assert.equal(
highlighter.serializeAddon.serialize(),
"first ERROR\r\nsecond ERROR",
"saved history must never contain Netcatty's injected colors",
);
assert.equal(cellRgb(term, 0, "ERROR"), RED);
highlighter.setRules(rule("#60A5FA"), true);
await highlighter.whenSettled();
assert.equal(cellRgb(term, 0, "ERROR"), BLUE);
assert.equal(cellRgb(term, 1, "ERROR"), BLUE);
assert.match(visibleSerializer.serialize(), /38;2;96;165;250m/);
assert.doesNotMatch(visibleSerializer.serialize(), /38;2;248;113;113m/);
highlighter.setRules(rule("#60A5FA"), false);
await highlighter.whenSettled();
assert.equal(visibleSerializer.serialize(), "first ERROR\r\nsecond ERROR");
highlighter.dispose();
term.dispose();
});
test("keywords split across ordinary writes are colored after the line is complete", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "plain ERR");
assert.notEqual(cellRgb(term, 0, "ERR"), RED);
await write(term, "OR text");
assert.equal(cellRgb(term, 0, "ERROR"), RED);
highlighter.dispose();
term.dispose();
});
test("overlapping expressions color each character at most once", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules([{
id: "overlap",
label: "Overlap",
patterns: ["\\[ERROR\\]", "ERROR"],
color: "#F87171",
enabled: true,
}], true);
await write(term, "[ERROR]");
assert.equal(firstRgbMatch(term, RED), 7);
highlighter.dispose();
term.dispose();
});
test("line anchors are evaluated per output line", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules([{
id: "prompt",
label: "Prompt",
patterns: ["^#"],
color: "#F87171",
enabled: true,
}], true);
await write(term, "# one\r\n# two");
assert.equal(cellRgb(term, 0, "#"), RED);
assert.equal(cellRgb(term, 1, "#"), RED);
highlighter.dispose();
term.dispose();
});
test("alternate-screen programs are not keyword-colored", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "before ERROR");
assert.equal(cellRgb(term, 0, "ERROR"), RED);
await write(term, "\x1b[?1049hTUI ERROR");
assert.notEqual(cellRgb(term, 0, "ERROR"), RED);
await write(term, "\x1b[?1049l");
assert.equal(cellRgb(term, 0, "ERROR"), RED);
highlighter.dispose();
term.dispose();
});
test("byte output catches up once instead of staying permanently uncolored", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await new Promise<void>((resolve) => {
term.write(new TextEncoder().encode("byte ERROR"), resolve);
});
assert.notEqual(cellRgb(term, 0, "ERROR"), RED);
await new Promise((resolve) => setTimeout(resolve, 650));
await highlighter.whenSettled();
assert.equal(cellRgb(term, 0, "ERROR"), RED);
assert.equal(highlighter.rebuildCount, 1);
highlighter.dispose();
term.dispose();
});
test("large output can bypass per-write coloring and is still recolored on a rule change", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
let bypass = true;
const highlighter = new KeywordHighlighter(term, {
shouldBypassHighlight: () => bypass,
});
highlighter.setRules(rule(), true);
await write(term, `${"ERROR ".repeat(20)}tail`);
assert.notEqual(cellRgb(term, 0, "ERROR"), RED);
bypass = false;
highlighter.setRules(rule("#60A5FA"), true);
await highlighter.whenSettled();
assert.equal(cellRgb(term, 0, "ERROR"), BLUE);
highlighter.dispose();
term.dispose();
});
test("bulk output skipped on the hot path is highlighted after output becomes quiet", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 8, scrollback: 100 });
let bypass = true;
const highlighter = new KeywordHighlighter(term, {
shouldBypassHighlight: () => bypass,
});
highlighter.setRules(rule(), true);
const history = Array.from({ length: 40 }, (_, index) => `line-${index} ERROR`).join("\r\n");
await write(term, history);
assert.equal(highlighter.rebuildCount, 0, "hot-path flood must not recolor immediately");
assert.notEqual(cellRgb(term, 0, "ERROR"), RED);
bypass = false;
await new Promise((resolve) => setTimeout(resolve, 650));
await highlighter.whenSettled();
assert.equal(cellRgb(term, 0, "ERROR"), RED);
assert.equal(highlighter.rebuildCount, 1);
highlighter.dispose();
term.dispose();
});
test("ordinary Enter does not rebuild a saturated scrollback", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 40, rows: 3, scrollback: 10 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
const history = Array.from({ length: 20 }, (_, index) => `line-${index} ERROR`).join("\r\n");
noteTerminalOutputPressureData(term, history);
await write(term, history);
await new Promise((resolve) => setTimeout(resolve, 650));
await highlighter.whenSettled();
const rebuildsBeforeEnter = highlighter.rebuildCount;
const prompt = "\r\nplain prompt # ";
noteTerminalOutputPressureData(term, prompt);
await write(term, prompt);
await new Promise((resolve) => setTimeout(resolve, 650));
await highlighter.whenSettled();
assert.equal(highlighter.rebuildCount, rebuildsBeforeEnter);
highlighter.dispose();
term.dispose();
});
test("carriage-return rewrites rematch the current line", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "plain ERROR");
assert.equal(cellRgb(term, 0, "ERROR"), RED);
await write(term, "\rready OK ");
assert.equal(cellRgb(term, 0, "ERROR"), undefined);
assert.match(term.buffer.active.getLine(0)?.translateToString(true) ?? "", /ready OK/);
highlighter.dispose();
term.dispose();
});
test("rule changes keep scrollback position", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 40, rows: 4, scrollback: 40 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, Array.from({ length: 20 }, (_, index) => `line-${index} ERROR`).join("\r\n"));
term.scrollToLine(2);
const viewportY = term.buffer.active.viewportY;
highlighter.setRules(rule("#60A5FA"), true);
await highlighter.whenSettled();
assert.equal(term.buffer.active.viewportY, viewportY);
highlighter.dispose();
term.dispose();
});
test("rule changes preserve existing line timestamps", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await new Promise<void>((resolve) => {
writeTerminalDataWithLineTimestamps(term, "stamp ERROR\r\nnext ERROR", resolve, {
timestampDate: new Date("2026-08-13T00:00:00.000Z"),
});
});
const markersBefore = term.markers.length;
highlighter.setRules(rule("#60A5FA"), true);
await highlighter.whenSettled();
assert.equal(term.markers.length, markersBefore);
assert.equal(cellRgb(term, 0, "ERROR"), BLUE);
highlighter.dispose();
term.dispose();
});
test("same-line appends still restore original colors when rules are disabled", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "ERROR");
await write(term, " more");
assert.equal(cellRgb(term, 0, "ERROR"), RED);
highlighter.setRules(rule(), false);
await highlighter.whenSettled();
assert.notEqual(cellRgb(term, 0, "ERROR"), RED);
highlighter.dispose();
term.dispose();
});
test("clear recolors the retained cursor row when rules stay enabled", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "keep ERROR");
term.clear();
const line = term.buffer.active.getLine(0)?.translateToString(true) ?? "";
if (line.includes("ERROR")) {
assert.equal(cellRgb(term, 0, "ERROR"), RED);
}
highlighter.dispose();
term.dispose();
});
test("clear restores highlighted retained cells before dropping originals", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "ERROR stays");
term.clear();
highlighter.setRules(rule(), false);
await highlighter.whenSettled();
const line = term.buffer.active.getLine(0)?.translateToString(true) ?? "";
if (line.includes("ERROR")) {
assert.notEqual(cellRgb(term, 0, "ERROR"), RED);
}
highlighter.dispose();
term.dispose();
});
test("application reset clears highlight state with the visible terminal", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "old ERROR");
term.reset();
await write(term, "new plain");
assert.equal(highlighter.serializeAddon.serialize(), "new plain");
highlighter.dispose();
term.dispose();
});
test("8-digit plugin hex colors keep RGB and ignore alpha", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules([{
id: "plugin-alpha",
label: "Error",
patterns: ["ERROR"],
color: "#F8717180",
enabled: true,
providerId: "plugin.demo",
}], true);
await write(term, "plain ERROR text");
assert.equal(cellRgb(term, 0, "ERROR"), RED);
highlighter.dispose();
term.dispose();
});
test("plugin scans stay within the bounded match budget", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules([{
id: "plugin-dots",
label: "Dots",
patterns: ["."],
color: "#F87171",
enabled: true,
providerId: "plugin.demo",
}], true);
await write(term, "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ");
assert.ok(firstRgbMatch(term, RED) <= 256);
highlighter.dispose();
term.dispose();
});
test("Enter does not refresh already highlighted history rows", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "existing ERROR");
const refreshed: Array<[number, number]> = [];
const originalRefresh = term.refresh.bind(term);
term.refresh = (start, end) => {
refreshed.push([start, end]);
originalRefresh(start, end);
};
await write(term, "\r\nplain prompt # ");
assert.equal(cellRgb(term, 0, "ERROR"), RED);
assert.ok(refreshed.every(([start]) => start >= 1), JSON.stringify(refreshed));
highlighter.dispose();
term.dispose();
});
test("a keyword written on a saturated scrollback is still colored", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 20, rows: 3, scrollback: 2 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, Array.from({ length: 8 }, (_, index) => `pad-${index}`).join("\r\n"));
await write(term, "\r\nline ERROR");
assert.equal(cellRgb(term, term.buffer.active.baseY + term.buffer.active.cursorY, "ERROR")
?? cellRgb(term, term.buffer.active.length - 1, "ERROR")
?? cellRgb(term, term.buffer.active.length - 2, "ERROR"), RED);
highlighter.dispose();
term.dispose();
});
test("scrolling during a rule change catch-up recolors newly visible rows", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 40, rows: 4, scrollback: 80 });
let bypass = true;
const highlighter = new KeywordHighlighter(term, {
shouldBypassHighlight: () => bypass,
});
highlighter.setRules(rule(), true);
await write(term, Array.from({ length: 30 }, (_, index) => `line-${index} ERROR`).join("\r\n"));
bypass = false;
highlighter.setRules(rule("#60A5FA"), true);
term.scrollToLine(0);
assert.equal(cellRgb(term, 0, "ERROR"), BLUE);
await highlighter.whenSettled();
highlighter.dispose();
term.dispose();
});
test("catch-up does not hang when the terminal enters the alternate screen", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term, {
shouldBypassHighlight: () => true,
});
highlighter.setRules(rule(), true);
await write(term, "queued ERROR");
await write(term, "\x1b[?1049hTUI");
await Promise.race([
highlighter.whenSettled(),
new Promise((_, reject) => setTimeout(() => reject(new Error("whenSettled hung")), 200)),
]);
highlighter.dispose();
term.dispose();
});
test("partial grapheme matches still color the whole cell", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules([{
id: "letter-e",
label: "E",
patterns: ["e"],
color: "#F87171",
enabled: true,
}], true);
await write(term, "e\u0301");
assert.equal(term.buffer.active.getLine(0)?.getCell(0)?.getFgColor(), RED);
highlighter.dispose();
term.dispose();
});
test("wrapped matches stay on the same logical line", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 8, rows: 6, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
await write(term, "xx ERROR");
assert.equal(cellRgb(term, 0, "ER") === RED || cellRgb(term, 1, "ROR") === RED, true);
highlighter.dispose();
term.dispose();
});
test("identical flood lines are all colored after quiet catch-up", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 12, scrollback: 40 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1 payload=xxxxxxxx";
for (let index = 0; index < 6; index += 1) {
noteTerminalOutputPressureData(term, `${line}\r\n`);
await write(term, `${line}\r\n`);
}
const flood = Array.from({ length: 48 }, () => line).join("\r\n");
noteTerminalOutputPressureData(term, flood);
await write(term, flood);
await new Promise((resolve) => setTimeout(resolve, 650));
await highlighter.whenSettled();
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
highlighter.dispose();
term.dispose();
});
test("saturated flood does not rematch the pinned viewport on each write", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 8 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1";
const chunk = Array.from({ length: 16 }, () => line).join("\r\n");
for (let index = 0; index < 4; index += 1) {
noteTerminalOutputPressureData(term, chunk);
await write(term, chunk);
}
assert.equal(highlighter.rebuildCount, 0);
const viewportY = term.buffer.active.viewportY;
assert.notEqual(cellRgb(term, viewportY, "ERROR"), RED);
assert.notEqual(cellRgb(term, viewportY + 2, "ERROR"), RED);
highlighter.dispose();
term.dispose();
});
test("recycled identical flood rows are recolored on catch-up", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 8, scrollback: 12 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1";
const flood = Array.from({ length: 40 }, () => line).join("\r\n");
noteTerminalOutputPressureData(term, flood);
await write(term, flood);
noteTerminalOutputPressureData(term, flood);
await write(term, flood);
await new Promise((resolve) => setTimeout(resolve, 650));
await highlighter.whenSettled();
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
highlighter.dispose();
term.dispose();
});