Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
606 lines
17 KiB
TypeScript
606 lines
17 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import { notifyTerminalSearchTermChange } from "./TerminalSearchBar.tsx";
|
|
import {
|
|
armSearchHighlightRevivalGuard,
|
|
disarmSearchAddonRevival,
|
|
disposeStaleSearchDecorations,
|
|
installSearchDecorationTracker,
|
|
resetTerminalSearch,
|
|
SEARCH_HIGHLIGHT_REVIVAL_GUARD_MS,
|
|
settleTerminalSearchAfterLayout,
|
|
shouldResetOnSharedSearchClose,
|
|
stripStaleSearchDecorationNodes,
|
|
subscribeTerminalUserSelection,
|
|
} from "./hooks/useTerminalSearch.ts";
|
|
|
|
test("clearing the search input notifies the terminal search handler", () => {
|
|
const terms: string[] = [];
|
|
const onSearch = (term: string) => {
|
|
terms.push(term);
|
|
return false;
|
|
};
|
|
|
|
let previousTerm = notifyTerminalSearchTermChange("needle", "", onSearch);
|
|
previousTerm = notifyTerminalSearchTermChange("", previousTerm, onSearch);
|
|
|
|
assert.equal(previousTerm, "");
|
|
assert.deepEqual(terms, ["needle", ""]);
|
|
});
|
|
|
|
test("unchanged search input does not repeat a search", () => {
|
|
const terms: string[] = [];
|
|
|
|
const previousTerm = notifyTerminalSearchTermChange("needle", "needle", (term) => {
|
|
terms.push(term);
|
|
return false;
|
|
});
|
|
|
|
assert.equal(previousTerm, "needle");
|
|
assert.deepEqual(terms, []);
|
|
});
|
|
|
|
test("resetting terminal search clears both match decorations and active selection", () => {
|
|
let decorationsVisible = true;
|
|
let activeSelectionVisible = true;
|
|
const searchedTerms: string[] = [];
|
|
const searchAddon = {
|
|
findNext(term: string) {
|
|
searchedTerms.push(term);
|
|
if (term === "") {
|
|
decorationsVisible = false;
|
|
activeSelectionVisible = false;
|
|
}
|
|
return false;
|
|
},
|
|
clearDecorations() {
|
|
decorationsVisible = false;
|
|
},
|
|
};
|
|
const term = {
|
|
rows: 24,
|
|
refresh() {},
|
|
clearSelection() {
|
|
activeSelectionVisible = false;
|
|
},
|
|
};
|
|
const searchTermRef = { current: "needle" };
|
|
|
|
resetTerminalSearch(searchAddon, searchTermRef, term);
|
|
|
|
assert.equal(searchTermRef.current, "");
|
|
assert.deepEqual(searchedTerms, [""]);
|
|
assert.equal(decorationsVisible, false);
|
|
assert.equal(activeSelectionVisible, false);
|
|
});
|
|
|
|
test("resetting terminal search clears cache before empty find and refreshes", () => {
|
|
// SearchAddon keeps a 200ms onWriteParsed/onResize timer that can revive
|
|
// highlights after reset if cachedSearchTerm was not cleared first. Also,
|
|
// findNext("", { decorations }) re-arms lastSearchOptions.decorations — so
|
|
// the empty find must not pass decoration options. clearDecorations alone
|
|
// leaves the active-match selection; clear it explicitly and refresh so
|
|
// Windows/WebGL does not keep yellow match backgrounds.
|
|
const calls: string[] = [];
|
|
const findNextArgs: unknown[] = [];
|
|
const searchAddon = {
|
|
findNext(term: string, options?: unknown) {
|
|
calls.push(`findNext:${term}`);
|
|
findNextArgs.push(options);
|
|
return false;
|
|
},
|
|
clearDecorations() {
|
|
calls.push("clearDecorations");
|
|
},
|
|
};
|
|
const term = {
|
|
rows: 24,
|
|
refresh(start: number, end: number) {
|
|
calls.push(`refresh:${start}:${end}`);
|
|
},
|
|
clearSelection() {
|
|
calls.push("clearSelection");
|
|
},
|
|
};
|
|
const searchTermRef = { current: "needle" };
|
|
|
|
resetTerminalSearch(searchAddon, searchTermRef, term);
|
|
|
|
assert.equal(searchTermRef.current, "");
|
|
assert.deepEqual(calls, [
|
|
"clearDecorations",
|
|
"clearSelection",
|
|
"findNext:",
|
|
"clearDecorations",
|
|
"refresh:0:23",
|
|
]);
|
|
assert.equal(findNextArgs[0], undefined);
|
|
});
|
|
|
|
test("search highlight revival guard re-clears decorations and the addon selection", () => {
|
|
assert.ok(SEARCH_HIGHLIGHT_REVIVAL_GUARD_MS > 200);
|
|
|
|
const calls: string[] = [];
|
|
let scheduled: { cb: () => void; ms: number } | null = null;
|
|
const searchAddon = {
|
|
clearDecorations() {
|
|
calls.push("clearDecorations");
|
|
},
|
|
};
|
|
const term = {
|
|
rows: 10,
|
|
refresh(start: number, end: number) {
|
|
calls.push(`refresh:${start}:${end}`);
|
|
},
|
|
clearSelection() {
|
|
calls.push("clearSelection");
|
|
},
|
|
};
|
|
|
|
const guard = armSearchHighlightRevivalGuard({
|
|
getSearchAddon: () => searchAddon,
|
|
getTerm: () => term,
|
|
setTimeoutFn: ((cb: () => void, ms: number) => {
|
|
scheduled = { cb, ms };
|
|
return 1 as unknown as ReturnType<typeof setTimeout>;
|
|
}) as typeof setTimeout,
|
|
clearTimeoutFn: (() => {}) as typeof clearTimeout,
|
|
});
|
|
|
|
guard.arm();
|
|
assert.ok(scheduled);
|
|
assert.equal(scheduled?.ms, SEARCH_HIGHLIGHT_REVIVAL_GUARD_MS);
|
|
assert.deepEqual(calls, []);
|
|
|
|
// SearchAddon._updateMatches can revive decorations and re-select the
|
|
// previous active match. With no user selection in the window, clear both.
|
|
calls.push("revived");
|
|
scheduled?.cb();
|
|
|
|
assert.deepEqual(calls, [
|
|
"revived",
|
|
"clearDecorations",
|
|
"refresh:0:9",
|
|
"clearSelection",
|
|
]);
|
|
|
|
guard.dispose();
|
|
});
|
|
|
|
test("search highlight revival guard keeps a user selection made during the window", () => {
|
|
const calls: string[] = [];
|
|
let scheduled: { cb: () => void; ms: number } | null = null;
|
|
const searchAddon = {
|
|
clearDecorations() {
|
|
calls.push("clearDecorations");
|
|
},
|
|
};
|
|
const term = {
|
|
rows: 10,
|
|
refresh(start: number, end: number) {
|
|
calls.push(`refresh:${start}:${end}`);
|
|
},
|
|
clearSelection() {
|
|
calls.push("clearSelection");
|
|
},
|
|
};
|
|
|
|
const guard = armSearchHighlightRevivalGuard({
|
|
getSearchAddon: () => searchAddon,
|
|
getTerm: () => term,
|
|
setTimeoutFn: ((cb: () => void, ms: number) => {
|
|
scheduled = { cb, ms };
|
|
return 1 as unknown as ReturnType<typeof setTimeout>;
|
|
}) as typeof setTimeout,
|
|
clearTimeoutFn: (() => {}) as typeof clearTimeout,
|
|
});
|
|
|
|
guard.arm();
|
|
guard.markUserSelection();
|
|
scheduled?.cb();
|
|
|
|
assert.deepEqual(calls, [
|
|
"clearDecorations",
|
|
"refresh:0:9",
|
|
]);
|
|
|
|
guard.dispose();
|
|
});
|
|
|
|
test("subscribeTerminalUserSelection marks on pointer down and unsubscribes", () => {
|
|
const marks: string[] = [];
|
|
const listeners = new Map<string, EventListener>();
|
|
const element = {
|
|
addEventListener(type: string, listener: EventListener) {
|
|
listeners.set(type, listener);
|
|
},
|
|
removeEventListener(type: string) {
|
|
listeners.delete(type);
|
|
},
|
|
};
|
|
const unsubscribe = subscribeTerminalUserSelection(
|
|
{ element: element as unknown as HTMLElement },
|
|
() => marks.push("mark"),
|
|
);
|
|
|
|
listeners.get("mousedown")?.(new Event("mousedown"));
|
|
listeners.get("touchstart")?.(new Event("touchstart"));
|
|
assert.deepEqual(marks, ["mark", "mark"]);
|
|
|
|
unsubscribe();
|
|
assert.equal(listeners.size, 0);
|
|
});
|
|
|
|
test("revival guard subscribes to user selection only while armed", () => {
|
|
let subscribed = 0;
|
|
let unsubscribed = 0;
|
|
let scheduled: { cb: () => void } | null = null;
|
|
const guard = armSearchHighlightRevivalGuard({
|
|
getSearchAddon: () => ({
|
|
clearDecorations() {},
|
|
}),
|
|
getTerm: () => ({
|
|
rows: 1,
|
|
refresh() {},
|
|
clearSelection() {},
|
|
}),
|
|
subscribeUserSelection: () => {
|
|
subscribed += 1;
|
|
return () => {
|
|
unsubscribed += 1;
|
|
};
|
|
},
|
|
setTimeoutFn: ((cb: () => void) => {
|
|
scheduled = { cb };
|
|
return 1 as unknown as ReturnType<typeof setTimeout>;
|
|
}) as typeof setTimeout,
|
|
clearTimeoutFn: (() => {}) as typeof clearTimeout,
|
|
});
|
|
|
|
guard.arm();
|
|
assert.equal(subscribed, 1);
|
|
assert.equal(unsubscribed, 0);
|
|
|
|
scheduled?.cb();
|
|
assert.equal(unsubscribed, 1);
|
|
|
|
guard.dispose();
|
|
});
|
|
|
|
test("shared search close only resets terminals that have a local query", () => {
|
|
assert.equal(shouldResetOnSharedSearchClose(""), false);
|
|
assert.equal(shouldResetOnSharedSearchClose("needle"), true);
|
|
});
|
|
|
|
const leftoverDecoration = (className: string) => {
|
|
const node = {
|
|
className,
|
|
removed: false,
|
|
remove() {
|
|
this.removed = true;
|
|
},
|
|
};
|
|
return node;
|
|
};
|
|
|
|
test("resetting terminal search strips leftover find-result decoration nodes", () => {
|
|
// Esc close disposes SearchAddon decorations, but the last match's HTML
|
|
// overlay can remain — typically the first two cells of the previous hit.
|
|
const leftover = leftoverDecoration("xterm-find-result-decoration");
|
|
const searchAddon = {
|
|
findNext() {
|
|
return false;
|
|
},
|
|
clearDecorations() {},
|
|
};
|
|
const term = {
|
|
rows: 24,
|
|
refresh() {},
|
|
clearSelection() {},
|
|
element: {
|
|
querySelectorAll(selector: string) {
|
|
assert.match(selector, /xterm-find-result-decoration/);
|
|
return [leftover];
|
|
},
|
|
},
|
|
};
|
|
|
|
resetTerminalSearch(searchAddon, { current: "pro" }, term);
|
|
|
|
assert.equal(leftover.removed, true);
|
|
});
|
|
|
|
test("stripStaleSearchDecorationNodes removes both result and active overlays", () => {
|
|
const resultNode = leftoverDecoration("xterm-find-result-decoration");
|
|
const activeNode = leftoverDecoration("xterm-find-active-result-decoration");
|
|
stripStaleSearchDecorationNodes({
|
|
element: {
|
|
querySelectorAll() {
|
|
return [resultNode, activeNode];
|
|
},
|
|
},
|
|
});
|
|
assert.equal(resultNode.removed, true);
|
|
assert.equal(activeNode.removed, true);
|
|
});
|
|
|
|
test("settling search after an emptied query keeps a later manual selection", () => {
|
|
const leftover = leftoverDecoration("xterm-find-result-decoration");
|
|
const calls: string[] = [];
|
|
const term = {
|
|
rows: 12,
|
|
registerDecoration() {
|
|
return undefined;
|
|
},
|
|
refresh() {
|
|
calls.push("refresh");
|
|
},
|
|
clearSelection() {
|
|
calls.push("clearSelection");
|
|
},
|
|
element: {
|
|
querySelectorAll() {
|
|
return [leftover];
|
|
},
|
|
},
|
|
};
|
|
const tracker = installSearchDecorationTracker(term);
|
|
tracker.markSearched();
|
|
tracker.noteEmptyQueryReset();
|
|
settleTerminalSearchAfterLayout(
|
|
{
|
|
clearDecorations() {
|
|
calls.push("clearDecorations");
|
|
},
|
|
},
|
|
term,
|
|
() => calls.push("atlas"),
|
|
);
|
|
assert.equal(calls.includes("clearSelection"), false);
|
|
assert.ok(calls.includes("clearDecorations"));
|
|
assert.ok(calls.includes("atlas"));
|
|
assert.equal(leftover.removed, true);
|
|
});
|
|
|
|
test("settling search after layout skips terminals that never searched", () => {
|
|
const leftover = leftoverDecoration("xterm-find-result-decoration");
|
|
const calls: string[] = [];
|
|
const term = {
|
|
rows: 12,
|
|
registerDecoration() {
|
|
return undefined;
|
|
},
|
|
refresh() {
|
|
calls.push("refresh");
|
|
},
|
|
clearSelection() {
|
|
calls.push("clearSelection");
|
|
},
|
|
element: {
|
|
querySelectorAll() {
|
|
return [leftover];
|
|
},
|
|
},
|
|
};
|
|
installSearchDecorationTracker(term);
|
|
settleTerminalSearchAfterLayout(
|
|
{
|
|
clearDecorations() {
|
|
calls.push("clearDecorations");
|
|
},
|
|
},
|
|
term,
|
|
() => calls.push("atlas"),
|
|
);
|
|
assert.deepEqual(calls, []);
|
|
assert.equal(leftover.removed, false);
|
|
});
|
|
|
|
test("settling search after layout re-clears decorations, selection, leftover nodes, and repaints", () => {
|
|
const leftover = leftoverDecoration("xterm-find-result-decoration");
|
|
const calls: string[] = [];
|
|
const searchAddon = {
|
|
clearDecorations() {
|
|
calls.push("clearDecorations");
|
|
},
|
|
};
|
|
const term = {
|
|
rows: 12,
|
|
refresh(start: number, end: number) {
|
|
calls.push(`refresh:${start}:${end}`);
|
|
},
|
|
clearSelection() {
|
|
calls.push("clearSelection");
|
|
},
|
|
element: {
|
|
querySelectorAll() {
|
|
calls.push("queryLeftover");
|
|
return [leftover];
|
|
},
|
|
},
|
|
};
|
|
|
|
settleTerminalSearchAfterLayout(searchAddon, term, () => {
|
|
calls.push("atlas");
|
|
});
|
|
|
|
assert.equal(leftover.removed, true);
|
|
assert.deepEqual(calls, [
|
|
"clearDecorations",
|
|
"queryLeftover",
|
|
"refresh:0:11",
|
|
"clearSelection",
|
|
"atlas",
|
|
]);
|
|
});
|
|
|
|
test("disposeStaleSearchDecorations drops leaked search backgrounds and keeps others", () => {
|
|
const searchMatch = {
|
|
disposed: false,
|
|
options: { backgroundColor: "#FFFF0044" },
|
|
dispose() {
|
|
this.disposed = true;
|
|
},
|
|
};
|
|
const activeMatch = {
|
|
disposed: false,
|
|
options: { backgroundColor: "#FF880088" },
|
|
dispose() {
|
|
this.disposed = true;
|
|
},
|
|
};
|
|
const cursorLine = {
|
|
disposed: false,
|
|
options: { backgroundColor: "#1e293b" },
|
|
dispose() {
|
|
this.disposed = true;
|
|
},
|
|
};
|
|
const count = disposeStaleSearchDecorations({
|
|
_core: {
|
|
_decorationService: {
|
|
decorations: [searchMatch, activeMatch, cursorLine],
|
|
},
|
|
},
|
|
});
|
|
assert.equal(count, 2);
|
|
assert.equal(searchMatch.disposed, true);
|
|
assert.equal(activeMatch.disposed, true);
|
|
assert.equal(cursorLine.disposed, false);
|
|
});
|
|
|
|
test("resetting terminal search disposes leaked WebGL search decorations", () => {
|
|
const leaked = {
|
|
disposed: false,
|
|
options: { backgroundColor: "#ffff0044" },
|
|
dispose() {
|
|
this.disposed = true;
|
|
},
|
|
};
|
|
resetTerminalSearch(
|
|
{
|
|
findNext() {
|
|
return false;
|
|
},
|
|
clearDecorations() {},
|
|
},
|
|
{ current: "s" },
|
|
{
|
|
rows: 24,
|
|
refresh() {},
|
|
clearSelection() {},
|
|
_core: {
|
|
_decorationService: {
|
|
decorations: [leaked],
|
|
},
|
|
},
|
|
} as never,
|
|
);
|
|
assert.equal(leaked.disposed, true);
|
|
});
|
|
|
|
test("search decoration tracker disposes leaked incremental matches", () => {
|
|
const live: Array<{
|
|
registeredBackground?: string;
|
|
overlayBackground?: string;
|
|
disposed: boolean;
|
|
dispose: () => void;
|
|
onDispose: (cb: () => void) => { dispose: () => void };
|
|
onRender: (cb: (el: { style: { backgroundColor: string } }) => void) => { dispose: () => void };
|
|
}> = [];
|
|
const registeredBackgrounds: Array<string | undefined> = [];
|
|
const term = {
|
|
registerDecoration(options: { backgroundColor?: string }) {
|
|
registeredBackgrounds.push(options.backgroundColor);
|
|
const disposeListeners: Array<() => void> = [];
|
|
const renderListeners: Array<(el: { style: { backgroundColor: string } }) => void> = [];
|
|
const decoration = {
|
|
registeredBackground: options.backgroundColor,
|
|
overlayBackground: undefined as string | undefined,
|
|
disposed: false,
|
|
dispose() {
|
|
if (this.disposed) return;
|
|
this.disposed = true;
|
|
for (const listener of disposeListeners) listener();
|
|
},
|
|
onDispose(cb: () => void) {
|
|
disposeListeners.push(cb);
|
|
return { dispose() {} };
|
|
},
|
|
onRender(cb: (el: { style: { backgroundColor: string } }) => void) {
|
|
renderListeners.push(cb);
|
|
return { dispose() {} };
|
|
},
|
|
paint() {
|
|
const el = { style: { backgroundColor: "" } };
|
|
for (const listener of renderListeners) listener(el);
|
|
this.overlayBackground = el.style.backgroundColor;
|
|
},
|
|
};
|
|
live.push(decoration);
|
|
return decoration;
|
|
},
|
|
};
|
|
const tracker = installSearchDecorationTracker(term);
|
|
const match = term.registerDecoration({ backgroundColor: "#FFFF0044" });
|
|
term.registerDecoration({ backgroundColor: "#FF880088" });
|
|
term.registerDecoration({ backgroundColor: "#1e293b" });
|
|
match?.paint();
|
|
assert.deepEqual(registeredBackgrounds, [undefined, undefined, "#1e293b"]);
|
|
assert.equal(match?.overlayBackground, "#FFFF0044");
|
|
assert.equal(tracker.size(), 2);
|
|
tracker.markSearched();
|
|
assert.equal(tracker.hasSearched(), true);
|
|
assert.equal(tracker.disposeAll(), 2);
|
|
assert.equal(live.filter((item) => item.disposed).length, 2);
|
|
assert.equal(live.some((item) => item.registeredBackground === "#1e293b" && !item.disposed), true);
|
|
assert.equal(tracker.size(), 0);
|
|
});
|
|
|
|
test("xterm runtime installs the search decoration tracker", () => {
|
|
const source = readFileSync(new URL("./runtime/createXTermRuntime.ts", import.meta.url), "utf8");
|
|
assert.match(source, /installSearchDecorationTracker\(term\)/);
|
|
});
|
|
|
|
test("disarmSearchAddonRevival cancels the addon timer and resets cached state", () => {
|
|
const calls: string[] = [];
|
|
disarmSearchAddonRevival({
|
|
_highlightTimeout: {
|
|
clear() {
|
|
calls.push("clearTimeout");
|
|
},
|
|
},
|
|
_state: {
|
|
reset() {
|
|
calls.push("resetState");
|
|
},
|
|
},
|
|
clearActiveDecoration() {
|
|
calls.push("clearActive");
|
|
},
|
|
clearDecorations() {
|
|
calls.push("clearDecorations");
|
|
},
|
|
});
|
|
assert.deepEqual(calls, [
|
|
"clearTimeout",
|
|
"resetState",
|
|
"clearActive",
|
|
"clearDecorations",
|
|
]);
|
|
});
|
|
|
|
test("search-bar close fit settles leftover highlights after layout", () => {
|
|
const source = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
|
|
assert.match(source, /settleTerminalSearchAfterLayout/);
|
|
assert.match(source, /closingSearch = wasSearchOpen && !isSearchOpen/);
|
|
assert.match(source, /if \(!closingSearch \|\| prevIsSearchOpenRef\.current\) return/);
|
|
assert.match(source, /if \(raf\) cancelAnimationFrame\(raf\)/);
|
|
assert.match(
|
|
source,
|
|
/safeFit\(\{ force: true, requireVisible: true \}\);[\s\S]*settleClosedSearch/,
|
|
);
|
|
});
|