test: add app browser coverage

This commit is contained in:
Mohamed Boudra
2026-04-21 20:46:49 +07:00
parent 051507717c
commit 79bf5ac3c7
11 changed files with 573 additions and 14 deletions
+51
View File
@@ -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",
+2
View File
@@ -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",
@@ -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<DndContextProps>) => {
latestDndContextProps = props;
return <div>{children}</div>;
},
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: <T,>(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(
<DraggableList
data={["alpha", "beta"]}
keyExtractor={(item) => item}
onDragEnd={vi.fn()}
renderItem={({ item, isActive }) => (
<div data-active={String(isActive)} data-testid={`item-${item}`}>
{item}
</div>
)}
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");
});
});
@@ -159,12 +159,16 @@ export function DraggableList<T>({
[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<T>({
}
}
},
[items, keyExtractor, onDragEnd],
[clearDragState, items, keyExtractor, onDragEnd],
);
const ids = items.map((item, index) => keyExtractor(item, index));
@@ -207,6 +211,7 @@ export function DraggableList<T>({
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
@@ -237,6 +242,7 @@ export function DraggableList<T>({
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
@@ -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<DndContextProps>) => {
latestDndContextProps = props;
return <div>{children}</div>;
},
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: <T,>(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(
<SortableInlineList
data={["alpha", "beta"]}
keyExtractor={(item) => item}
onDragEnd={vi.fn()}
renderItem={({ item, isActive }) => (
<div data-active={String(isActive)} data-testid={`item-${item}`}>
{item}
</div>
)}
/>,
);
});
}
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");
});
});
@@ -155,12 +155,16 @@ export function SortableInlineList<T>({
[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<T>({
}
}
},
[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<T>({
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}
>
{renderedItems}
@@ -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<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => {
resolve();
});
});
}
async function waitFor(input: { predicate: () => boolean; timeoutMs?: number }): Promise<void> {
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();
});
});
@@ -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();
@@ -1 +1,4 @@
export class LigaturesAddon {}
export class LigaturesAddon {
activate(): void {}
dispose(): void {}
}
+32 -2
View File
@@ -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"),
},
],
+5 -1
View File
@@ -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"),
},
],