diff --git a/README.md b/README.md index 7995ec8..8f2a49f 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,18 @@ # Soroban Testkit Interface -A visual companion to [Soroban Testkit](https://github.com/soroban-testkit/testkit-blockchain). It documents the library's focused testing modules and provides a command builder for the supported CLI workflows. +A visual companion to [Soroban Testkit](https://github.com/soroban-testkit/testkit-blockchain). It documents the library's focused testing modules and provides an accessible, validated command builder for supported CLI workflows (`coverage`, `limits`, `audit`). The interface does not execute contracts in the browser. Generated commands run locally through `soroban-testkit`, keeping contract tests deterministic and offline as required by the core project. +## Features + +- **CLI Workbench**: Switch between `coverage`, `limits`, and `audit` command builders with instant command generation and argument quoting. +- **Accessible Workflow Navigation**: Compliant with WAI-ARIA tab pattern (`role="tablist"`, `role="tab"`, `role="tabpanel"`). Navigate workflows using keyboard arrow keys (`ArrowRight`, `ArrowLeft`), `Home`, and `End` with roving `tabIndex`. +- **Form Controls & Labels**: Explicit association of labels and form controls (`htmlFor` / `id`), enabling assistive technology support. +- **Input Validation**: Real-time validation for required contract paths and limits identifiers (`fn` and `ramp` verified as valid Rust identifiers). Displays inline `role="alert"` messages linked with `aria-invalid` and `aria-describedby`, safely disabling clipboard copy while inputs are invalid. +- **Default Reset**: Quickly restore customized inputs back to canonical workflow defaults via the `Reset` action. +- **Clipboard Feedback & Status**: Copy commands with visual and screen-reader status announcements via `aria-live="polite"`. Handles clipboard success (`Copied`) as well as graceful error recovery on failure (`Failed`). + ## Development ```sh diff --git a/src/App.test.tsx b/src/App.test.tsx index 8676631..3254e4c 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import App from "./App"; describe("App command copy", () => { @@ -39,7 +39,7 @@ describe("App command copy", () => { render(); - fireEvent.click(screen.getByRole("button", { name: "audit" })); + fireEvent.click(screen.getByRole("tab", { name: "audit" })); fireEvent.click(screen.getByRole("button", { name: "Copy" })); await waitFor(() => @@ -63,3 +63,372 @@ describe("App command copy", () => { expect(await screen.findByRole("button", { name: "Copied" })).toBeInTheDocument(); }); }); + +describe("App accessibility assertions (#203)", () => { + it("provides proper ARIA tablist, tab, and tabpanel roles and relationships", () => { + render(); + + const tablist = screen.getByRole("tablist", { name: "CLI commands" }); + expect(tablist).toBeInTheDocument(); + + const tabs = screen.getAllByRole("tab"); + expect(tabs).toHaveLength(3); + + const coverageTab = screen.getByRole("tab", { name: "coverage" }); + const limitsTab = screen.getByRole("tab", { name: "limits" }); + const auditTab = screen.getByRole("tab", { name: "audit" }); + + expect(coverageTab).toHaveAttribute("aria-selected", "true"); + expect(coverageTab).toHaveAttribute("id", "tab-coverage"); + expect(coverageTab).toHaveAttribute("aria-controls", "panel-coverage"); + expect(coverageTab).toHaveAttribute("tabindex", "0"); + + expect(limitsTab).toHaveAttribute("aria-selected", "false"); + expect(limitsTab).toHaveAttribute("id", "tab-limits"); + expect(limitsTab).toHaveAttribute("aria-controls", "panel-limits"); + expect(limitsTab).toHaveAttribute("tabindex", "-1"); + + expect(auditTab).toHaveAttribute("aria-selected", "false"); + expect(auditTab).toHaveAttribute("id", "tab-audit"); + expect(auditTab).toHaveAttribute("aria-controls", "panel-audit"); + expect(auditTab).toHaveAttribute("tabindex", "-1"); + + const panel = screen.getByRole("tabpanel"); + expect(panel).toHaveAttribute("id", "panel-coverage"); + expect(panel).toHaveAttribute("aria-labelledby", "tab-coverage"); + }); + + it("associates all builder inputs with accessible form labels", () => { + render(); + + expect(screen.getByLabelText("Project or contract path")).toBeInTheDocument(); + expect(screen.getByLabelText("Report format")).toBeInTheDocument(); + expect(screen.getByLabelText("Open report when complete")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "limits" })); + expect(screen.getByLabelText("Project or contract path")).toBeInTheDocument(); + expect(screen.getByLabelText("Function")).toBeInTheDocument(); + expect(screen.getByLabelText("Ramp parameter")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "audit" })); + expect(screen.getByLabelText("Project or contract path")).toBeInTheDocument(); + expect(screen.getByLabelText("Fail on warnings")).toBeInTheDocument(); + }); + + it("includes an accessible polite live region for screen-reader announcements", () => { + const { container } = render(); + const liveRegion = container.querySelector('[role="status"][aria-live="polite"]'); + expect(liveRegion).toBeInTheDocument(); + }); +}); + +describe("App keyboard workflow navigation (#204)", () => { + it("cycles through tabs using ArrowRight and ArrowLeft keys", () => { + render(); + + const coverageTab = screen.getByRole("tab", { name: "coverage" }); + const limitsTab = screen.getByRole("tab", { name: "limits" }); + const auditTab = screen.getByRole("tab", { name: "audit" }); + + coverageTab.focus(); + expect(coverageTab).toHaveAttribute("aria-selected", "true"); + + fireEvent.keyDown(coverageTab, { key: "ArrowRight" }); + expect(limitsTab).toHaveAttribute("aria-selected", "true"); + expect(coverageTab).toHaveAttribute("aria-selected", "false"); + expect(limitsTab).toHaveAttribute("tabindex", "0"); + expect(coverageTab).toHaveAttribute("tabindex", "-1"); + expect(screen.getByRole("tabpanel")).toHaveAttribute("id", "panel-limits"); + + fireEvent.keyDown(limitsTab, { key: "ArrowRight" }); + expect(auditTab).toHaveAttribute("aria-selected", "true"); + expect(auditTab).toHaveAttribute("tabindex", "0"); + expect(screen.getByRole("tabpanel")).toHaveAttribute("id", "panel-audit"); + + // Wrap around to the first tab + fireEvent.keyDown(auditTab, { key: "ArrowRight" }); + expect(coverageTab).toHaveAttribute("aria-selected", "true"); + expect(coverageTab).toHaveAttribute("tabindex", "0"); + + // Wrap backwards to the last tab with ArrowLeft + fireEvent.keyDown(coverageTab, { key: "ArrowLeft" }); + expect(auditTab).toHaveAttribute("aria-selected", "true"); + + fireEvent.keyDown(auditTab, { key: "ArrowLeft" }); + expect(limitsTab).toHaveAttribute("aria-selected", "true"); + }); + + it("jumps to first and last tabs using Home and End keys", () => { + render(); + + const coverageTab = screen.getByRole("tab", { name: "coverage" }); + const auditTab = screen.getByRole("tab", { name: "audit" }); + + coverageTab.focus(); + + fireEvent.keyDown(coverageTab, { key: "End" }); + expect(auditTab).toHaveAttribute("aria-selected", "true"); + expect(auditTab).toHaveAttribute("tabindex", "0"); + expect(coverageTab).toHaveAttribute("tabindex", "-1"); + + fireEvent.keyDown(auditTab, { key: "Home" }); + expect(coverageTab).toHaveAttribute("aria-selected", "true"); + expect(coverageTab).toHaveAttribute("tabindex", "0"); + }); +}); + +describe("App reset behavior after customized inputs (#205)", () => { + it("resets coverage inputs to default values and updates the generated command", () => { + render(); + + const pathInput = screen.getByLabelText("Project or contract path") as HTMLInputElement; + const formatSelect = screen.getByLabelText("Report format") as HTMLSelectElement; + const openCheckbox = screen.getByLabelText("Open report when complete") as HTMLInputElement; + + fireEvent.change(pathInput, { target: { value: "./contracts/escrow" } }); + fireEvent.change(formatSelect, { target: { value: "lcov" } }); + fireEvent.click(openCheckbox); + + expect( + screen.getByText("soroban-testkit coverage ./contracts/escrow --format lcov"), + ).toBeInTheDocument(); + + const resetButton = screen.getByRole("button", { name: "Reset" }); + fireEvent.click(resetButton); + + expect(pathInput.value).toBe("."); + expect(formatSelect.value).toBe("html"); + expect(openCheckbox.checked).toBe(true); + expect( + screen.getByText("soroban-testkit coverage . --format html --open"), + ).toBeInTheDocument(); + + const liveRegion = screen.getByRole("status"); + expect(liveRegion).toHaveTextContent("Reset coverage configuration to default settings"); + }); + + it("resets limits inputs to default values", () => { + render(); + + fireEvent.click(screen.getByRole("tab", { name: "limits" })); + + const pathInput = screen.getByLabelText("Project or contract path") as HTMLInputElement; + const fnInput = screen.getByLabelText("Function") as HTMLInputElement; + const rampInput = screen.getByLabelText("Ramp parameter") as HTMLInputElement; + + fireEvent.change(pathInput, { target: { value: "./custom/path.wasm" } }); + fireEvent.change(fnInput, { target: { value: "custom_fn" } }); + fireEvent.change(rampInput, { target: { value: "custom_ramp" } }); + + expect( + screen.getByText( + 'soroban-testkit limits --contract "./custom/path.wasm" --fn custom_fn --ramp custom_ramp', + ), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Reset" })); + + expect(pathInput.value).toBe("target/wasm32v1-none/release/contract.wasm"); + expect(fnInput.value).toBe("batch_payout"); + expect(rampInput.value).toBe("recipients"); + expect( + screen.getByText( + "soroban-testkit limits --contract target/wasm32v1-none/release/contract.wasm --fn batch_payout --ramp recipients", + ), + ).toBeInTheDocument(); + }); + + it("resets audit inputs to default values and clears validation errors", () => { + render(); + + fireEvent.click(screen.getByRole("tab", { name: "audit" })); + + const pathInput = screen.getByLabelText("Project or contract path") as HTMLInputElement; + const strictCheckbox = screen.getByLabelText("Fail on warnings") as HTMLInputElement; + + // Introduce invalid empty path + fireEvent.change(pathInput, { target: { value: "" } }); + expect(screen.getByRole("alert")).toHaveTextContent("Project or contract path cannot be empty."); + + // Uncheck strict + fireEvent.click(strictCheckbox); + + // Reset + fireEvent.click(screen.getByRole("button", { name: "Reset" })); + + expect(pathInput.value).toBe("./src"); + expect(strictCheckbox.checked).toBe(true); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByText("soroban-testkit audit ./src --strict")).toBeInTheDocument(); + }); +}); + +describe("App clipboard success and failure states (#206)", () => { + const originalClipboard = navigator.clipboard; + + afterEach(() => { + Object.defineProperty(navigator, "clipboard", { + value: originalClipboard, + configurable: true, + }); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("displays success state and announces to live region when copy succeeds", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + render(); + + const copyButton = screen.getByRole("button", { name: "Copy" }); + fireEvent.click(copyButton); + + await waitFor(() => + expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(), + ); + + const liveRegion = screen.getByRole("status"); + expect(liveRegion).toHaveTextContent("Command copied to clipboard"); + }); + + it("displays failure state and announces failure when copy throws", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("Clipboard write rejected")); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + render(); + + const copyButton = screen.getByRole("button", { name: "Copy" }); + fireEvent.click(copyButton); + + const failedButton = await screen.findByRole("button", { name: "Failed" }); + expect(failedButton).toBeInTheDocument(); + expect(failedButton).toHaveClass("error"); + + const liveRegion = screen.getByRole("status"); + expect(liveRegion).toHaveTextContent("Failed to copy command to clipboard"); + }); + + it("recovers from failure state back to Copy after timeout", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + const writeText = vi.fn().mockRejectedValue(new Error("Clipboard write rejected")); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + render(); + + const copyButton = screen.getByRole("button", { name: "Copy" }); + fireEvent.click(copyButton); + + await waitFor(() => + expect(screen.getByRole("button", { name: "Failed" })).toBeInTheDocument(), + ); + + act(() => { + vi.advanceTimersByTime(2100); + }); + + expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Copy" })).not.toHaveClass("error"); + }); +}); + +describe("App invalid builder input states (#207)", () => { + it("shows error alert, marks aria-invalid, and disables Copy button when path is empty", () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + render(); + + const pathInput = screen.getByLabelText("Project or contract path"); + const copyButton = screen.getByRole("button", { name: "Copy" }); + + expect(pathInput).toHaveAttribute("aria-invalid", "false"); + expect(copyButton).not.toBeDisabled(); + + fireEvent.change(pathInput, { target: { value: " " } }); + + expect(pathInput).toHaveAttribute("aria-invalid", "true"); + expect(pathInput).toHaveAttribute("aria-describedby", "error-path"); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveAttribute("id", "error-path"); + expect(alert).toHaveTextContent("Project or contract path cannot be empty."); + + expect(copyButton).toBeDisabled(); + + // Clicking disabled Copy button should not copy + fireEvent.click(copyButton); + expect(writeText).not.toHaveBeenCalled(); + + // Restoring valid value clears error state + fireEvent.change(pathInput, { target: { value: "./src" } }); + expect(pathInput).toHaveAttribute("aria-invalid", "false"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(copyButton).not.toBeDisabled(); + }); + + it("shows error alert and disables Copy button for invalid limits function identifier", () => { + render(); + + fireEvent.click(screen.getByRole("tab", { name: "limits" })); + + const fnInput = screen.getByLabelText("Function"); + const copyButton = screen.getByRole("button", { name: "Copy" }); + + fireEvent.change(fnInput, { target: { value: "123-bad-fn" } }); + + expect(fnInput).toHaveAttribute("aria-invalid", "true"); + expect(fnInput).toHaveAttribute("aria-describedby", "error-fn"); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveAttribute("id", "error-fn"); + expect(alert).toHaveTextContent( + "Function name must be a valid Rust identifier (e.g. batch_payout).", + ); + expect(copyButton).toBeDisabled(); + + fireEvent.change(fnInput, { target: { value: "valid_function" } }); + expect(fnInput).toHaveAttribute("aria-invalid", "false"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(copyButton).not.toBeDisabled(); + }); + + it("shows error alert and disables Copy button for invalid limits ramp parameter", () => { + render(); + + fireEvent.click(screen.getByRole("tab", { name: "limits" })); + + const rampInput = screen.getByLabelText("Ramp parameter"); + const copyButton = screen.getByRole("button", { name: "Copy" }); + + fireEvent.change(rampInput, { target: { value: "invalid param!" } }); + + expect(rampInput).toHaveAttribute("aria-invalid", "true"); + expect(rampInput).toHaveAttribute("aria-describedby", "error-ramp"); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveAttribute("id", "error-ramp"); + expect(alert).toHaveTextContent( + "Ramp parameter must be a valid Rust identifier (e.g. recipients).", + ); + expect(copyButton).toBeDisabled(); + + fireEvent.change(rampInput, { target: { value: "amount" } }); + expect(rampInput).toHaveAttribute("aria-invalid", "false"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(copyButton).not.toBeDisabled(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 902695f..e947abf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,11 @@ import { useMemo, useState } from "react"; -import { buildCommand, modules, type CommandName } from "./testkit"; +import { + buildCommand, + DEFAULT_OPTIONS, + modules, + validateCommandInputs, + type CommandName, +} from "./testkit"; import { copyToClipboard } from "./clipboard"; const descriptions: Record = { @@ -8,6 +14,8 @@ const descriptions: Record = { audit: "Scan contract sources for common auth, arithmetic, and TTL risks.", }; +const COMMANDS: CommandName[] = ["coverage", "limits", "audit"]; + export default function App() { const [command, setCommand] = useState("coverage"); const [path, setPath] = useState("."); @@ -16,17 +24,85 @@ export default function App() { const [fn, setFn] = useState("batch_payout"); const [ramp, setRamp] = useState("recipients"); const [strict, setStrict] = useState(true); - const [copied, setCopied] = useState(false); + const [copyStatus, setCopyStatus] = useState<"idle" | "success" | "error">("idle"); + const [announcement, setAnnouncement] = useState(""); const generated = useMemo( () => buildCommand({ command, path, format, open, fn, ramp, strict }), [command, path, format, open, fn, ramp, strict], ); + const errors = useMemo( + () => validateCommandInputs({ command, path, format, open, fn, ramp, strict }), + [command, path, format, open, fn, ramp, strict], + ); + + const hasErrors = Object.keys(errors).length > 0; + + const handleTabChange = (nextCommand: CommandName) => { + setCommand(nextCommand); + setCopyStatus("idle"); + }; + + const handleTabKeyDown = ( + event: React.KeyboardEvent, + current: CommandName, + ) => { + const currentIndex = COMMANDS.indexOf(current); + let nextIndex: number | null = null; + + if (event.key === "ArrowRight" || event.key === "ArrowDown") { + nextIndex = (currentIndex + 1) % COMMANDS.length; + } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { + nextIndex = (currentIndex - 1 + COMMANDS.length) % COMMANDS.length; + } else if (event.key === "Home") { + nextIndex = 0; + } else if (event.key === "End") { + nextIndex = COMMANDS.length - 1; + } + + if (nextIndex !== null) { + event.preventDefault(); + const nextCommand = COMMANDS[nextIndex]; + handleTabChange(nextCommand); + const nextButton = document.getElementById(`tab-${nextCommand}`); + nextButton?.focus(); + } + }; + + const handleReset = () => { + const defaults = DEFAULT_OPTIONS[command]; + setPath(defaults.path); + setFormat(defaults.format); + setOpen(defaults.open); + setFn(defaults.fn); + setRamp(defaults.ramp); + setStrict(defaults.strict); + setCopyStatus("idle"); + setAnnouncement(`Reset ${command} configuration to default settings`); + }; + const copy = async () => { - await copyToClipboard(generated); - setCopied(true); - window.setTimeout(() => setCopied(false), 1600); + if (hasErrors) { + return; + } + + try { + await copyToClipboard(generated); + setCopyStatus("success"); + setAnnouncement("Command copied to clipboard"); + window.setTimeout(() => { + setCopyStatus("idle"); + setAnnouncement(""); + }, 1600); + } catch { + setCopyStatus("error"); + setAnnouncement("Failed to copy command to clipboard"); + window.setTimeout(() => { + setCopyStatus("idle"); + setAnnouncement(""); + }, 2000); + } }; return ( @@ -79,22 +155,143 @@ export default function App() {

CLI workbench

From intent to command.

Configure a supported Testkit workflow and copy the exact command to your terminal.

-
- {(["coverage", "limits", "audit"] as const).map((name) => ( - +
+ {COMMANDS.map((name) => ( + ))}

{descriptions[command]}

-
- - {command === "coverage" && <> - - - } - {command === "limits" &&
} - {command === "audit" && } -
{generated}
+
+
+ + setPath(event.target.value)} + aria-invalid={Boolean(errors.path)} + aria-describedby={errors.path ? "error-path" : undefined} + /> + {errors.path && ( + + {errors.path} + + )} +
+ + {command === "coverage" && ( + <> +
+ + +
+ + + )} + + {command === "limits" && ( +
+
+ + setFn(event.target.value)} + aria-invalid={Boolean(errors.fn)} + aria-describedby={errors.fn ? "error-fn" : undefined} + /> + {errors.fn && ( + + {errors.fn} + + )} +
+
+ + setRamp(event.target.value)} + aria-invalid={Boolean(errors.ramp)} + aria-describedby={errors.ramp ? "error-ramp" : undefined} + /> + {errors.ramp && ( + + {errors.ramp} + + )} +
+
+ )} + + {command === "audit" && ( + + )} + +
+ {generated} + + +
+
+ {announcement} +
diff --git a/src/styles.css b/src/styles.css index 332b692..92f6e13 100644 --- a/src/styles.css +++ b/src/styles.css @@ -33,10 +33,19 @@ section header { max-width: 650px; margin-bottom: 50px; } h2 { margin: 12px 0; f .commands { display: grid; grid-template-columns: .8fr 1.2fr; gap: 8vw; background: #091916; }.command-copy > p:not(.eyebrow) { color: #91a39c; line-height: 1.7; } .command-tabs { display: flex; gap: 8px; margin-top: 36px; }.command-tabs button { border: 1px solid #ffffff20; color: #8ea099; background: transparent; padding: 9px 14px; border-radius: 5px; cursor: pointer; font: .75rem "DM Mono"; }.command-tabs button.active { border-color: #66e2ad; color: #66e2ad; background: #66e2ad0b; } .description { min-height: 48px; }.builder { padding: 32px; background: #061310; border: 1px solid #ffffff18; border-radius: 10px; } -label { display: grid; gap: 9px; margin-bottom: 22px; color: #a7b8b1; font-size: .78rem; }.row { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } +.field, label { display: grid; gap: 9px; margin-bottom: 22px; color: #a7b8b1; font-size: .78rem; } +.field label { margin-bottom: 0; } +.row { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } input, select { width: 100%; padding: 12px 13px; color: #eaf6f1; background: #ffffff06; border: 1px solid #ffffff1f; border-radius: 5px; outline: none; } input:focus, select:focus { border-color: #66e2ad; } .check { display: flex; align-items: center; grid-template-columns: auto 1fr; }.check input { width: auto; accent-color: #66e2ad; } +.field-error { color: #ff6b6b; font-size: .72rem; line-height: 1.4; } +input[aria-invalid="true"] { border-color: #ff6b6b; } .output { display: flex; align-items: center; gap: 15px; padding: 16px; background: #020807; border-radius: 6px; }.output code { flex: 1; color: #66e2ad; font: .75rem/1.6 "DM Mono"; overflow-wrap: anywhere; }.output button { color: #061310; background: #66e2ad; border: 0; border-radius: 4px; padding: 8px 12px; cursor: pointer; } +.output button.button-reset { color: #a9bbb4; background: transparent; border: 1px solid #ffffff24; } +.output button.button-reset:hover { color: #e9f4ef; border-color: #66e2ad; } +.output button.error { background: #ff5f56; color: #ffffff; } +.output button:disabled { opacity: 0.45; cursor: not-allowed; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; } footer { display: flex; justify-content: space-between; padding: 34px 5vw; border-top: 1px solid #ffffff12; color: #6f817a; font-size: .78rem; } @media (max-width: 900px) { .hero, .commands { grid-template-columns: 1fr; }.hero { padding-top: 70px; }.module-grid { grid-template-columns: repeat(2, 1fr); }.nav-links a:not(:last-child) { display: none; } } @media (max-width: 540px) { nav, .hero, .modules, .commands { padding-left: 22px; padding-right: 22px; }.module-grid { grid-template-columns: 1fr; }.row { grid-template-columns: 1fr; }.actions { flex-direction: column; }.terminal pre { padding: 22px; font-size: .7rem; } footer { gap: 20px; } } diff --git a/src/testkit.test.ts b/src/testkit.test.ts index f03c0b1..b12dc4f 100644 --- a/src/testkit.test.ts +++ b/src/testkit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildCommand } from "./testkit"; +import { buildCommand, validateCommandInputs } from "./testkit"; describe("buildCommand", () => { it("builds a coverage command", () => { @@ -47,3 +47,120 @@ describe("buildCommand", () => { .toBe('soroban-testkit audit "./a b c"'); }); }); + +describe("validateCommandInputs", () => { + it("returns no errors for valid coverage options", () => { + const errors = validateCommandInputs({ + command: "coverage", + path: ".", + format: "html", + open: true, + fn: "", + ramp: "", + strict: false, + }); + expect(errors).toEqual({}); + }); + + it("returns an error when path is empty or whitespace only", () => { + const emptyErrors = validateCommandInputs({ + command: "coverage", + path: "", + format: "html", + open: true, + fn: "", + ramp: "", + strict: false, + }); + expect(emptyErrors.path).toBe("Project or contract path cannot be empty."); + + const whitespaceErrors = validateCommandInputs({ + command: "coverage", + path: " \t", + format: "html", + open: true, + fn: "", + ramp: "", + strict: false, + }); + expect(whitespaceErrors.path).toBe("Project or contract path cannot be empty."); + }); + + it("returns no errors for valid limits options", () => { + const errors = validateCommandInputs({ + command: "limits", + path: "target/wasm32v1-none/release/contract.wasm", + format: "html", + open: true, + fn: "batch_payout", + ramp: "recipients", + strict: false, + }); + expect(errors).toEqual({}); + }); + + it("validates function name in limits as a valid Rust identifier", () => { + const emptyFn = validateCommandInputs({ + command: "limits", + path: "target.wasm", + format: "html", + open: true, + fn: "", + ramp: "recipients", + strict: false, + }); + expect(emptyFn.fn).toBe("Function name cannot be empty."); + + const invalidFn = validateCommandInputs({ + command: "limits", + path: "target.wasm", + format: "html", + open: true, + fn: "123_invalid-name!", + ramp: "recipients", + strict: false, + }); + expect(invalidFn.fn).toBe( + "Function name must be a valid Rust identifier (e.g. batch_payout).", + ); + }); + + it("validates ramp parameter in limits as a valid Rust identifier", () => { + const emptyRamp = validateCommandInputs({ + command: "limits", + path: "target.wasm", + format: "html", + open: true, + fn: "batch_payout", + ramp: "", + strict: false, + }); + expect(emptyRamp.ramp).toBe("Ramp parameter cannot be empty."); + + const invalidRamp = validateCommandInputs({ + command: "limits", + path: "target.wasm", + format: "html", + open: true, + fn: "batch_payout", + ramp: "invalid ramp param", + strict: false, + }); + expect(invalidRamp.ramp).toBe( + "Ramp parameter must be a valid Rust identifier (e.g. recipients).", + ); + }); + + it("returns no errors for valid audit options", () => { + const errors = validateCommandInputs({ + command: "audit", + path: "./src", + format: "text", + open: false, + fn: "", + ramp: "", + strict: true, + }); + expect(errors).toEqual({}); + }); +}); diff --git a/src/testkit.ts b/src/testkit.ts index 32bad1e..22a1718 100644 --- a/src/testkit.ts +++ b/src/testkit.ts @@ -54,3 +54,58 @@ export function buildCommand(options: CommandOptions): string { .filter(Boolean) .join(" "); } + +export const DEFAULT_OPTIONS: Record = { + coverage: { + command: "coverage", + path: ".", + format: "html", + open: true, + fn: "batch_payout", + ramp: "recipients", + strict: true, + }, + limits: { + command: "limits", + path: "target/wasm32v1-none/release/contract.wasm", + format: "html", + open: true, + fn: "batch_payout", + ramp: "recipients", + strict: true, + }, + audit: { + command: "audit", + path: "./src", + format: "text", + open: false, + fn: "batch_payout", + ramp: "recipients", + strict: true, + }, +}; + +export function validateCommandInputs(options: CommandOptions): Record { + const errors: Record = {}; + + if (!options.path || options.path.trim() === "") { + errors.path = "Project or contract path cannot be empty."; + } + + if (options.command === "limits") { + if (!options.fn || options.fn.trim() === "") { + errors.fn = "Function name cannot be empty."; + } else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(options.fn.trim())) { + errors.fn = "Function name must be a valid Rust identifier (e.g. batch_payout)."; + } + + if (!options.ramp || options.ramp.trim() === "") { + errors.ramp = "Ramp parameter cannot be empty."; + } else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(options.ramp.trim())) { + errors.ramp = "Ramp parameter must be a valid Rust identifier (e.g. recipients)."; + } + } + + return errors; +} + diff --git a/tests/visual/landing.spec.ts b/tests/visual/landing.spec.ts index e186581..556c166 100644 --- a/tests/visual/landing.spec.ts +++ b/tests/visual/landing.spec.ts @@ -19,10 +19,10 @@ test.describe("landing page visual regression", () => { await expect(commands).toHaveScreenshot("commands-coverage.png"); - await page.getByRole("button", { name: "limits" }).click(); + await page.getByRole("tab", { name: "limits" }).click(); await expect(commands).toHaveScreenshot("commands-limits.png"); - await page.getByRole("button", { name: "audit" }).click(); + await page.getByRole("tab", { name: "audit" }).click(); await expect(commands).toHaveScreenshot("commands-audit.png"); });