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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ A visual companion to [Soroban Testkit](https://github.com/soroban-testkit/testk

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

- **Design System & Theme Tokens**: Comprehensive CSS custom property scale (`:root`) standardizing color schemes (canvas, surfaces, accent, text hierarchy, borders) and spacing increments.
- **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.

## Development

```sh
Expand Down
115 changes: 115 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,118 @@ describe("App command copy", () => {
expect(await screen.findByRole("button", { name: "Copied" })).toBeInTheDocument();
});
});

describe("Module filter and empty-state layout (#188)", () => {
it("renders all 7 modules by default", () => {
render(<App />);
expect(screen.getByText("core")).toBeInTheDocument();
expect(screen.getByText("ledger")).toBeInTheDocument();
expect(screen.getByText("money")).toBeInTheDocument();
expect(screen.getByText("events")).toBeInTheDocument();
expect(screen.getByText("tokens")).toBeInTheDocument();
expect(screen.getByText("auth")).toBeInTheDocument();
expect(screen.getByText("ttl")).toBeInTheDocument();
});

it("filters modules matching search query by name or description", () => {
render(<App />);
const filterInput = screen.getByLabelText("Filter modules");

fireEvent.change(filterInput, { target: { value: "ledger" } });

expect(screen.getByText("ledger")).toBeInTheDocument();
expect(screen.queryByText("tokens")).not.toBeInTheDocument();
expect(screen.queryByText("auth")).not.toBeInTheDocument();
});

it("renders an accessible empty-state layout when no modules match the filter", () => {
render(<App />);
const filterInput = screen.getByLabelText("Filter modules");

fireEvent.change(filterInput, { target: { value: "nonexistent-module" } });

const emptyState = screen.getByRole("status");
expect(emptyState).toBeInTheDocument();
expect(emptyState).toHaveAttribute("aria-live", "polite");
expect(
screen.getByText(/No modules match/i),
).toBeInTheDocument();

const resetButton = screen.getByRole("button", { name: "Reset filter" });
expect(resetButton).toBeInTheDocument();

fireEvent.click(resetButton);

expect(screen.getByText("core")).toBeInTheDocument();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});

it("clears the filter using the Clear button", () => {
render(<App />);
const filterInput = screen.getByLabelText("Filter modules");

fireEvent.change(filterInput, { target: { value: "events" } });
expect(screen.getByText("events")).toBeInTheDocument();
expect(screen.queryByText("ttl")).not.toBeInTheDocument();

const clearButton = screen.getByRole("button", { name: "Clear module filter" });
fireEvent.click(clearButton);

expect(screen.getByText("ttl")).toBeInTheDocument();
});
});

describe("Reduced-width layout for navigation labels (#189)", () => {
it("renders accessible navigation links with responsive container attributes", () => {
render(<App />);
const nav = screen.getByRole("navigation");
expect(nav).toBeInTheDocument();

const brand = screen.getByLabelText("Soroban Testkit home");
expect(brand).toBeInTheDocument();

const links = screen.getByLabelText("Main navigation");
expect(links).toBeInTheDocument();
expect(screen.getByText("Modules")).toHaveAttribute("href", "#modules");
expect(screen.getByText("Commands")).toHaveAttribute("href", "#commands");
expect(screen.getByText("GitHub ↗")).toHaveAttribute(
"href",
"https://github.com/soroban-testkit/testkit-blockchain",
);
});
});

describe("Visual distinction between required and optional settings (#196)", () => {
it("displays Required badge and aria-required on required path input", () => {
render(<App />);
const pathInput = screen.getByLabelText(/project or contract path/i);
expect(pathInput).toHaveAttribute("aria-required", "true");
expect(screen.getAllByText("Required").length).toBeGreaterThanOrEqual(1);
});

it("displays Optional badge on optional format and open options in coverage workflow", () => {
render(<App />);
expect(screen.getByLabelText(/report format/i)).toBeInTheDocument();
expect(screen.getByLabelText(/open report when complete/i)).toBeInTheDocument();
expect(screen.getAllByText("Optional").length).toBeGreaterThanOrEqual(2);
});

it("displays Required badges and aria-required on function and ramp in limits workflow", () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "limits" }));

const fnInput = screen.getByLabelText(/function/i);
const rampInput = screen.getByLabelText(/ramp parameter/i);

expect(fnInput).toHaveAttribute("aria-required", "true");
expect(rampInput).toHaveAttribute("aria-required", "true");
});

it("displays Optional badge on fail on warnings option in audit workflow", () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "audit" }));

expect(screen.getByLabelText(/fail on warnings/i)).toBeInTheDocument();
expect(screen.getByText("Optional")).toBeInTheDocument();
});
});
134 changes: 119 additions & 15 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,22 @@ export default function App() {
const [ramp, setRamp] = useState("recipients");
const [strict, setStrict] = useState(true);
const [copied, setCopied] = useState(false);
const [moduleFilter, setModuleFilter] = useState("");

const generated = useMemo(
() => buildCommand({ command, path, format, open, fn, ramp, strict }),
[command, path, format, open, fn, ramp, strict],
);

const filteredModules = useMemo(() => {
const q = moduleFilter.trim().toLowerCase();
if (!q) return modules;
return modules.filter(
([name, description]) =>
name.toLowerCase().includes(q) || description.toLowerCase().includes(q),
);
}, [moduleFilter]);

const copy = async () => {
await copyToClipboard(generated);
setCopied(true);
Expand All @@ -36,7 +46,7 @@ export default function App() {
<span className="brand-mark">S</span>
<span>Soroban Testkit</span>
</a>
<div className="nav-links">
<div className="nav-links" aria-label="Main navigation">
<a href="#modules">Modules</a>
<a href="#commands">Commands</a>
<a href="https://github.com/soroban-testkit/testkit-blockchain">GitHub ↗</a>
Expand Down Expand Up @@ -64,14 +74,55 @@ export default function App() {
</section>

<section className="modules" id="modules">
<header><p className="eyebrow">One prelude. Seven focused modules.</p><h2>Everything a serious contract test needs.</h2></header>
<div className="module-grid">
{modules.map(([name, description], index) => (
<article key={name}>
<span>0{index + 1}</span><h3>{name}</h3><p>{description}</p>
</article>
))}
</div>
<header>
<p className="eyebrow">One prelude. Seven focused modules.</p>
<h2>Everything a serious contract test needs.</h2>
<div className="module-filter-bar">
<label htmlFor="module-search" className="sr-only">Filter modules</label>
<input
id="module-search"
type="search"
className="module-filter-input"
placeholder="Filter modules by name or description..."
value={moduleFilter}
onChange={(event) => setModuleFilter(event.target.value)}
aria-label="Filter modules"
/>
{moduleFilter && (
<button
type="button"
className="module-filter-clear"
onClick={() => setModuleFilter("")}
aria-label="Clear module filter"
>
Clear
</button>
)}
</div>
</header>

{filteredModules.length > 0 ? (
<div className="module-grid" id="module-grid">
{filteredModules.map(([name, description], index) => (
<article key={name}>
<span>0{index + 1}</span><h3>{name}</h3><p>{description}</p>
</article>
))}
</div>
) : (
<div className="module-empty-state" role="status" aria-live="polite">
<div className="module-empty-icon" aria-hidden="true">∅</div>
<h3>No modules match &ldquo;{moduleFilter}&rdquo;</h3>
<p>No testing modules found matching your filter criteria. Try adjusting your search term.</p>
<button
type="button"
className="button secondary"
onClick={() => setModuleFilter("")}
>
Reset filter
</button>
</div>
)}
</section>

<section className="commands" id="commands">
Expand All @@ -81,19 +132,72 @@ export default function App() {
<p>Configure a supported Testkit workflow and copy the exact command to your terminal.</p>
<div className="command-tabs" role="tablist">
{(["coverage", "limits", "audit"] as const).map((name) => (
<button key={name} className={command === name ? "active" : ""} onClick={() => setCommand(name)}>{name}</button>
<button
key={name}
className={command === name ? "active" : ""}
onClick={() => setCommand(name)}
>
{name}
</button>
))}
</div>
<p className="description">{descriptions[command]}</p>
</div>
<div className="builder">
<label>Project or contract path<input value={path} onChange={(event) => setPath(event.target.value)} /></label>
<label>
<div className="field-header">
<span>Project or contract path</span>
<span className="badge badge-required">Required</span>
</div>
<input
value={path}
onChange={(event) => setPath(event.target.value)}
aria-required="true"
/>
</label>
{command === "coverage" && <>
<label>Report format<select value={format} onChange={(event) => setFormat(event.target.value as typeof format)}><option>html</option><option>lcov</option><option>text</option></select></label>
<label className="check"><input type="checkbox" checked={open} onChange={(event) => setOpen(event.target.checked)} /> Open report when complete</label>
<label>
<div className="field-header">
<span>Report format</span>
<span className="badge badge-optional">Optional</span>
</div>
<select value={format} onChange={(event) => setFormat(event.target.value as typeof format)}>
<option>html</option>
<option>lcov</option>
<option>text</option>
</select>
</label>
<label className="check">
<span className="check-content">
<input type="checkbox" checked={open} onChange={(event) => setOpen(event.target.checked)} />
<span>Open report when complete</span>
</span>
<span className="badge badge-optional">Optional</span>
</label>
</>}
{command === "limits" && <div className="row"><label>Function<input value={fn} onChange={(event) => setFn(event.target.value)} /></label><label>Ramp parameter<input value={ramp} onChange={(event) => setRamp(event.target.value)} /></label></div>}
{command === "audit" && <label className="check"><input type="checkbox" checked={strict} onChange={(event) => setStrict(event.target.checked)} /> Fail on warnings</label>}
{command === "limits" && <div className="row">
<label>
<div className="field-header">
<span>Function</span>
<span className="badge badge-required">Required</span>
</div>
<input value={fn} onChange={(event) => setFn(event.target.value)} aria-required="true" />
</label>
<label>
<div className="field-header">
<span>Ramp parameter</span>
<span className="badge badge-required">Required</span>
</div>
<input value={ramp} onChange={(event) => setRamp(event.target.value)} aria-required="true" />
</label>
</div>}
{command === "audit" && <label className="check">
<span className="check-content">
<input type="checkbox" checked={strict} onChange={(event) => setStrict(event.target.checked)} />
<span>Fail on warnings</span>
</span>
<span className="badge badge-optional">Optional</span>
</label>}
<div className="output"><code>{generated}</code><button onClick={copy}>{copied ? "Copied" : "Copy"}</button></div>
</div>
</section>
Expand Down
Loading