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,
+ });
+ });
+});