diff --git a/desktop/package.json b/desktop/package.json index 338bc0132..8a54daded 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -91,9 +91,11 @@ "@tanstack/router-plugin": "^1.167.12", "@tanstack/virtual-file-routes": "^1.161.7", "@tauri-apps/cli": "~2.11", + "@testing-library/react": "^16.3.2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^6.0.0", + "jsdom": "^27.4.0", "nostr-tools": "^2.23.3", "postcss": "^8.5.8", "tailwindcss": "^4.3.0", diff --git a/desktop/src/features/terminal/TerminalSubstrate.test.mjs b/desktop/src/features/terminal/TerminalSubstrate.test.mjs new file mode 100644 index 000000000..609ac4062 --- /dev/null +++ b/desktop/src/features/terminal/TerminalSubstrate.test.mjs @@ -0,0 +1,233 @@ +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("
", { + 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.getContext = () => ({ + 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 view = render( + createElement( + ThemeProvider, + null, + createElement("div", { + className: "buzz-huddle-app-surface", + tabIndex: -1, + }), + createElement(TerminalSubstrate, props), + ), + ); + return { calls, props, view }; +} + +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, ["か"]); +}); + +test("welcome survives non-intersecting frame and dismisses on input or intersecting damage", async () => { + const baseFrame = { + cursor: { column: 0, line: 0, visible: false }, + full: false, + rows: [ + { + line: 0, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [{ column: 0, text: "$", width: 1 }], + }, + ], + }, + ], + viewport: { columns: 80, generation: 1, screenLines: 24 }, + }; + const { view } = fixture({ frame: baseFrame }); + await ready(view); + assert.ok(view.container.querySelector(".buzz-terminal-welcome")); + toggleChord(); + const input = view.getByLabelText("Terminal input"); + fireEvent.input(input, { target: { value: "x" } }); + await waitFor(() => + assert.equal(view.container.querySelector(".buzz-terminal-welcome"), null), + ); + + cleanup(); + const intersecting = fixture({ + frame: { + ...baseFrame, + rows: [ + { + line: 8, + spans: [ + { + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [{ column: 0, text: "x", width: 1 }], + }, + ], + }, + ], + }, + }); + await ready(intersecting.view); + await waitFor(() => + assert.equal( + intersecting.view.container.querySelector(".buzz-terminal-welcome"), + null, + ), + ); +}); + +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"), + ); +}); diff --git a/desktop/src/features/terminal/TerminalSubstrate.tsx b/desktop/src/features/terminal/TerminalSubstrate.tsx index 3a3ad7dda..295d9246b 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.tsx +++ b/desktop/src/features/terminal/TerminalSubstrate.tsx @@ -5,9 +5,12 @@ import { cn } from "@/shared/lib/cn"; import { FadeController } from "./fadeController"; import { INITIAL_HANDOFF_STATE, + accumulateScrollLines, encodePaste, encodeTerminalKey, reduceHandoff, + updateWelcomeForOutput, + type WelcomeState, } from "./terminalState"; import { type TerminalFrame, TerminalGrid } from "./terminalRenderer"; @@ -42,6 +45,42 @@ function isToggleChord(event: KeyboardEvent): boolean { ); } +const CELL_HEIGHT = 17; +const WELCOME_COLUMNS = 25; +const WELCOME_LINES = 4; + +function welcomeRect(frame: TerminalFrame) { + const top = Math.max( + 0, + Math.floor(frame.viewport.screenLines * 0.42 - WELCOME_LINES / 2), + ); + const left = Math.max( + 0, + Math.floor((frame.viewport.columns - WELCOME_COLUMNS) / 2), + ); + return { + top, + left, + bottom: top + WELCOME_LINES, + right: left + WELCOME_COLUMNS, + }; +} + +function frameDamageRects(frame: TerminalFrame) { + return frame.rows + .filter((row) => + row.spans.some((span) => + span.clusters.some((cluster) => cluster.text.trim().length > 0), + ), + ) + .map((row) => ({ + top: row.line, + left: 0, + bottom: row.line + 1, + right: frame.viewport.columns, + })); +} + export function TerminalSubstrate({ appSurfaceRef, channelName, @@ -65,7 +104,13 @@ export function TerminalSubstrate({ const paintedPaletteRef = React.useRef(terminalPalette); const previousFocusRef = React.useRef