From f1944e1c2789ad02b66591df7346fb3c536cf4fd Mon Sep 17 00:00:00 2001 From: Maja507 Date: Fri, 16 Oct 2026 05:21:20 +0100 Subject: [PATCH] test: argument quoting, clipboard fallback/copy flow, landing page visual regression - testkit.test.ts: add quoting regression tests across coverage/limits/audit (unquoted when no spaces, quoted when spaces, default-path fallbacks) - clipboard.ts: extract copyToClipboard with an execCommand fallback for browsers without navigator.clipboard, used by App.tsx's copy button - clipboard.test.ts: cover the Clipboard API path, the no-Clipboard-API fallback, and the case where the fallback copy is rejected - App.test.tsx: browser-level tests that clicking Copy writes the currently-generated command to the clipboard, for the active tab, and that copying still succeeds with no Clipboard API present - playwright.config.ts, tests/visual/landing.spec.ts: add visual regression coverage for the landing page (hero, modules, command workbench per tab, full page), wired into CI as a separate job --- .github/workflows/ci.yml | 18 ++++++++++ .gitignore | 4 +++ package.json | 5 ++- playwright.config.ts | 29 ++++++++++++++++ src/App.test.tsx | 65 ++++++++++++++++++++++++++++++++++++ src/App.tsx | 3 +- src/clipboard.test.ts | 56 +++++++++++++++++++++++++++++++ src/clipboard.ts | 28 ++++++++++++++++ src/testkit.test.ts | 35 +++++++++++++++++++ tests/visual/landing.spec.ts | 35 +++++++++++++++++++ 10 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 playwright.config.ts create mode 100644 src/App.test.tsx create mode 100644 src/clipboard.test.ts create mode 100644 src/clipboard.ts create mode 100644 tests/visual/landing.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f8439f..59b65bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,3 +21,21 @@ jobs: - run: npm run lint - run: npm test - run: npm run build + + visual-regression: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run test:visual + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 1bfcb0a..c218d2a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ coverage/ *.log *.tsbuildinfo .DS_Store +test-results/ +playwright-report/ +blob-report/ +playwright/.cache/ diff --git a/package.json b/package.json index 9562460..cbffd69 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "test": "vitest run" + "test": "vitest run", + "test:visual": "playwright test", + "test:visual:update": "playwright test --update-snapshots" }, "dependencies": { "@vitejs/plugin-react": "latest", @@ -17,6 +19,7 @@ }, "devDependencies": { "@eslint/js": "latest", + "@playwright/test": "latest", "@testing-library/jest-dom": "latest", "@testing-library/react": "latest", "@types/react": "latest", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..c1d7272 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Visual regression config for the landing page. Baselines live under + * tests/visual/*-snapshots/ and are generated/refreshed with + * `npm run test:visual:update`. + */ +export default defineConfig({ + testDir: "./tests/visual", + snapshotPathTemplate: "{testDir}/{testFileDir}/__screenshots__/{arg}{ext}", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: "list", + use: { + baseURL: "http://localhost:4173", + }, + webServer: { + command: "npm run build && npm run dev -- --port 4173", + url: "http://localhost:4173", + reuseExistingServer: !process.env.CI, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..8676631 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import App from "./App"; + +describe("App command copy", () => { + const originalClipboard = navigator.clipboard; + + afterEach(() => { + Object.defineProperty(navigator, "clipboard", { + value: originalClipboard, + configurable: true, + }); + vi.restoreAllMocks(); + }); + + it("copies the currently generated command to the clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Copy" })); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith("soroban-testkit coverage . --format html --open"), + ); + expect(await screen.findByRole("button", { name: "Copied" })).toBeInTheDocument(); + }); + + it("copies the command for the selected tab, not the default one", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "audit" })); + fireEvent.click(screen.getByRole("button", { name: "Copy" })); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith("soroban-testkit audit . --strict"), + ); + }); + + it("still copies successfully when the browser has no Clipboard API", async () => { + Object.defineProperty(navigator, "clipboard", { + value: undefined, + configurable: true, + }); + const execCommand = vi.fn().mockReturnValue(true); + document.execCommand = execCommand; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Copy" })); + + await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy")); + expect(await screen.findByRole("button", { name: "Copied" })).toBeInTheDocument(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 80f1c00..902695f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { buildCommand, modules, type CommandName } from "./testkit"; +import { copyToClipboard } from "./clipboard"; const descriptions: Record = { coverage: "Generate contract-test coverage through cargo-llvm-cov.", @@ -23,7 +24,7 @@ export default function App() { ); const copy = async () => { - await navigator.clipboard.writeText(generated); + await copyToClipboard(generated); setCopied(true); window.setTimeout(() => setCopied(false), 1600); }; diff --git a/src/clipboard.test.ts b/src/clipboard.test.ts new file mode 100644 index 0000000..c9ced55 --- /dev/null +++ b/src/clipboard.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { copyToClipboard } from "./clipboard"; + +describe("copyToClipboard", () => { + const originalClipboard = navigator.clipboard; + + afterEach(() => { + Object.defineProperty(navigator, "clipboard", { + value: originalClipboard, + configurable: true, + }); + vi.restoreAllMocks(); + }); + + it("uses the Clipboard API when the browser supports it", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + await copyToClipboard("soroban-testkit audit ."); + + expect(writeText).toHaveBeenCalledWith("soroban-testkit audit ."); + }); + + it("falls back to execCommand when navigator.clipboard is unavailable", async () => { + Object.defineProperty(navigator, "clipboard", { + value: undefined, + configurable: true, + }); + const execCommand = vi.fn().mockReturnValue(true); + document.execCommand = execCommand; + + await expect( + copyToClipboard("soroban-testkit coverage . --format html"), + ).resolves.toBeUndefined(); + + expect(execCommand).toHaveBeenCalledWith("copy"); + // The off-screen textarea used for the fallback must not leak into the DOM. + expect(document.querySelector("textarea")).toBeNull(); + }); + + it("surfaces an error when the fallback copy is rejected by the browser", async () => { + Object.defineProperty(navigator, "clipboard", { + value: undefined, + configurable: true, + }); + document.execCommand = vi.fn().mockReturnValue(false); + + await expect(copyToClipboard("soroban-testkit audit .")).rejects.toThrow( + "execCommand('copy') was rejected by the browser", + ); + expect(document.querySelector("textarea")).toBeNull(); + }); +}); diff --git a/src/clipboard.ts b/src/clipboard.ts new file mode 100644 index 0000000..a084877 --- /dev/null +++ b/src/clipboard.ts @@ -0,0 +1,28 @@ +/** + * Copies `text` to the clipboard, falling back to the `document.execCommand` + * path for browsers (or embedded/non-HTTPS contexts) where + * `navigator.clipboard` is unavailable. + */ +export async function copyToClipboard(text: string): Promise { + if (navigator.clipboard) { + await navigator.clipboard.writeText(text); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + + try { + const succeeded = document.execCommand("copy"); + if (!succeeded) { + throw new Error("execCommand('copy') was rejected by the browser"); + } + } finally { + document.body.removeChild(textarea); + } +} diff --git a/src/testkit.test.ts b/src/testkit.test.ts index 22226a8..f03c0b1 100644 --- a/src/testkit.test.ts +++ b/src/testkit.test.ts @@ -11,4 +11,39 @@ describe("buildCommand", () => { expect(buildCommand({ command: "audit", path: "./my contract/src", format: "text", open: false, fn: "", ramp: "", strict: true })) .toBe('soroban-testkit audit "./my contract/src" --strict'); }); + + it("does not quote a coverage path with no spaces", () => { + expect(buildCommand({ command: "coverage", path: "./contracts/escrow", format: "lcov", open: false, fn: "", ramp: "", strict: false })) + .toBe("soroban-testkit coverage ./contracts/escrow --format lcov"); + }); + + it("quotes a coverage path containing spaces", () => { + expect(buildCommand({ command: "coverage", path: "./my contract", format: "html", open: false, fn: "", ramp: "", strict: false })) + .toBe('soroban-testkit coverage "./my contract" --format html'); + }); + + it("quotes the limits --contract path when it contains spaces", () => { + expect(buildCommand({ command: "limits", path: "./target/my build/contract.wasm", format: "html", open: false, fn: "batch_payout", ramp: "recipients", strict: false })) + .toBe('soroban-testkit limits --contract "./target/my build/contract.wasm" --fn batch_payout --ramp recipients'); + }); + + it("falls back to the default contract path for limits, unquoted", () => { + expect(buildCommand({ command: "limits", path: "", format: "html", open: false, fn: "", ramp: "", strict: false })) + .toBe("soroban-testkit limits --contract target/wasm32v1-none/release/contract.wasm --fn batch_payout --ramp recipients"); + }); + + it("does not quote an audit path with no spaces", () => { + expect(buildCommand({ command: "audit", path: "./src", format: "text", open: false, fn: "", ramp: "", strict: false })) + .toBe("soroban-testkit audit ./src"); + }); + + it("falls back to the default audit path, unquoted, when none is given", () => { + expect(buildCommand({ command: "audit", path: "", format: "text", open: false, fn: "", ramp: "", strict: false })) + .toBe("soroban-testkit audit ./src"); + }); + + it("quotes a path with multiple spaces as a single argument", () => { + expect(buildCommand({ command: "audit", path: "./a b c", format: "text", open: false, fn: "", ramp: "", strict: false })) + .toBe('soroban-testkit audit "./a b c"'); + }); }); diff --git a/tests/visual/landing.spec.ts b/tests/visual/landing.spec.ts new file mode 100644 index 0000000..e186581 --- /dev/null +++ b/tests/visual/landing.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from "@playwright/test"; + +test.describe("landing page visual regression", () => { + test("hero section matches the baseline", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("#top")).toHaveScreenshot("hero.png"); + }); + + test("modules section matches the baseline", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("#modules")).toHaveScreenshot("modules.png"); + }); + + test("command workbench matches the baseline for each tab", async ({ + page, + }) => { + await page.goto("/"); + const commands = page.locator("#commands"); + + await expect(commands).toHaveScreenshot("commands-coverage.png"); + + await page.getByRole("button", { name: "limits" }).click(); + await expect(commands).toHaveScreenshot("commands-limits.png"); + + await page.getByRole("button", { name: "audit" }).click(); + await expect(commands).toHaveScreenshot("commands-audit.png"); + }); + + test("full page matches the baseline", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveScreenshot("full-page.png", { + fullPage: true, + }); + }); +});