From 79bf5ac3c7b0f7c861bda096376de3a9e888f07e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 21 Apr 2026 20:46:49 +0700 Subject: [PATCH] test: add app browser coverage --- package-lock.json | 51 +++++ packages/app/package.json | 2 + .../components/draggable-list.web.test.tsx | 131 ++++++++++++ .../app/src/components/draggable-list.web.tsx | 12 +- .../sortable-inline-list.web.test.tsx | 113 ++++++++++ .../components/sortable-inline-list.web.tsx | 11 +- .../terminal-emulator-runtime.browser.test.ts | 193 ++++++++++++++++++ .../runtime/terminal-emulator-runtime.test.ts | 29 ++- .../app/test-stubs/xterm-addon-ligatures.ts | 5 +- packages/app/vitest.config.ts | 34 ++- vitest.config.ts | 6 +- 11 files changed, 573 insertions(+), 14 deletions(-) create mode 100644 packages/app/src/components/draggable-list.web.test.tsx create mode 100644 packages/app/src/components/sortable-inline-list.web.test.tsx create mode 100644 packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts diff --git a/package-lock.json b/package-lock.json index 1aa1a8f61..a6874d53c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12882,6 +12882,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -14145,6 +14159,42 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/browser": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.4.tgz", + "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/user-event": "^14.6.1", + "@vitest/mocker": "3.2.4", + "@vitest/utils": "3.2.4", + "magic-string": "^0.30.17", + "sirv": "^3.0.1", + "tinyrainbow": "^2.0.0", + "ws": "^8.18.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "3.2.4", + "webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -37391,6 +37441,7 @@ "@testing-library/react": "^16.3.2", "@types/react": "~19.2.0", "@types/ws": "^8.18.1", + "@vitest/browser": "^3.2.4", "eas-cli": "^16.24.1", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", diff --git a/packages/app/package.json b/packages/app/package.json index 472f52960..1e82f5e07 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -19,6 +19,7 @@ "lint": "expo lint", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:browser": "vitest run --project browser", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "build": "npm run build:web", @@ -112,6 +113,7 @@ "@testing-library/react": "^16.3.2", "@types/react": "~19.2.0", "@types/ws": "^8.18.1", + "@vitest/browser": "^3.2.4", "eas-cli": "^16.24.1", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", diff --git a/packages/app/src/components/draggable-list.web.test.tsx b/packages/app/src/components/draggable-list.web.test.tsx new file mode 100644 index 000000000..a2e0150ad --- /dev/null +++ b/packages/app/src/components/draggable-list.web.test.tsx @@ -0,0 +1,131 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DraggableList } from "./draggable-list.web"; + +type DndContextProps = { + onDragStart?: (event: { active: { id: string } }) => void; + onDragCancel?: () => void; +}; + +let latestDndContextProps: DndContextProps | null = null; + +vi.mock("@dnd-kit/core", () => ({ + DndContext: ({ children, ...props }: React.PropsWithChildren) => { + latestDndContextProps = props; + return
{children}
; + }, + closestCenter: vi.fn(), + KeyboardSensor: vi.fn(), + PointerSensor: vi.fn(), + useSensor: vi.fn(() => ({})), + useSensors: vi.fn(() => []), +})); + +vi.mock("@dnd-kit/sortable", () => ({ + SortableContext: ({ children }: React.PropsWithChildren) => <>{children}, + arrayMove: (items: T[], from: number, to: number) => { + const next = [...items]; + const [item] = next.splice(from, 1); + if (item !== undefined) { + next.splice(to, 0, item); + } + return next; + }, + sortableKeyboardCoordinates: vi.fn(), + useSortable: () => ({ + attributes: {}, + listeners: {}, + setNodeRef: vi.fn(), + setActivatorNodeRef: vi.fn(), + transform: null, + transition: undefined, + isDragging: false, + }), + verticalListSortingStrategy: {}, +})); + +vi.mock("./use-web-scrollbar", () => ({ + useWebScrollViewScrollbar: () => ({ + onLayout: vi.fn(), + onContentSizeChange: vi.fn(), + onScroll: vi.fn(), + overlay: null, + }), +})); + +let root: Root | null = null; +let container: HTMLElement | null = null; + +beforeEach(() => { + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + latestDndContextProps = null; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); +}); + +function renderList(): void { + act(() => { + root?.render( + item} + onDragEnd={vi.fn()} + renderItem={({ item, isActive }) => ( +
+ {item} +
+ )} + scrollEnabled={false} + />, + ); + }); +} + +function getItemActiveState(item: string): string | null { + return ( + container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null + ); +} + +describe("DraggableList web", () => { + it("clears active drag state when a drag is cancelled", () => { + renderList(); + + act(() => { + latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } }); + }); + expect(getItemActiveState("alpha")).toBe("true"); + + act(() => { + latestDndContextProps?.onDragCancel?.(); + }); + expect(getItemActiveState("alpha")).toBe("false"); + }); +}); diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index 42863e24f..a08f2be98 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -159,12 +159,16 @@ export function DraggableList({ [data, onDragBegin], ); + const clearDragState = useCallback(() => { + setActiveId(null); + setDragItems(null); + }, []); + const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; - setActiveId(null); - setDragItems(null); + clearDragState(); if (over && active.id !== over.id) { const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id); @@ -176,7 +180,7 @@ export function DraggableList({ } } }, - [items, keyExtractor, onDragEnd], + [clearDragState, items, keyExtractor, onDragEnd], ); const ids = items.map((item, index) => keyExtractor(item, index)); @@ -207,6 +211,7 @@ export function DraggableList({ collisionDetection={closestCenter} modifiers={[restrictToVerticalAxis]} onDragStart={handleDragStart} + onDragCancel={clearDragState} onDragEnd={handleDragEnd} > @@ -237,6 +242,7 @@ export function DraggableList({ collisionDetection={closestCenter} modifiers={[restrictToVerticalAxis]} onDragStart={handleDragStart} + onDragCancel={clearDragState} onDragEnd={handleDragEnd} > diff --git a/packages/app/src/components/sortable-inline-list.web.test.tsx b/packages/app/src/components/sortable-inline-list.web.test.tsx new file mode 100644 index 000000000..00d77c95f --- /dev/null +++ b/packages/app/src/components/sortable-inline-list.web.test.tsx @@ -0,0 +1,113 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SortableInlineList } from "./sortable-inline-list.web"; + +type DndContextProps = { + onDragStart?: (event: { active: { id: string } }) => void; + onDragCancel?: () => void; +}; + +let latestDndContextProps: DndContextProps | null = null; + +vi.mock("@dnd-kit/core", () => ({ + DndContext: ({ children, ...props }: React.PropsWithChildren) => { + latestDndContextProps = props; + return
{children}
; + }, + closestCenter: vi.fn(), + KeyboardSensor: vi.fn(), + PointerSensor: vi.fn(), + useSensor: vi.fn(() => ({})), + useSensors: vi.fn(() => []), +})); + +vi.mock("@dnd-kit/sortable", () => ({ + SortableContext: ({ children }: React.PropsWithChildren) => <>{children}, + arrayMove: (items: T[], from: number, to: number) => { + const next = [...items]; + const [item] = next.splice(from, 1); + if (item !== undefined) { + next.splice(to, 0, item); + } + return next; + }, + horizontalListSortingStrategy: {}, + sortableKeyboardCoordinates: vi.fn(), + useSortable: () => ({ + attributes: {}, + listeners: {}, + setNodeRef: vi.fn(), + setActivatorNodeRef: vi.fn(), + transform: null, + transition: undefined, + isDragging: false, + }), +})); + +let root: Root | null = null; +let container: HTMLElement | null = null; + +beforeEach(() => { + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + latestDndContextProps = null; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); +}); + +function renderList(): void { + act(() => { + root?.render( + item} + onDragEnd={vi.fn()} + renderItem={({ item, isActive }) => ( +
+ {item} +
+ )} + />, + ); + }); +} + +function getItemActiveState(item: string): string | null { + return ( + container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null + ); +} + +describe("SortableInlineList web", () => { + it("clears active drag state when a drag is cancelled", () => { + renderList(); + + act(() => { + latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } }); + }); + expect(getItemActiveState("alpha")).toBe("true"); + + act(() => { + latestDndContextProps?.onDragCancel?.(); + }); + expect(getItemActiveState("alpha")).toBe("false"); + }); +}); diff --git a/packages/app/src/components/sortable-inline-list.web.tsx b/packages/app/src/components/sortable-inline-list.web.tsx index 6a1061fa6..cd71a39ca 100644 --- a/packages/app/src/components/sortable-inline-list.web.tsx +++ b/packages/app/src/components/sortable-inline-list.web.tsx @@ -155,12 +155,16 @@ export function SortableInlineList({ [data, disabled, onDragBegin], ); + const clearDragState = useCallback(() => { + setActiveId(null); + setDragItems(null); + }, []); + const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; - setActiveId(null); - setDragItems(null); + clearDragState(); if (disabled) { return; @@ -176,7 +180,7 @@ export function SortableInlineList({ } } }, - [disabled, items, keyExtractor, onDragEnd], + [clearDragState, disabled, items, keyExtractor, onDragEnd], ); const ids = items.map((item, index) => keyExtractor(item, index)); @@ -213,6 +217,7 @@ export function SortableInlineList({ collisionDetection={closestCenter} modifiers={[restrictToHorizontalAxis]} onDragStart={handleDragStart} + onDragCancel={clearDragState} onDragEnd={handleDragEnd} > {renderedItems} diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts new file mode 100644 index 000000000..04e14178e --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -0,0 +1,193 @@ +import { page } from "@vitest/browser/context"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime"; + +vi.mock("@xterm/addon-webgl", () => ({ + WebglAddon: class WebglAddon { + activate(): void {} + dispose(): void {} + onContextLoss(): void {} + }, +})); + +type TerminalSize = { rows: number; cols: number }; + +type BrowserTerminal = TerminalSize & { + refresh: (start: number, end: number) => void; + reset: () => void; +}; + +type MountedTerminal = { + host: HTMLDivElement; + root: HTMLDivElement; + runtime: TerminalEmulatorRuntime; + sizes: TerminalSize[]; +}; + +const mountedTerminals: MountedTerminal[] = []; + +function nextFrame(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => { + resolve(); + }); + }); +} + +async function waitFor(input: { predicate: () => boolean; timeoutMs?: number }): Promise { + const startedAt = performance.now(); + const timeoutMs = input.timeoutMs ?? 2_000; + + while (!input.predicate()) { + if (performance.now() - startedAt > timeoutMs) { + throw new Error("Timed out waiting for terminal browser condition"); + } + await nextFrame(); + } +} + +function createTerminalHost(input: { width: number; height: number }): MountedTerminal { + const root = document.createElement("div"); + root.style.width = `${input.width}px`; + root.style.height = `${input.height}px`; + root.style.position = "fixed"; + root.style.left = "0"; + root.style.top = "0"; + root.style.overflow = "hidden"; + + const host = document.createElement("div"); + host.style.width = "100%"; + host.style.height = "100%"; + root.appendChild(host); + document.body.appendChild(root); + + const sizes: TerminalSize[] = []; + const runtime = new TerminalEmulatorRuntime(); + runtime.setCallbacks({ + callbacks: { + onResize: (size) => { + sizes.push(size); + }, + }, + }); + runtime.mount({ + root, + host, + initialSnapshot: null, + theme: { + background: "#0b0b0b", + foreground: "#e6e6e6", + cursor: "#e6e6e6", + }, + }); + + const mounted = { host, root, runtime, sizes }; + mountedTerminals.push(mounted); + return mounted; +} + +function latestSize(sizes: TerminalSize[]): TerminalSize { + const size = sizes.at(-1); + if (!size) { + throw new Error("Terminal did not report a size"); + } + return size; +} + +function getBrowserTerminal(): BrowserTerminal { + const terminal = window.__paseoTerminal as BrowserTerminal | undefined; + if (!terminal) { + throw new Error("Expected xterm to be exposed for browser test inspection"); + } + return terminal; +} + +afterEach(() => { + for (const mounted of mountedTerminals.splice(0)) { + mounted.runtime.unmount(); + mounted.root.remove(); + } +}); + +describe("terminal emulator runtime in a real browser", () => { + it("reports a larger PTY size when the terminal container grows", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 360, height: 180 }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + const initialSize = latestSize(mounted.sizes); + + mounted.root.style.width = "720px"; + mounted.root.style.height = "360px"; + await nextFrame(); + mounted.runtime.resize({ force: true }); + + await waitFor({ + predicate: () => { + const size = latestSize(mounted.sizes); + return size.cols > initialSize.cols && size.rows > initialSize.rows; + }, + }); + + const grownSize = latestSize(mounted.sizes); + expect(grownSize.cols).toBeGreaterThan(initialSize.cols); + expect(grownSize.rows).toBeGreaterThan(initialSize.rows); + }); + + it("refreshes visible rows on a forced same-size resize", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360 }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + const terminal = getBrowserTerminal(); + const refreshCalls: Array<[number, number]> = []; + const originalRefresh = terminal.refresh.bind(terminal); + terminal.refresh = (start, end) => { + refreshCalls.push([start, end]); + originalRefresh(start, end); + }; + + mounted.runtime.resize({ force: true }); + + await waitFor({ predicate: () => refreshCalls.length > 0 }); + expect(refreshCalls.at(-1)).toEqual([0, terminal.rows - 1]); + }); + + it("replays snapshots without synchronously resetting the visible terminal", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360 }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + const terminal = getBrowserTerminal(); + const originalReset = terminal.reset.bind(terminal); + const reset = vi.fn(originalReset); + terminal.reset = reset; + + mounted.runtime.renderSnapshot({ + state: { + rows: terminal.rows, + cols: terminal.cols, + scrollback: [], + grid: [ + [ + { char: "p" }, + { char: "r" }, + { char: "o" }, + { char: "m" }, + { char: "p" }, + { char: "t" }, + ], + ], + cursor: { + row: 0, + col: 6, + }, + }, + }); + await nextFrame(); + + expect(reset).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index 504a5a52d..e8a5fe532 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -39,10 +39,6 @@ vi.mock("@xterm/xterm", () => ({ Terminal: class Terminal {}, })); -vi.mock("@/utils/open-external-url", () => ({ - openExternalUrl: vi.fn(), -})); - import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime"; type StubTerminal = { @@ -211,6 +207,31 @@ describe("terminal-emulator-runtime", () => { expect(onCommittedB).toHaveBeenCalledTimes(1); }); + it("replays snapshots through a single write without first painting a reset terminal", () => { + const { runtime, terminal, writeTexts } = createRuntimeWithTerminal(); + + runtime.renderSnapshot({ + state: { + rows: 2, + cols: 8, + scrollback: [], + grid: [ + [{ char: "h" }, { char: "i" }], + [{ char: "$" }, { char: " " }], + ], + cursor: { + row: 1, + col: 2, + }, + }, + }); + + expect(terminal.resetCalls).toBe(0); + expect(writeTexts).toHaveLength(1); + expect(writeTexts[0]?.startsWith("\u001bc")).toBe(true); + expect(writeTexts[0]).toContain("hi"); + }); + it("forces a refit when resize is requested", () => { const runtime = new TerminalEmulatorRuntime(); const fitAndEmitResize = vi.fn(); diff --git a/packages/app/test-stubs/xterm-addon-ligatures.ts b/packages/app/test-stubs/xterm-addon-ligatures.ts index b7949387e..32ac7e09d 100644 --- a/packages/app/test-stubs/xterm-addon-ligatures.ts +++ b/packages/app/test-stubs/xterm-addon-ligatures.ts @@ -1 +1,4 @@ -export class LigaturesAddon {} +export class LigaturesAddon { + activate(): void {} + dispose(): void {} +} diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 6b55aceff..ccfbfa63b 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -15,7 +15,33 @@ export default defineConfig({ test: { environment: "node", exclude: [...configDefaults.exclude, "e2e/**"], - setupFiles: [path.resolve(__dirname, "vitest.setup.ts")], + projects: [ + { + extends: true, + test: { + name: "unit", + environment: "node", + include: ["src/**/*.{test,spec}.{ts,tsx}"], + setupFiles: [path.resolve(__dirname, "vitest.setup.ts")], + exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"], + }, + }, + { + extends: true, + test: { + name: "browser", + include: ["src/**/*.browser.{test,spec}.{ts,tsx}"], + browser: { + enabled: true, + provider: "playwright", + headless: true, + connectTimeout: 180_000, + instances: [{ browser: "chromium" }], + screenshotDirectory: ".vitest-screenshots", + }, + }, + }, + ], /** * Expo pulls in native tooling (xcode, etc.) that executes files relying on `process.send`. * Vitest's default worker pool uses worker_threads, which intentionally stub that API and @@ -75,7 +101,11 @@ export default defineConfig({ replacement: resolvePackageEntry("react-dom"), }, { - find: "@xterm/addon-ligatures", + find: /^@xterm\/addon-ligatures\/lib\/addon-ligatures\.mjs$/, + replacement: path.resolve(__dirname, "test-stubs/xterm-addon-ligatures.ts"), + }, + { + find: /^@xterm\/addon-ligatures$/, replacement: path.resolve(__dirname, "test-stubs/xterm-addon-ligatures.ts"), }, ], diff --git a/vitest.config.ts b/vitest.config.ts index 2f1f4684a..a7f329add 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -47,7 +47,11 @@ export default defineConfig({ { find: "react", replacement: resolvePackageEntry("react") }, { find: "react-dom", replacement: resolvePackageEntry("react-dom") }, { - find: "@xterm/addon-ligatures", + find: /^@xterm\/addon-ligatures\/lib\/addon-ligatures\.mjs$/, + replacement: path.resolve(appDir, "test-stubs/xterm-addon-ligatures.ts"), + }, + { + find: /^@xterm\/addon-ligatures$/, replacement: path.resolve(appDir, "test-stubs/xterm-addon-ligatures.ts"), }, ],