Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
242 changes: 235 additions & 7 deletions components/terminal/keywordHighlight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1417,7 +1417,9 @@ test("Enter dirty work continues after a queued full refresh", async () => {
handlers.data?.("\r");
setLineText(22, "redrawn without a keyword");
handlers.writeParsed?.();
await new Promise((resolve) => { setTimeout(resolve, 340); });
// Idle Enter suppresses decoration mutation until the Enter guard clears
// (~600ms) so prompt redraw cannot flash still-visible keywords.
await new Promise((resolve) => { setTimeout(resolve, 850); });
raf.flush();

const originalDisposed = originalDecoration.isDisposed;
Expand Down Expand Up @@ -1587,15 +1589,38 @@ test("continuous Enter output still refreshes highlights periodically", async ()

handlers.data?.("\r");
setLineText(22, "DEPLOY");
for (let index = 0; index < 20; index += 1) {
handlers.writeParsed?.();
raf.flush();
await new Promise((resolve) => { setTimeout(resolve, 50); });
const internals = highlighter as unknown as {
lastRefreshTime: number;
lastUserInputAt: number;
};
// Sustained output must be a write burst, not spaced callbacks that look
// like a multi-batch prompt redraw.
internals.lastUserInputAt = Number.NEGATIVE_INFINITY;
const originalPerformance = globalThis.performance;
let simulatedNow = 0;
Object.defineProperty(globalThis, "performance", {
configurable: true,
value: {
now: () => simulatedNow,
},
});
try {
for (let index = 0; index < 12; index += 1) {
simulatedNow += 10;
internals.lastRefreshTime = Number.NEGATIVE_INFINITY;
handlers.writeParsed?.();
raf.flush();
}
} finally {
Object.defineProperty(globalThis, "performance", {
configurable: true,
value: originalPerformance,
});
}

assert.ok(
decorationStates.some(({ isDisposed }) => !isDisposed),
"ongoing output should not postpone new highlights until the stream stops",
"ongoing write-burst output should not postpone new highlights until the stream stops",
);
highlighter.dispose();
} finally {
Expand Down Expand Up @@ -1923,7 +1948,9 @@ test("Enter input still detects redraws away from the cursor", async () => {
handlers.data?.("\r");
setLineText(20, "redrawn without a keyword");
handlers.writeParsed?.();
await new Promise((resolve) => { setTimeout(resolve, 220); });
// Idle Enter defers decoration dispose/apply until the Enter guard clears.
await new Promise((resolve) => { setTimeout(resolve, 850); });
raf.flush();

assert.equal(originalDecoration.isDisposed, true);
highlighter.dispose();
Expand Down Expand Up @@ -2245,6 +2272,207 @@ test("idle Enter scroll before writeParsed does not rescan visible keywords", ()
}
});

test("idle Enter scroll before buffer dims update does not rescan", () => {
const raf = installAnimationFrameQueue();
try {
const {
term,
decorationStates,
handlers,
getTranslateCount,
resetTranslateCount,
refreshCalls,
resetRefreshCalls,
} = createFakeTerminal("hello DEPLOY world", { lineCount: 40 });
term.buffer.active.viewportY = 20;
term.buffer.active.baseY = 20;
term.buffer.active.cursorY = 2;
const highlighter = new KeywordHighlighter(term as never);
highlighter.setRules([{
id: "deploy",
label: "Deploy",
patterns: ["DEPLOY"],
color: "#F87171",
enabled: true,
}], true);
raf.flush();
const existingDecorations = [...decorationStates];
assert.ok(existingDecorations.length > 0);

const internals = highlighter as unknown as {
lastWriteAt: number;
lastRenderRange: { start: number; end: number } | null;
};
internals.lastWriteAt = performance.now() - 10_000;
internals.lastRenderRange = null;
resetTranslateCount();
resetRefreshCalls();

// Ubuntu RTT: onScroll can fire while length/baseY/cursor still match the
// last snapshot, so output-driven detection is false. Bottom-pinned Enter
// must still defer — requiring hasOutputDrivenViewportChange reopens flash.
handlers.data?.("\r");
handlers.scroll?.();

assert.equal(
getTranslateCount(),
0,
"Enter-pending bottom scroll without buffer-dim change must not rescan",
);
assert.deepEqual(
refreshCalls,
[],
"Enter-pending bottom scroll without buffer-dim change must not repaint",
);
assert.equal(
existingDecorations.filter(({ isDisposed }) => isDisposed).length,
0,
"Enter-pending bottom scroll must keep existing keyword decorations mounted",
);
highlighter.dispose();
} finally {
raf.restore();
}
});

test("idle Enter prompt redraw does not repaint existing keyword rows", async () => {
const raf = installAnimationFrameQueue();
try {
const {
term,
decorationStates,
handlers,
setLineText,
refreshCalls,
resetRefreshCalls,
} = createFakeTerminal("hello DEPLOY world", { lineCount: 40 });
term.buffer.active.viewportY = 20;
term.buffer.active.baseY = 20;
term.buffer.active.cursorY = 2;
const highlighter = new KeywordHighlighter(term as never);
highlighter.setRules([
{
id: "deploy",
label: "Deploy",
patterns: ["DEPLOY"],
color: "#F87171",
enabled: true,
},
{
id: "prompt",
label: "Prompt",
patterns: ["~", "#"],
color: "#60A5FA",
enabled: true,
},
], true);
raf.flush();
const existingDecorations = [...decorationStates];
assert.ok(existingDecorations.length > 0);
resetRefreshCalls();

handlers.data?.("\r");
term.buffer.active.viewportY += 1;
term.buffer.active.baseY += 1;
term.buffer.active.length += 1;
// New prompt line matches custom ~/# rules — applying those decorations
// makes xterm repaint the full viewport and flashes still-visible keywords.
setLineText(22, "user@host:~# ");
handlers.scroll?.();
handlers.writeParsed?.();
await new Promise((resolve) => { setTimeout(resolve, 220); });
raf.flush();

assert.equal(
existingDecorations.filter(({ isDisposed }) => isDisposed).length,
0,
"idle Enter must keep prior keyword decorations mounted",
);
assert.deepEqual(
refreshCalls,
[],
"idle Enter prompt redraw must not register decorations that force a viewport repaint",
);
highlighter.dispose();
} finally {
raf.restore();
}
});

test("idle Enter keeps suppression across split echo and prompt writes", async () => {
const raf = installAnimationFrameQueue();
try {
const {
term,
decorationStates,
handlers,
setLineText,
refreshCalls,
resetRefreshCalls,
} = createFakeTerminal("hello DEPLOY world", { lineCount: 40 });
term.buffer.active.viewportY = 20;
term.buffer.active.baseY = 20;
term.buffer.active.cursorY = 2;
const highlighter = new KeywordHighlighter(term as never);
highlighter.setRules([
{
id: "deploy",
label: "Deploy",
patterns: ["DEPLOY"],
color: "#F87171",
enabled: true,
},
{
id: "prompt",
label: "Prompt",
patterns: ["~", "#"],
color: "#60A5FA",
enabled: true,
},
], true);
raf.flush();
const existingDecorations = [...decorationStates];
assert.ok(existingDecorations.length > 0);
resetRefreshCalls();

handlers.data?.("\r");
// Batch 1: newline echo advances the buffer.
term.buffer.active.viewportY += 1;
term.buffer.active.baseY += 1;
term.buffer.active.length += 1;
handlers.scroll?.();
handlers.writeParsed?.();
await new Promise((resolve) => { setTimeout(resolve, 40); });
raf.flush();

// Batch 2: prompt text for the same idle Enter (custom ~/# matches).
setLineText(22, "user@host:~# ");
handlers.writeParsed?.();
await new Promise((resolve) => { setTimeout(resolve, 40); });
raf.flush();

// Batch 3: trailing mode/control write — still the same prompt redraw,
// not sustained command output.
handlers.writeParsed?.();
await new Promise((resolve) => { setTimeout(resolve, 220); });
raf.flush();

assert.equal(
existingDecorations.filter(({ isDisposed }) => isDisposed).length,
0,
"split idle-Enter writes must keep prior keyword decorations mounted",
);
assert.deepEqual(
refreshCalls,
[],
"a three-batch idle prompt must not register decorations that flash the viewport",
);
highlighter.dispose();
} finally {
raf.restore();
}
});

test("Enter without write clears pending so later user scroll can highlight", async () => {
const raf = installAnimationFrameQueue();
try {
Expand Down
Loading
Loading