From 4773fd180c67ba808ff74254ccd6cd750c5b3565 Mon Sep 17 00:00:00 2001 From: Darkvader-ship-it Date: Sun, 18 Oct 2026 21:15:36 +0100 Subject: [PATCH] feat: installation guide, command comparison, defaults preview, summary card Implements upstream issues #150, #151, #155, and #156 as one coherent enhancement of the CLI workbench. - #155 First-time installation: new section with six ordered steps (WASM target, CLI, coverage wrapper, dev-dependency, verification, first command), each with a keyboard-accessible copy button; Install nav link; README installation section. - #150 Effective defaults preview: before copying, the workbench lists every field that falls back to a default (batch_payout, recipients, ./src, ...), shows the value the command will use, and flags it as "Default applied" or "From your input". Fallback values now live in a shared fallbackValues constant used by buildCommand/formatCommandLine. - #156 Command summary card: live recap of the active workflow's effective settings, marking values that come from defaults. - #151 Comparison view: aria-expanded/aria-controls disclosure opens a second configuration editor (Configuration B), a table that marks each changed setting ("Changed"/"Matches"), both generated commands, focus management, and a "Match B to A" sync button. Also: - add @testing-library/user-event and explicit @testing-library/dom; the committed lockfile had also gone stale (npm ci failed on main because @playwright/test was missing), so package-lock.json is refreshed. - focused unit tests for the new helpers plus component tests covering copy flows, keyboard activation, diff markers, and live summaries. - README features list updated; visual snapshots regenerated. --- README.md | 15 + package-lock.json | 106 +++--- package.json | 2 + src/App.test.tsx | 332 +++++++++++++++++- src/App.tsx | 327 ++++++++++++++++- src/styles.css | 72 +++- src/testkit.test.ts | 222 ++++++++++++ src/testkit.ts | 259 +++++++++++++- .../visual/__screenshots__/commands-audit.png | Bin 44605 -> 57461 bytes .../__screenshots__/commands-coverage.png | Bin 48432 -> 66832 bytes .../__screenshots__/commands-limits.png | Bin 50783 -> 70748 bytes tests/visual/__screenshots__/full-page.png | Bin 365558 -> 444126 bytes tests/visual/__screenshots__/hero.png | Bin 215787 -> 203947 bytes tests/visual/__screenshots__/modules.png | Bin 57754 -> 47109 bytes 14 files changed, 1277 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index dcdea19..01a4422 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,21 @@ The interface does not execute contracts in the browser. Generated commands run - **Module Filter & Empty State**: Search and filter testing modules by name or description with real-time feedback and an accessible empty-state layout with quick filter reset. - **Responsive & Reduced-Width Navigation**: Accessible navigation layout supporting truncated and responsive labels without overflowing viewport on mobile or narrow screens. - **Required vs. Optional Setting Distinction**: Visual badges (`Required` / `Optional`) and `aria-required` attributes in the CLI workbench distinguishing mandatory parameters from optional configuration flags. +- **First-Time Installation Workflow**: A six-step `#install` section covering the WASM target, the CLI, the coverage wrapper, the dev-dependency, verification, and a first command — each step with a keyboard-accessible copy button. +- **Command Comparison View**: A disclosure panel (`aria-expanded` / `aria-controls`) that compares configuration A with a fully editable configuration B, marking every changed setting in a scrollable table and showing both generated commands. +- **Effective Defaults Preview**: A pre-copy panel that lists the fallback values the CLI will substitute for empty fields (for example `batch_payout`, `recipients`, and `./src`) and flags them as `Default applied`. +- **Command Summary Card**: A live `Command summary` region recapping the active workflow's effective settings, marking values that fall back to defaults. + +## First-time installation + +1. `rustup target add wasm32v1-none` — add the WASM target used by `limits`. +2. `cargo install soroban-testkit-cli --locked` — install the CLI (from a workspace checkout: `cargo install --path crates/cli --locked`). +3. `cargo install cargo-llvm-cov` — install the wrapper used by `coverage`. +4. `cargo add --dev soroban-testkit` — add the testing crate to your contract. +5. `soroban-testkit --help` — verify the binary resolves on your PATH. +6. Build your first command in the [CLI workbench](#commands); the effective-defaults preview shows every value before you copy it. + +The landing page mirrors these steps in the **First-time setup** section. ## Development diff --git a/package-lock.json b/package-lock.json index 77ae40b..5808646 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,11 @@ }, "devDependencies": { "@eslint/js": "latest", + "@playwright/test": "latest", + "@testing-library/dom": "latest", "@testing-library/jest-dom": "latest", "@testing-library/react": "latest", + "@testing-library/user-event": "latest", "@types/react": "latest", "@types/react-dom": "latest", "eslint": "latest", @@ -811,6 +814,22 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", @@ -914,9 +933,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -933,9 +949,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -952,9 +965,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -971,9 +981,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -990,9 +997,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1009,9 +1013,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1081,7 +1082,6 @@ "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -1160,13 +1160,26 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.8", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", + "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.3", @@ -1583,7 +1596,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -1594,7 +1606,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -1867,8 +1878,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-to-chromium": { "version": "1.5.451", @@ -2633,9 +2643,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2656,9 +2663,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2679,9 +2683,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2702,9 +2703,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2790,7 +2788,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -2995,6 +2992,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.29", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", @@ -3039,7 +3065,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -3105,8 +3130,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redent": { "version": "3.0.0", diff --git a/package.json b/package.json index cbffd69..67ec734 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,10 @@ "devDependencies": { "@eslint/js": "latest", "@playwright/test": "latest", + "@testing-library/dom": "latest", "@testing-library/jest-dom": "latest", "@testing-library/react": "latest", + "@testing-library/user-event": "latest", "@types/react": "latest", "@types/react-dom": "latest", "eslint": "latest", diff --git a/src/App.test.tsx b/src/App.test.tsx index 76d1bbd..9ae31cb 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import App from "./App"; @@ -178,3 +179,332 @@ describe("Visual distinction between required and optional settings (#196)", () expect(screen.getByText("Optional")).toBeInTheDocument(); }); }); + +describe("First-time installation workflow (#155)", () => { + const originalClipboard = navigator.clipboard; + + afterEach(() => { + Object.defineProperty(navigator, "clipboard", { + value: originalClipboard, + configurable: true, + }); + vi.restoreAllMocks(); + }); + + it("links an Install entry in the main navigation", () => { + render(); + expect(screen.getByText("Install")).toHaveAttribute("href", "#install"); + }); + + it("lists the installation steps in order with their commands", () => { + render(); + const section = screen.getByRole("region", { + name: /from a clean machine to your first command/i, + }); + + const titles = within(section) + .getAllByRole("heading", { level: 3 }) + .map((heading) => heading.textContent); + expect(titles).toEqual([ + "Add the WebAssembly target", + "Install the soroban-testkit CLI", + "Install the coverage wrapper", + "Add the crate to your contract", + "Verify the installation", + "Build your first command", + ]); + + expect(within(section).getAllByRole("listitem")).toHaveLength(6); + expect( + within(section).getByText("cargo install soroban-testkit-cli --locked"), + ).toBeInTheDocument(); + }); + + it("copies an installation command from its step", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Copy step 2 command" })); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith( + "cargo install soroban-testkit-cli --locked", + ), + ); + expect(screen.getByRole("button", { name: "Copy step 2 command" })).toHaveTextContent( + "Copied", + ); + }); + + it("activates an installation copy button from the keyboard", async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + render(); + + const button = screen.getByRole("button", { name: "Copy step 1 command" }); + button.focus(); + await user.keyboard("{Enter}"); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith("rustup target add wasm32v1-none"), + ); + expect(button).toHaveTextContent("Copied"); + }); +}); + +describe("Effective defaults preview (#150)", () => { + it("previews the limits fallbacks that will be applied once fields are cleared", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "limits" })); + + let preview = screen.getByRole("region", { name: "Effective defaults" }); + expect(within(preview).getAllByText("From your input")).toHaveLength(3); + expect(within(preview).getByText("0 of 3 applied")).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText(/project or contract path/i), { + target: { value: "" }, + }); + fireEvent.change(screen.getByLabelText(/function/i), { + target: { value: "" }, + }); + fireEvent.change(screen.getByLabelText(/ramp parameter/i), { + target: { value: "" }, + }); + + preview = screen.getByRole("region", { name: "Effective defaults" }); + expect(within(preview).getAllByText("Default applied")).toHaveLength(3); + expect(within(preview).getByText("3 of 3 applied")).toBeInTheDocument(); + expect(within(preview).getByText(/batch_payout/)).toBeInTheDocument(); + expect(within(preview).getByText(/recipients/)).toBeInTheDocument(); + expect( + within(preview).getByText(/target\/wasm32v1-none\/release\/contract\.wasm/), + ).toBeInTheDocument(); + }); + + it("switches a row to the typed value once the user provides one", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "limits" })); + + fireEvent.change(screen.getByLabelText(/function/i), { + target: { value: "execute_equal" }, + }); + + const preview = screen.getByRole("region", { name: "Effective defaults" }); + expect(within(preview).getByText("execute_equal")).toBeInTheDocument(); + expect(within(preview).getAllByText("From your input")).toHaveLength(3); + expect(within(preview).queryByText("Default applied")).not.toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText(/ramp parameter/i), { + target: { value: "" }, + }); + + expect( + within(screen.getByRole("region", { name: "Effective defaults" })).getByText( + "1 of 3 applied", + ), + ).toBeInTheDocument(); + }); + + it("explains that coverage has no empty-field fallbacks", () => { + render(); + + const preview = screen.getByRole("region", { name: "Effective defaults" }); + expect( + within(preview).getByText(/no empty-field fallbacks/i), + ).toBeInTheDocument(); + expect(within(preview).queryByText("Default applied")).not.toBeInTheDocument(); + }); + + it("applies the audit source-path default when the path is empty", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "audit" })); + fireEvent.change(screen.getByLabelText(/project or contract path/i), { + target: { value: "" }, + }); + + const preview = screen.getByRole("region", { name: "Effective defaults" }); + expect(within(preview).getByText(/\.\/src/)).toBeInTheDocument(); + expect(within(preview).getByText("Default applied")).toBeInTheDocument(); + }); +}); + +describe("Command summary card (#156)", () => { + it("summarizes the active coverage settings", () => { + render(); + + const card = screen.getByRole("region", { name: "Command summary" }); + expect(within(card).getByText("coverage")).toBeInTheDocument(); + expect(within(card).getByText("Report format")).toBeInTheDocument(); + expect(within(card).getByText("html")).toBeInTheDocument(); + expect(within(card).getByText("Open report when complete")).toBeInTheDocument(); + expect(within(card).getByText("On")).toBeInTheDocument(); + }); + + it("tracks builder edits and workflow switches", () => { + render(); + + fireEvent.change(screen.getByLabelText(/project or contract path/i), { + target: { value: "./contracts/escrow" }, + }); + + let card = screen.getByRole("region", { name: "Command summary" }); + expect(within(card).getByText("./contracts/escrow")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "limits" })); + + card = screen.getByRole("region", { name: "Command summary" }); + expect(within(card).getByText("limits")).toBeInTheDocument(); + expect(within(card).getByText("batch_payout")).toBeInTheDocument(); + expect(within(card).getByText("recipients")).toBeInTheDocument(); + }); + + it("marks values that fall back to defaults", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "audit" })); + fireEvent.change(screen.getByLabelText(/project or contract path/i), { + target: { value: "" }, + }); + + const card = screen.getByRole("region", { name: "Command summary" }); + expect(within(card).getByText("./src (default)")).toBeInTheDocument(); + }); +}); + +describe("Command comparison view (#151)", () => { + it("reveals the panel from the keyboard with correct aria wiring", async () => { + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole("button", { + name: "Compare two configurations", + }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(toggle).toHaveAttribute("aria-controls", "comparison-panel"); + + toggle.focus(); + await user.keyboard("{Enter}"); + + expect(toggle).toHaveAttribute("aria-expanded", "true"); + + const panel = screen.getByRole("region", { + name: "Compare two configurations", + }); + expect(panel).toHaveAttribute("id", "comparison-panel"); + expect(panel).toHaveFocus(); + }); + + it("starts configuration B from configuration A and marks changed settings", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Compare two configurations" }), + ); + + const panel = screen.getByRole("region", { + name: "Compare two configurations", + }); + const table = within(panel).getByRole("table", { + name: /setting comparison between configuration a and configuration b/i, + }); + + expect(within(table).getAllByRole("row")).toHaveLength(5); + expect(within(table).queryByText("Changed")).not.toBeInTheDocument(); + + fireEvent.change(within(panel).getByLabelText(/project or contract path/i), { + target: { value: "./contracts/escrow" }, + }); + + const pathRow = within(table).getByRole("row", { + name: /project or contract path/i, + }); + expect(within(pathRow).getByText("Changed")).toBeInTheDocument(); + expect(within(pathRow).getByText("./contracts/escrow")).toBeInTheDocument(); + }); + + it("syncs configuration B back to configuration A", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Compare two configurations" }), + ); + + const panel = screen.getByRole("region", { + name: "Compare two configurations", + }); + fireEvent.change(within(panel).getByLabelText(/project or contract path/i), { + target: { value: "./contracts/escrow" }, + }); + expect(within(panel).getByText("Changed")).toBeInTheDocument(); + + const sync = within(panel).getByRole("button", { name: "Match B to A" }); + sync.focus(); + await user.keyboard(" "); + + expect(within(panel).queryByText("Changed")).not.toBeInTheDocument(); + expect(within(panel).getAllByText("Matches")).toHaveLength(4); + }); + + it("renders both generated commands and updates configuration B's", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Compare two configurations" }), + ); + + const panel = screen.getByRole("region", { + name: "Compare two configurations", + }); + expect( + within(panel).getAllByText("soroban-testkit coverage . --format html --open"), + ).toHaveLength(2); + + fireEvent.change(within(panel).getByLabelText(/project or contract path/i), { + target: { value: "./contracts/escrow" }, + }); + + expect( + within(panel).getByText( + "soroban-testkit coverage ./contracts/escrow --format html --open", + ), + ).toBeInTheDocument(); + }); + + it("follows configuration B's workflow change in the comparison table", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Compare two configurations" }), + ); + + const panel = screen.getByRole("region", { + name: "Compare two configurations", + }); + fireEvent.change(within(panel).getByLabelText(/workflow/i), { + target: { value: "audit" }, + }); + + const table = within(panel).getByRole("table", { + name: /setting comparison between configuration a and configuration b/i, + }); + const workflowRow = within(table).getByRole("row", { name: /workflow/i }); + expect(within(workflowRow).getByText("Changed")).toBeInTheDocument(); + expect( + within(table).getByRole("row", { name: /fail on warnings/i }), + ).toBeInTheDocument(); + expect(within(panel).getByLabelText(/fail on warnings/i)).toBeInTheDocument(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 0e00718..bf19cbc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,14 @@ -import { useMemo, useState } from "react"; -import { buildCommand, modules, type CommandName } from "./testkit"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + buildCommand, + compareConfigurations, + effectiveDefaults, + installationSteps, + modules, + summarizeConfiguration, + type CommandName, + type CommandOptions, +} from "./testkit"; import { copyToClipboard } from "./clipboard"; const descriptions: Record = { @@ -17,13 +26,37 @@ export default function App() { const [ramp, setRamp] = useState("recipients"); const [strict, setStrict] = useState(true); const [copied, setCopied] = useState(false); + const [copiedStep, setCopiedStep] = useState(null); const [moduleFilter, setModuleFilter] = useState(""); + const [compareOpen, setCompareOpen] = useState(false); + const [configB, setConfigB] = useState(null); + const comparePanelRef = useRef(null); - const generated = useMemo( - () => buildCommand({ command, path, format, open, fn, ramp, strict }), + const options = useMemo( + () => ({ command, path, format, open, fn, ramp, strict }), [command, path, format, open, fn, ramp, strict], ); + const generated = useMemo(() => buildCommand(options), [options]); + + const summaryRows = useMemo(() => summarizeConfiguration(options), [options]); + const defaultRows = useMemo(() => effectiveDefaults(options), [options]); + const appliedDefaults = defaultRows.filter((row) => row.applied).length; + const configBCommand = useMemo( + () => (configB ? buildCommand(configB) : ""), + [configB], + ); + const comparisonRows = useMemo( + () => (configB ? compareConfigurations(options, configB) : []), + [options, configB], + ); + + useEffect(() => { + if (compareOpen) { + comparePanelRef.current?.focus(); + } + }, [compareOpen]); + const filteredModules = useMemo(() => { const q = moduleFilter.trim().toLowerCase(); if (!q) return modules; @@ -39,6 +72,26 @@ export default function App() { window.setTimeout(() => setCopied(false), 1600); }; + const copyStep = async (index: number, value: string) => { + await copyToClipboard(value); + setCopiedStep(index); + window.setTimeout( + () => setCopiedStep((current) => (current === index ? null : current)), + 1600, + ); + }; + + const toggleComparison = () => { + if (!compareOpen && !configB) { + setConfigB({ ...options }); + } + setCompareOpen((prev) => !prev); + }; + + const updateConfigB = (patch: Partial) => { + setConfigB((prev) => (prev ? { ...prev, ...patch } : prev)); + }; + return (