Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ coverage/
*.log
*.tsbuildinfo
.DS_Store
test-results/
playwright-report/
blob-report/
playwright/.cache/
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -17,6 +19,7 @@
},
"devDependencies": {
"@eslint/js": "latest",
"@playwright/test": "latest",
"@testing-library/jest-dom": "latest",
"@testing-library/react": "latest",
"@types/react": "latest",
Expand Down
29 changes: 29 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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"] },
},
],
});
65 changes: 65 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<App />);

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(<App />);

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(<App />);

fireEvent.click(screen.getByRole("button", { name: "Copy" }));

await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
expect(await screen.findByRole("button", { name: "Copied" })).toBeInTheDocument();
});
});
3 changes: 2 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { buildCommand, modules, type CommandName } from "./testkit";
import { copyToClipboard } from "./clipboard";

const descriptions: Record<CommandName, string> = {
coverage: "Generate contract-test coverage through cargo-llvm-cov.",
Expand All @@ -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);
};
Expand Down
56 changes: 56 additions & 0 deletions src/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
28 changes: 28 additions & 0 deletions src/clipboard.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
35 changes: 35 additions & 0 deletions src/testkit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"');
});
});
35 changes: 35 additions & 0 deletions tests/visual/landing.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading