mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
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>
315 lines
8.5 KiB
JavaScript
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"),
|
|
);
|
|
});
|