/** * Standing in for the Rust side. * * The frontend is an ordinary web page; the only thing it needs from Tauri is * the IPC bridge. Installing a fake one before the page loads lets the real * components run in a real browser with no native build, no window, and no * system permissions. * * This reaches for `window.__TAURI_INTERNALS__`, which is what * `@tauri-apps/api`'s own `mockIPC` sets. It is deliberately the only place in * the tests that knows that, so a Tauri upgrade that moves it breaks here and * nowhere else. */ import type { Page } from "@playwright/test"; import type { GraphRow, RecentRepository, RefEntry } from "../../src/lib/bindings"; export type MockOptions = { /** Every row the fake backend is willing to hand over. */ rows: GraphRow[]; /** Make `open_repository` reject, to exercise the failure path. */ failToOpen?: string; /** What the recents list starts out holding. */ recents?: RecentRepository[]; /** The repository's references, for the sidebar. */ references?: RefEntry[]; /** * What the folder dialog returns. `null` stands for the user dismissing it, * which the app must treat as an ordinary outcome rather than a failure. */ chooseFolder?: string | null; }; /** * Install the fake bridge. Must be called before navigating. * * The fake honours the budget the way the real backend does: it sends what it * was asked for and no more, so a test that never scrolls really does see only * the first batch. */ export async function mockTauri(page: Page, options: MockOptions): Promise { await page.addInitScript((serialised: MockOptions) => { const all = serialised.rows; let sent = 0; let channel: { onmessage: (event: unknown) => void } | undefined; const deliver = (count: number) => { const batch = all.slice(sent, sent + count); if (batch.length > 0) { channel?.onmessage({ event: "rows", data: { start: sent, rows: batch } }); sent += batch.length; } if (sent >= all.length) { channel?.onmessage({ event: "exhausted", data: { total: sent, degraded: false } }); } }; // Recorded so tests can assert on what the frontend actually asked for. const calls: { command: string; args: Record }[] = []; (window as unknown as { __calls: typeof calls }).__calls = calls; let recents = serialised.recents ?? []; (window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = { transformCallback: () => Math.random(), invoke: async (command: string, args: Record) => { calls.push({ command, args: { ...args, channel: undefined } }); switch (command) { case "open_repository": { if (serialised.failToOpen) { throw { kind: "git", message: serialised.failToOpen }; } return { id: "/demo/.git", summary: { gitDir: "/demo/.git", workdir: "/demo", isBare: false, head: { state: "branch", name: "refs/heads/main", shorthand: "main", id: "abc1234def5678", }, }, }; } case "stream_graph": { channel = args.channel as { onmessage: (event: unknown) => void }; sent = 0; deliver(args.budget as number); return null; } case "request_more_rows": { deliver(args.rows as number); return null; } // The dialog plugin goes over the same bridge, so standing in for it // needs no separate mechanism. case "plugin:dialog|open": { return serialised.chooseFolder ?? null; } case "list_references": { return serialised.references ?? []; } case "list_recent_repositories": { return recents; } case "forget_recent_repository": { const before = recents.length; recents = recents.filter((entry) => entry.id !== args.id); return recents.length !== before; } default: return null; } }, }; }, options); } /** * Open a repository through the UI, the way a person would. * * The folder dialog is native, so what the mock returns for it is set up front * by `mockTauri`; this just presses the button that summons it. */ export async function openRepository(page: Page): Promise { await page.getByRole("button", { name: "Choose folder…" }).click(); } /** Every command the frontend has invoked, in order. */ export function invocations(page: Page): Promise<{ command: string }[]> { return page.evaluate(() => (window as unknown as { __calls: { command: string }[] }).__calls); } /** * The commit rows. * * Scoped to `main`, because the ref sidebar is a list too — an unscoped * `listitem` query quietly counts four extra rows that are not commits. */ export function commitRows(page: Page) { return page.getByRole("main").getByRole("listitem"); } /** * The remembered repositories. * * Scoped by the list's own name for the same reason as {@link commitRows}: the * ref sidebar is a list too. */ export function recentRows(page: Page) { return page.getByRole("list", { name: "Recent" }).getByRole("listitem"); } /** * The entries in one sidebar section. * * Addressed by the list's own name, because the history is a list too. */ export function sidebarSection(page: Page, title: "Branches" | "Remotes" | "Tags") { return page .getByRole("navigation", { name: "Repository" }) .getByRole("list", { name: new RegExp(`^${title}`, "u") }) .getByRole("listitem"); }