Files
buzz/desktop/src/features/terminal/TerminalSubstrate.test.mjs
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc e3deaffbe6 fix(desktop): keep the Buzz Term splash until the terminal is revealed
The welcome banner was dismissed by the first visible output from the
active PTY, but TerminalBootstrap spawns the shell when AppShell mounts
and a real shell prints its prompt immediately. The banner was therefore
always gone before the user first opened the terminal.

Dismiss on the first keystroke sent to the PTY, or on visible output that
arrives after the terminal has been revealed at least once. Keystroke
alone would leave the banner painted over live scrollback when a
background job prints into a revealed-but-idle terminal.

Also stop comparing a mounted DOM node against null in the welcome test
helper: building that AssertionError message inspects the element, and
the React fiber keys make the walk take ~2 minutes. A failure in these
rows now reports in about a second.

Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
2026-08-02 17:06:59 -04:00

315 lines
8.5 KiB
JavaScript

import assert from "node:assert/strict";
import { after, before, beforeEach, test } from "node:test";
import { JSDOM } from "jsdom";
let act;
let cleanup;
let createElement;
let fireEvent;
let render;
let waitFor;
let ThemeProvider;
let TerminalSubstrate;
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
before(async () => {
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
HTMLCanvasElement: dom.window.HTMLCanvasElement,
KeyboardEvent: dom.window.KeyboardEvent,
window: dom.window,
IS_REACT_ACT_ENVIRONMENT: true,
});
dom.window.matchMedia = () => ({
matches: false,
addEventListener() {},
removeEventListener() {},
});
dom.window.HTMLElement.prototype.animate = () => ({
cancel() {},
currentTime: 0,
finished: new Promise(() => {}),
play() {},
playbackRate: 1,
reverse() {},
});
({ act, cleanup, fireEvent, render, waitFor } = await import(
"@testing-library/react"
));
({ createElement } = await import("react"));
({ ThemeProvider } = await import("@/shared/theme/ThemeProvider"));
({ TerminalSubstrate } = await import("./TerminalSubstrate.tsx"));
});
after(() => dom.window.close());
beforeEach(() => {
cleanup?.();
dom.window.localStorage.clear();
dom.window.HTMLCanvasElement.prototype.getBoundingClientRect = () => ({
bottom: 782,
height: 782,
left: 0,
right: 940.8,
top: 0,
width: 940.8,
x: 0,
y: 0,
toJSON() {},
});
dom.window.HTMLCanvasElement.prototype.getContext = () => ({
clearRect() {},
fillRect() {},
fillStyle: "",
fillText() {},
font: "",
restore() {},
save() {},
setTransform() {},
textBaseline: "",
});
});
function fixture(overrides = {}) {
const calls = { input: [], scroll: [] };
const props = {
bracketedPaste: false,
channelName: "terminal-test",
focusReportingEnabled: false,
onCloseSession() {},
onInput(value) {
calls.input.push(value);
},
onNewSession() {},
onScroll(lines) {
calls.scroll.push(lines);
},
onSelectSession() {},
onTerminalFocusChange() {},
sessions: [{ active: true, closing: false, id: "one", title: "SHELL" }],
...overrides,
};
const renderTree = (nextProps) =>
createElement(
ThemeProvider,
null,
createElement("div", {
className: "buzz-huddle-app-surface",
tabIndex: -1,
}),
createElement(TerminalSubstrate, nextProps),
);
const view = render(renderTree(props));
return {
calls,
props,
view,
rerender(nextOverrides) {
view.rerender(renderTree({ ...props, ...nextOverrides }));
},
};
}
async function ready(view) {
await waitFor(() =>
assert.ok(view.container.querySelector(".buzz-terminal-substrate")),
);
}
function toggleChord(composing = false) {
const init = {
bubbles: true,
code: "KeyJ",
isComposing: composing,
metaKey: true,
};
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", init));
window.dispatchEvent(new KeyboardEvent("keyup", init));
});
}
test("mounted IME paths neither toggle nor emit preedit text", async () => {
const { calls, view } = fixture();
await ready(view);
const substrate = view.container.querySelector(".buzz-terminal-substrate");
toggleChord(true);
assert.equal(substrate.dataset.terminalOwner, "buzz");
toggleChord();
await waitFor(() =>
assert.equal(substrate.dataset.terminalOwner, "terminal"),
);
const input = view.getByLabelText("Terminal input");
fireEvent.compositionStart(input);
fireEvent.input(input, { target: { value: "k" } });
fireEvent.input(input, { target: { value: "か" } });
assert.deepEqual(calls.input, []);
fireEvent.compositionEnd(input, { data: "か" });
assert.deepEqual(calls.input, []);
fireEvent.input(input, { target: { value: "か" } });
assert.deepEqual(calls.input, ["か"]);
});
const EMPTY_FRAME = {
cursor: { column: 0, line: 0, visible: false },
full: false,
rows: [],
viewport: { columns: 112, generation: 1, screenLines: 46 },
};
const VISIBLE_FRAME = {
...EMPTY_FRAME,
viewport: { ...EMPTY_FRAME.viewport, generation: 2 },
rows: [
{
line: 0,
spans: [
{
style: { fg: 0, bg: 0, flags: 0 },
clusters: [{ column: 0, text: "$", width: 1 }],
},
],
},
],
};
async function expectWelcome(view, present) {
// Compare a boolean, never the node: an AssertionError carrying a mounted
// element inspects it to build its message, and `__reactFiber$` makes that
// walk the whole fiber graph — a failure takes ~2min to report instead of ms.
await waitFor(() =>
assert.equal(
view.container.querySelector(".buzz-terminal-welcome") !== null,
present,
),
);
}
async function reveal(view) {
const substrate = view.container.querySelector(".buzz-terminal-substrate");
toggleChord();
await waitFor(() =>
assert.equal(substrate.dataset.terminalOwner, "terminal"),
);
}
test("spawn-time output before the first reveal keeps the welcome overlay", async () => {
const subject = fixture({ frame: EMPTY_FRAME });
await ready(subject.view);
await expectWelcome(subject.view, true);
subject.rerender({ frame: VISIBLE_FRAME });
await expectWelcome(subject.view, true);
await reveal(subject.view);
await expectWelcome(subject.view, true);
});
test("the first keystroke dismisses the welcome overlay", async () => {
const subject = fixture({ frame: VISIBLE_FRAME });
await ready(subject.view);
await reveal(subject.view);
await expectWelcome(subject.view, true);
fireEvent.input(subject.view.getByLabelText("Terminal input"), {
target: { value: "l" },
});
await expectWelcome(subject.view, false);
});
test("non-empty output after the reveal dismisses the welcome overlay", async () => {
const subject = fixture({ frame: EMPTY_FRAME });
await ready(subject.view);
await reveal(subject.view);
await expectWelcome(subject.view, true);
subject.rerender({ frame: VISIBLE_FRAME });
await expectWelcome(subject.view, false);
});
test("empty active output keeps the welcome overlay", async () => {
const subject = fixture({ frame: EMPTY_FRAME });
await ready(subject.view);
await reveal(subject.view);
await expectWelcome(subject.view, true);
subject.rerender({
frame: {
...EMPTY_FRAME,
viewport: { ...EMPTY_FRAME.viewport, generation: 2 },
},
});
await expectWelcome(subject.view, true);
});
test("non-empty output from an inactive PTY keeps the welcome overlay", async () => {
const subject = fixture({
sessionFrames: [{ frame: EMPTY_FRAME, sessionId: "one" }],
sessions: [
{ active: true, closing: false, id: "one", title: "SHELL" },
{ active: false, closing: false, id: "two", title: "LOG" },
],
});
await ready(subject.view);
await reveal(subject.view);
await expectWelcome(subject.view, true);
subject.rerender({
sessionFrames: [
{ frame: EMPTY_FRAME, sessionId: "one" },
{ frame: VISIBLE_FRAME, sessionId: "two" },
],
});
await expectWelcome(subject.view, true);
});
test("mounted wheel path accumulates fractional lines per active session", async () => {
const { calls, view } = fixture();
await ready(view);
const substrate = view.container.querySelector(".buzz-terminal-substrate");
fireEvent.wheel(substrate, { deltaMode: 0, deltaY: 8 });
assert.deepEqual(calls.scroll, []);
fireEvent.wheel(substrate, { deltaMode: 0, deltaY: 10 });
assert.deepEqual(calls.scroll, [1]);
});
test("canvas failure atomically restores Buzz ownership", async () => {
const { props, view } = fixture();
await ready(view);
const substrate = view.container.querySelector(".buzz-terminal-substrate");
toggleChord();
await waitFor(() =>
assert.equal(substrate.dataset.terminalOwner, "terminal"),
);
dom.window.HTMLCanvasElement.prototype.getContext = () => null;
view.rerender(
createElement(
ThemeProvider,
null,
createElement("div", {
className: "buzz-huddle-app-surface",
tabIndex: -1,
}),
createElement(TerminalSubstrate, {
...props,
frame: {
cursor: { column: 0, line: 0, visible: false },
full: false,
rows: [],
viewport: { columns: 80, generation: 1, screenLines: 24 },
},
}),
),
);
await waitFor(() => assert.equal(substrate.dataset.terminalOwner, "buzz"));
toggleChord();
await waitFor(() =>
assert.equal(substrate.dataset.terminalOwner, "terminal"),
);
});