test: add app browser coverage
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user