From 4b3f3e7c285c871f998a1f26aaab2a18e9e13a16 Mon Sep 17 00:00:00 2001 From: Alejandro Perez Date: Wed, 1 Jul 2026 17:37:05 +0100 Subject: [PATCH 1/3] feat(app): wire restack_pr into the UI Adds a Restack action (store.restackPr + button) shown only for stale reviews, in the DiffView header and the review card; disabled while a conflict-resolver agent runs. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/App.tsx | 9 +++++++++ app/src/components/DiffView.tsx | 30 ++++++++++++++++++++++++++++++ app/src/components/ReviewCard.tsx | 26 +++++++++++++++++++++++++- app/src/store.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 26029a4..ae0368f 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -149,6 +149,7 @@ function App() { const planOpen = useAppStore((s) => s.planOpen); const generatePlan = useAppStore((s) => s.generatePlan); const batchStatus = useAppStore((s) => s.batchStatus); + const restackPr = useAppStore((s) => s.restackPr); const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true"; @@ -339,6 +340,13 @@ function App() { [openReview, navigateToDiff, authoredPrs, reviewRequests, reviews], ); + const handleRestack = useCallback( + (pr: string) => { + void restackPr(pr); + }, + [restackPr], + ); + // Opening a project always routes to its plan gate. When the project has no // plan yet, PlanView surfaces a "Generate plan" affordance. const handleOpenProject = useCallback( @@ -441,6 +449,7 @@ function App() { key={review.id} review={review} onAction={handleReviewAction} + onRestack={handleRestack} /> ))} diff --git a/app/src/components/DiffView.tsx b/app/src/components/DiffView.tsx index 987befd..41abc8c 100644 --- a/app/src/components/DiffView.tsx +++ b/app/src/components/DiffView.tsx @@ -59,6 +59,7 @@ import { XCircle, Loader2, Wrench, + Layers, } from "lucide-react"; // --------------------------------------------------------------------------- @@ -465,6 +466,10 @@ export function DiffView({ const [ciSummary, setCiSummary] = useState(null); const [fixingCi, setFixingCi] = useState(false); + // -- Restack state -- + const restackPr = useAppStore((s) => s.restackPr); + const [restacking, setRestacking] = useState(false); + // -- Refs -- const activeFileRef = useRef(null); const diffEditorRef = useRef(null); @@ -850,6 +855,15 @@ export function DiffView({ } }, [prRef]); + const handleRestack = useCallback(async () => { + setRestacking(true); + try { + await restackPr(prRef); + } finally { + setRestacking(false); + } + }, [restackPr, prRef]); + const handleSubmitReview = useCallback(async () => { const commentCount = review.comments.filter((c) => isLocalOrigin(c.origin)).length; if (commentCount === 0) return; @@ -1047,6 +1061,22 @@ export function DiffView({ Agent + {/* Restack — explicit user action; only when the review is stale. + Operates only on the review's own branch (Invariant 5 / §9). */} + {review.stale && ( + + )} + {/* Fix CI failures — explicit user action; only when CI is failing */} {ciSummary !== null && ciSummary.failed > 0 && ( + + {/* Restack — only for stale reviews; explicit user action on the + review's own branch. Disabled while the conflict-resolver runs. */} + {review.stale && ( + + )} diff --git a/app/src/store.ts b/app/src/store.ts index 170b8af..11e03ff 100644 --- a/app/src/store.ts +++ b/app/src/store.ts @@ -120,6 +120,15 @@ interface AppStore { /** Approve a single review by PR ref (explicit user action). */ approveReview: (pr: string) => Promise; + /** + * Restack a stale review onto its parent's new head (explicit user action). + * + * A clean rebase clears the stale flag; on conflict the backend spawns the + * conflict-resolver agent and returns the review with an active agent run. + * Failure is non-fatal: it sets the store `error` and never blocks the loop. + */ + restackPr: (pr: string) => Promise; + // ------------------------------------------------------------------------- // Config // ------------------------------------------------------------------------- @@ -541,6 +550,23 @@ export const useAppStore = create((set, get) => ({ } }, + restackPr: async (pr: string) => { + try { + const review = await invoke("restack_pr", { pr }); + const replace = (r: Review) => (r.pr === review.pr ? review : r); + set({ + activeReview: + get().activeReview?.pr === review.pr ? review : get().activeReview, + authoredPrs: get().authoredPrs.map(replace), + reviewRequests: get().reviewRequests.map(replace), + frontier: get().frontier.map(replace), + reviews: get().reviews.map(replace), + }); + } catch (e: unknown) { + set({ error: String(e) }); + } + }, + // ------------------------------------------------------------------------- // Config // ------------------------------------------------------------------------- From 066845b5c26d072eaed9db58d0985067a784419c Mon Sep 17 00:00:00 2001 From: Alejandro Perez Date: Wed, 1 Jul 2026 17:37:05 +0100 Subject: [PATCH 2/3] docs: drop duplicate root-doc copies; add dogfooding checklist cockpit-docs/{CLAUDE,SPEC,IMPLEMENTATION_PLAN}.md were stale duplicates of the root docs (accidentally committed); remove them. Add cockpit-docs/DOGFOODING.md for the live-only verification paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- cockpit-docs/CLAUDE.md | 283 -------------------------- cockpit-docs/DOGFOODING.md | 70 +++++++ cockpit-docs/IMPLEMENTATION_PLAN.md | 198 ------------------ cockpit-docs/SPEC.md | 301 ---------------------------- 4 files changed, 70 insertions(+), 782 deletions(-) delete mode 100644 cockpit-docs/CLAUDE.md create mode 100644 cockpit-docs/DOGFOODING.md delete mode 100644 cockpit-docs/IMPLEMENTATION_PLAN.md delete mode 100644 cockpit-docs/SPEC.md diff --git a/cockpit-docs/CLAUDE.md b/cockpit-docs/CLAUDE.md deleted file mode 100644 index c4deb78..0000000 --- a/cockpit-docs/CLAUDE.md +++ /dev/null @@ -1,283 +0,0 @@ -# CLAUDE.md — Cockpit - -Operating manual for any agent working in this repository. Read this **and** `SPEC.md` -before writing code. `SPEC.md` is the source of truth for *what* to build; this file is the -source of truth for *how*. When they conflict, stop and ask. - ---- - -## 0. What this project is - -Cockpit is a local-first desktop tool (Rust core + Tauri 2 shell) that takes a Linear -project to merged PRs through one review loop run at two gates: an optional project-level -**plan gate** and a per-PR **diff gate**. See `SPEC.md` for the model. - -### Invariants (never violate these) - -1. **The local app is the source of truth.** GitHub PRs are published artifacts. Never make - the loop block on a GitHub round-trip. -2. **`cockpit-core` has no UI dependencies.** No `tauri`, no DOM, no framework. If a feature - can't be driven from `cockpit-cli`, it doesn't belong in core yet. -3. **One loop, written once.** The review loop is the `Gated` trait. Do not fork it per gate. -4. **Comments are ephemeral.** A comment lives for one review→rework cycle and is cleared on - `Reworked`. Do not add durable anchoring or a `resolved` flag. -5. **Side effects require explicit confirmation.** Merge, force-push semantics, comment - mirroring, and "approve plan → build the batch" never fire automatically or from agent - output. See §9. -6. **Never weaken tests to get green.** No `|| true`, no deleting/skipping tests, no loosening - assertions to pass. If a test is wrong, say so; don't neuter it. - ---- - -## 1. Repository layout - -``` -cockpit/ -├── Cargo.toml # workspace -├── rust-toolchain.toml # pinned toolchain, edition 2024 -├── crates/ -│ ├── cockpit-core/ # headless: domain, Gated loop, adapters, hook server -│ │ └── src/ -│ │ ├── lib.rs -│ │ ├── model.rs # Review, ProjectPlan, GateState, Anchor, … -│ │ ├── gate.rs # the Gated trait + state transitions -│ │ ├── adapters/ # linear.rs, github.rs, git.rs, agent.rs -│ │ ├── prompt.rs # deterministic prompt assembly -│ │ └── hook_server.rs # axum Stop-hook listener -│ └── cockpit-cli/ # thin binary over core; the validation surface -└── app/ # Tauri 2 shell - ├── src-tauri/ # Rust side of the shell - │ └── src/ - │ ├── main.rs # minimal entry - │ ├── lib.rs # builder, state + handler registration - │ ├── commands/ # thin #[tauri::command]s, delegate to cockpit-core - │ ├── state.rs # AppState (holds core handles) - │ └── error.rs # Serialize-able command error - ├── src/ # frontend (Vite + React + TS) - └── capabilities/ # least-privilege capability files -``` - -Both `cockpit-cli` and `app/src-tauri` depend on `cockpit-core`. Nothing depends the other -way. Phases 0–2 (see `SPEC.md` §15) ship entirely in `cockpit-core` + `cockpit-cli`. - ---- - -## 2. Rust - -### Tooling (non-negotiable) - -- Edition **2024**, toolchain pinned in `rust-toolchain.toml`. -- `cargo fmt` is law; never hand-format, never fight rustfmt. -- `cargo clippy --all-targets --all-features -- -D warnings` must pass. Warnings are errors. -- `uv`-equivalent discipline for deps: add with `cargo add`, keep `Cargo.lock` committed. - -### Naming (Rust API Guidelines) - -- `PascalCase` types/traits, `snake_case` values/functions/modules, `SCREAMING_SNAKE_CASE` - consts. Acronyms are one word: `Uuid`, not `UUID`; `id`, not `ID`. -- No `get_` prefix on getters: `review.head()`, not `review.get_head()`. -- No module-name stutter: `gate::State`, not `gate::GateState` if it lives in `gate`. -- Error variants read verb-object-error: `ParseBranchError`, not `BranchParseError`. -- Conversions follow cost convention: `as_*` (free, borrowed), `to_*` (expensive), `into_*` - (owned, consuming). Don't call something `as_x` if it allocates or validates. -- No `-rs`/`-rust` in crate names. - -### Error handling — reason about caller intent - -The split is **not** "thiserror for libs, anyhow for apps" by rote. The question is whether -the caller will branch on the failure mode. - -- **`cockpit-core` (callers branch on failures):** define typed errors with `thiserror`. - One error enum per adapter/module, meaningful variants, `#[error("…")]` Display messages, - `#[from]` for clean `?`. Example: - - ```rust - #[derive(Debug, thiserror::Error)] - pub enum GitError { - #[error("worktree already exists at {0}")] - WorktreeExists(PathBuf), - #[error("rebase hit conflicts in {0} files")] - RebaseConflict(usize), - #[error(transparent)] - Git2(#[from] git2::Error), - } - ``` - -- **`cockpit-cli` and `src-tauri` (caller just reports and gives up):** use `anyhow::Result` - with `.context("…")` / `.with_context(|| …)` to add the human-readable trail. Print the - full chain with `{:#}`. - -- **No `unwrap`/`expect` in non-test code** except where you can prove the invariant in a - comment (`// SAFETY:` / `// INVARIANT:`). Prefer `?`. Never `unwrap` across an `.await` or - a thread/FFI boundary. - -### Types - -- **Newtype every ID.** `ReviewId`, `IssueRef`, `PrRef` are distinct types, not `String`s. - This is the single highest-value habit in this codebase — it makes the DAG impossible to - wire up wrong. `#[derive(Debug, Clone, PartialEq, Eq, Hash)]` plus `Serialize/Deserialize`. -- **Enums, not booleans**, for anything with meaning. `GateState`, `AgentMode` — never a - `bool is_planning`. -- Derive the common traits eagerly where they make sense (`Debug`, `Clone`, `PartialEq`, and - `Serialize`/`Deserialize` for anything crossing the IPC boundary). -- Keep types `Send + Sync` so they move across tasks freely. -- Make illegal states unrepresentable: prefer `enum Artifact { Plan(..), Diff(..) }` over a - struct with two `Option`s. - -### Async (tokio) - -- One runtime. `cockpit-core` exposes async APIs; binaries own the runtime. -- **Never hold a lock across `.await`.** Take the value out, drop the guard, then await. -- No blocking calls in async context — shell out to `gh`/`git`/`claude` via - `tokio::process`, not `std::process`, on hot paths. -- Mind cancellation safety: anything spawned (agent runs, the hook server) must clean up its - worktree/pid on drop or shutdown. - -### Modules, docs, comments - -- `///` doc comments on every public item, with at least one line of intent. -- Comments explain *why*, not *what*. The code says what. -- Keep functions small; push logic into core, keep adapters thin. - ---- - -## 3. TypeScript / frontend - -Stack: Vite + React + TypeScript, Zustand for app state, Monaco's diff editor for the diff -gate, a markdown/structured renderer for the plan gate. Styling is your call (CSS modules or -Tailwind) — keep it minimal and hand-understood. - -### The agent failure modes to avoid (read this twice) - -AI assistants reliably reach for the wrong tool here. In this repo: - -- **Never `any`.** Use `unknown` + a type guard. `any` is a design smell, not a fix. -- **Never `as` to silence the compiler.** Use `satisfies` to validate a value against a type - without widening it. `as` is reserved for genuinely unavoidable assertions, each with a - comment justifying it. -- **Model state with discriminated unions, not optional fields.** A gate is a tagged union - on its state; the diff result is `{ ok: true; … } | { ok: false; … }`. This mirrors the - Rust enums exactly and gives exhaustiveness. -- **No stealth widening.** `as const` on literal tables; preserve literal types. - -### Config - -- `strict: true` with all strict flags on (TS 6 defaults to this — don't turn any off). - `noUncheckedIndexedAccess`, `useUnknownInCatchVariables`, `exactOptionalPropertyTypes` - on. `module: esnext`, ESM only. -- `import type { … }` for type-only imports. -- Lint/format: a single tool (Biome, or eslint+prettier) with no per-file disables. - -### Patterns - -- `interface` for object shapes; `type` for unions, intersections, and utilities. -- **Branded types for IDs** to match the Rust newtypes: - ```ts - type ReviewId = string & { readonly __brand: "ReviewId" }; - ``` - A `ReviewId` must never be assignable from a raw `string`. -- Exhaustiveness via `never`: - ```ts - function assertNever(x: never): never { throw new Error(`unreachable: ${x}`); } - ``` - Every `switch` over a gate state ends in `default: return assertNever(state)`. -- `readonly` on props and data that shouldn't mutate. -- Function components only; obey the rules of hooks; no classes unless `instanceof` is - genuinely needed. - ---- - -## 4. The IPC boundary (Rust ↔ TS) - -The Rust domain types and the TS types must not drift. **Generate the TS types from Rust** -with `ts-rs` (`#[derive(TS)]` on the domain enums/structs); commit the generated `.ts` and -fail CI if it's stale. A Rust `GateState` enum becomes a TS discriminated union for free, -which is exactly what the frontend should switch on. - -Tauri command rules: - -- Commands are **thin**. They parse params, call into `cockpit-core`, and map the result. - All logic lives in core. -- `State<'_, Arc>` — the `Arc` is required because the hook server and agent runs - touch state from background tasks. -- Register **every** command in a single `generate_handler!` — there is no compile-time - check that you did. -- Command errors implement `Serialize` (a dedicated `CommandError` in `app/src-tauri/error.rs` - that converts from core's `thiserror` enums). Don't leak `anyhow` across IPC. -- Use Tauri **events** for the push direction: the Stop-hook reconcile emits an event the - frontend listens for to flip a PR to `reworked`. Don't poll. -- Keep capabilities least-privilege; don't broaden a capability speculatively. -- Never block the main thread — long work goes to core/async. - ---- - -## 5. Testing - -- **Rust:** `cargo test`. The priority target is the **`Gated` state machine and the loop** — - every transition in `SPEC.md` §7 has a test, including the failure transitions - (agent-failed → InReview) and the `stale`/restack edges. Adapters get integration tests - behind a feature flag or a local fixture; don't mock what you can run. -- **Do not over-mock.** Mocking the thing under test to make a test pass is worse than no - test. Test real behavior against real (local) git/worktrees where feasible. -- **TS:** Vitest. Narrow discriminated unions with real guards in tests, not casts. -- The Phase-1 reliability bar is itself the acceptance test: comment → request-changes → - agent fixes + pushes → state flips, end to end from the CLI. - ---- - -## 6. Definition of done (per change) - -A change is done when all of these hold: - -- `cargo fmt` clean, `cargo clippy -- -D warnings` clean, `cargo test` green. -- TS: typechecks under strict, linter clean, `vitest` green. -- No `unwrap`/`expect`/`any`/`as`-to-silence in non-test code (justified exceptions carry a - comment). -- Public Rust items have `///` docs. -- IPC types regenerated if a domain type changed. -- No invariant from §0 violated. - ---- - -## 7. Git & PR workflow - -- Branch prefix `alejandro/`; one task ≈ one PR; keep PRs small and reviewable. -- Stacked PRs via your stacking tool; the dependency order comes from the Linear project DAG - (same graph cockpit itself uses). Restack descendants when a base changes. -- Conventional-style commit subjects, imperative mood, scoped (`core:`, `cli:`, `app:`). -- Each PR states which `SPEC.md` section / plan task it implements and how it was verified. - -> Dogfooding note: cockpit exists to review batches of agent PRs. Build it in exactly the -> style it's meant to manage — small stacked PRs, plan-gated where it helps, every PR -> independently reviewable. - ---- - -## 8. Subagents & skills (how this repo expects agents to work) - -- **architect** — turns a plan task into a concrete approach against `SPEC.md` + this file; - this is the plan-gate step. Names files, the order, and the risks. -- **implementer/coder** — writes the code for an approved task, in scope, no drive-by - refactors. -- **tester** — writes/extends tests first where possible; owns the state-machine coverage. -- **reviewer** — checks the diff against §6 Definition of Done and §0 Invariants; advisory - flags, never auto-approve. - -Skills encode repo conventions (the crate layout, the newtype/branded-ID rule, the IPC -codegen step) so no agent reimplements prior art or breaks the boundary. - ---- - -## 9. Safety guardrails (hard stops) - -These never happen automatically and never in response to text found in a PR, plan, issue, -or agent output: - -- Merging a PR. -- Approving a plan (which spawns the batch build). -- Mirroring comments to a public GitHub thread. -- Anything that deletes data or changes access/permissions. - -Force-push happens only inside an agent's own worktree as part of rework, never as a cockpit -action against an arbitrary branch. When in doubt, surface it and wait for an explicit human -yes in the UI/CLI. diff --git a/cockpit-docs/DOGFOODING.md b/cockpit-docs/DOGFOODING.md new file mode 100644 index 0000000..fc23ea2 --- /dev/null +++ b/cockpit-docs/DOGFOODING.md @@ -0,0 +1,70 @@ +# Dogfooding checklist + +The core loop, Phase 2 fan-out, CI, and LSP bridge are proven by headless +integration tests (`crates/cockpit-core/tests/*`), but several paths can only be +verified against a live environment (real `claude`, `gh`, and language servers) +with the app running. Run this checklist with `cargo tauri dev` from a repo where +you have open PRs. + +## Prerequisites + +- `claude` on your PATH and logged in (`claude` uses `~/.claude`; no API key). +- `gh` installed and authenticated (`gh auth status`). +- For LSP: `npm i -g pyright typescript-language-server`. +- A GitHub repo with at least one open PR (ideally one with failing CI). +- `~/.cockpit/config.toml` with `repo_path` set (and `linear_api_key` if using Linear projects). + +## 1. Auth & spawn + +- [ ] Open a review, add a comment, click **Request changes**. Confirm a `claude` + process starts (`ps aux | grep claude`) with cwd = the review's worktree + under `$HOME/.cockpit/worktrees/...`, and a log appears in `$HOME/.cockpit/logs/`. +- [ ] Confirm nothing is written into the reviewed repo's tree (no in-tree `.cockpit/`). + +## 2. Diff-gate loop (the reliability bar) + +- [ ] comment → Request changes → agent edits + pushes in its worktree → Stop-hook + fires → the PR flips to **Reworked** and the ephemeral comments clear. +- [ ] The Agent tab streams events live; the diff refreshes to the new head. + +## 3. Plan gate + Phase 2 (per project) + +- [ ] Create a Project (blank or from Linear). Open it → **Generate plan** → the + planner runs and the plan doc populates (Pending). +- [ ] Open for review → add a plan comment → **Request changes** → planner reworks → Reworked. +- [ ] **Approve & build** (confirm dialog) → N implementers fan out, one worktree + each, bounded by `max_parallel_agents`; only THIS project's frontier reviews spawn. +- [ ] Open a second project; confirm its plan/batch state is independent. + +## 4. CI tab + +- [ ] On a PR with CI, the **CI** tab lists checks grouped by workflow with pass/fail/pending. +- [ ] A failing pipeline auto-expands and loads its failed-run logs inline; a passing + one stays collapsed and triggers no `gh` call. +- [ ] For an expired/in-progress run (HTTP 410), the panel shows the "logs unavailable" + message and a working **View run on GitHub** link. +- [ ] **Fix CI failures** (confirm) dispatches the fixer with the CI logs in the prompt. + +## 5. External links + +- [ ] The check link, and DiffView's PR/issue/repo links, open in your system browser + (opener plugin). If a link fails, a visible error appears (not a silent no-op). + +## 6. LSP + +- [ ] Open a `.py` and a `.ts` file in the diff editor; confirm diagnostics/hover/completion + appear (needs pyright / typescript-language-server installed). +- [ ] Close reviews / quit the app; confirm no orphan `pyright`/`tsserver` processes remain + (`ps aux | grep -E 'pyright|typescript-language-server'`). + +## 7. Restack + +- [ ] For a stale review (parent reworked), the **Restack** button appears; clicking it + rebases onto the parent's new head, or spawns the conflict-resolver on conflict. + +## Known limitations to watch for + +- The plan doc is ingested from `~/.cockpit/plans/.md` written by the planner; + if the planner doesn't write there, the plan stays empty (check the log). +- Skills are read from `~/.cockpit/skills/*/SKILL.md` and filtered by the diff's file + extensions; verify a relevant skill actually appears in the agent's prompt (log). diff --git a/cockpit-docs/IMPLEMENTATION_PLAN.md b/cockpit-docs/IMPLEMENTATION_PLAN.md deleted file mode 100644 index d39b8dd..0000000 --- a/cockpit-docs/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,198 +0,0 @@ -# IMPLEMENTATION_PLAN.md — Cockpit - -A task graph for building cockpit with the architect → coder → tester → reviewer pipeline. -Source of truth: `SPEC.md` (what) and `CLAUDE.md` (how). This file decomposes the SPEC's -phases into tasks sized at roughly one stacked PR each. - -## How to run this plan - -For every task: - -1. **architect** drafts the approach against `SPEC.md` + `CLAUDE.md` (files, order, risks). - This is the plan-gate step — get it approved before code. -2. **coder** implements only the approved task. No drive-by refactors, no scope creep. -3. **tester** writes/extends tests; for state-machine tasks, tests come first. -4. **reviewer** checks the diff against `CLAUDE.md` §6 (Definition of Done) and §0 - (Invariants). Advisory only; a human approves. - -Tasks are stacked in dependency order (the same DAG model cockpit itself manages). A task is -mergeable only when its acceptance criteria pass. **Build in validation order, not runtime -order** — the shared loop is proven on the diff gate (Phase 1) before the plan gate reuses it. - -Legend: `[deps: …]` lists prerequisite task IDs. Each task names its primary subagent owner. - ---- - -## Phase 0 — Core skeleton + adapters (headless) - -Goal: `cockpit project ` reads issues, builds the DAG, prints the frontier; -`cockpit ingest` lists existing PRs. No loop yet. - -### T0.1 — Workspace + toolchain scaffold · architect→coder · [deps: —] -Create the Cargo workspace (`cockpit-core`, `cockpit-cli`), `rust-toolchain.toml` (edition -2024), CI running `fmt`, `clippy -D warnings`, `test`. -**Done when:** empty crates build and CI is green on all three checks. - -### T0.2 — Domain model + newtypes · coder · [deps: T0.1] -Implement `model.rs`: `ReviewId`/`IssueRef`/`PrRef` newtypes, `Review`, `ProjectPlan`, -`GateState`, `Artifact`, `PlanDoc`, `Comment`, `Anchor`, `AgentRun`/`AgentMode` per `SPEC.md` -§6. Derive the required traits; comments ephemeral (no `resolved`, no durable sha). -**Done when:** types compile; a unit test constructs a small DAG of `Review`s and asserts the -parent/child edges. - -### T0.3 — `Gated` trait + state transitions · tester→coder · [deps: T0.2] -Implement `gate.rs`: the `Gated` trait and every transition in `SPEC.md` §7 as explicit -functions, plus the `stale` flag logic. **Tests first.** -**Done when:** a test exercises every transition including failure edges (agent-failed → -InReview) and stale set/clear; illegal transitions are rejected. - -### T0.4 — Linear adapter (read-only) · coder · [deps: T0.2] -`adapters/linear.rs`: read a project's issues + dependency relations via GraphQL; build the -DAG. `thiserror` error type. -**Done when:** given a project id, returns issues + edges; integration test against a fixture. - -### T0.5 — GitHub adapter (gh shell-out) + branch linkage · coder · [deps: T0.2] -`adapters/github.rs`: `pr list --json`, `pr diff`, `pr checks` via `gh`. Parse the Linear -issue id out of each PR head branch to link PR → issue (per `SPEC.md` §16, settled). -**Done when:** lists PRs with diffs; branch→issue parsing covered by unit tests incl. edge -cases (no id, malformed). - -### T0.6 — Git adapter (worktrees) · coder · [deps: T0.2] -`adapters/git.rs` with `git2`: `ensure_worktree` (stacked base = parent branch), -`reconcile`, `prune_worktree`. Restack stubbed (Phase 3). -**Done when:** can create/reconcile/prune a worktree against a scratch repo in tests. - -### T0.7 — `cockpit project` + `cockpit ingest` CLI · coder · [deps: T0.3,T0.4,T0.5] -Wire the CLI to build the DAG, compute the frontier (`SPEC.md` §5/§8), and print it. -**Done when:** both commands produce correct frontier/PR output against a real test project. - -**Phase 0 exit:** the frontier prints correctly from real data; all adapters tested. - ---- - -## Phase 1 — The loop at the diff gate (headless) ★ critical milestone - -Goal: prove the shared loop round-trips end to end from the CLI. **This is the product in -miniature; everything after is leverage on top.** - -### T1.1 — Deterministic prompt assembly · architect→coder · [deps: T0.2] -`prompt.rs`: build the rework prompt per `SPEC.md` §9 — intent, approved plan (diff gate), -current artifact, anchored comments, scope guard incl. the test-weakening clause. Hash + log -the assembled prompt. -**Done when:** golden-file tests assert exact prompt structure for a sample review. - -### T1.2 — Agent spawn (PTY) · coder · [deps: T0.6, T1.1] -`adapters/agent.rs`: spawn `claude` in a worktree via PTY (reuse Plannotator's PTY), -`AgentMode::Fix`, capture logs, track pid, map `session_id → (object, mode)`. -**Done when:** spawns a process in a worktree and records the session mapping; covered by a -test with a stub command. - -### T1.3 — Stop-hook listener (axum) · coder · [deps: T0.3, T1.2] -`hook_server.rs`: axum server on a fixed localhost port; `/hook/stop` maps session → object, -calls `reconcile` (re-read git, rerun ci/test deltas), clears ephemeral comments, → -`Reworked`, emits a completion signal. -**Done when:** a simulated POST drives a `Dispatched` review to `Reworked` and clears its -comments. - -### T1.4 — `comment add` + `request-changes` CLI · coder · [deps: T1.1,T1.2,T1.3] -CLI verbs: add an anchored comment; `request-changes ` gathers open comments → assembles -prompt → spawns fixer → `Dispatched`. -**Done when:** the full verb set works on a real PR. - -### T1.5 — End-to-end round-trip test · tester · [deps: T1.4] -The reliability bar: comment → request-changes → agent fixes → pushes → Stop hook → state -flips to `Reworked`, comments cleared, ready for re-review — no manual terminal step. -**Done when:** this runs green against a real (small) PR + agent. - -**Phase 1 exit:** the loop round-trips reliably. If it does, the rest is presentation and -reuse; if it doesn't, fix it here before anything else. - ---- - -## Phase 2 — Batch kickoff + optional plan gate - -Goal: originate work from a Linear project; reuse the Phase-1 loop on a `ProjectPlan`. - -### T2.1 — Plan-doc format + parser · architect→coder · [deps: T0.2] -Decide and pin a structured plan-output format via the planner subagent's instructions -(resolve `SPEC.md` §16 open item), parse it into `PlanDoc` (steps + files + risks as -anchors). -**Done when:** parser round-trips the pinned format; anchors resolve to steps/files. - -### T2.2 — Planner spawn + plan gate via `Gated` · coder · [deps: T2.1, T1.x] -Run the loop on `ProjectPlan` with `AgentMode::Plan`; reconcile re-parses the plan doc. -**Done when:** a plan can be commented on and re-planned through the same transitions as a -diff review. - -### T2.3 — `cockpit kickoff ` · coder · [deps: T2.2, T0.7] -Kick off: optionally produce a plan (→ plan gate) or skip; on approval/skip, spawn the -implementer for every issue, establishing stacked worktrees (base = parent branch). Each -build opens a PR → a `Review` at the diff gate. -**Done when:** a project goes plan→approved→batch-of-PRs (and skip→PRs) with no manual steps. - -**Phase 2 exit:** a project can be taken from issues to a batch of diff-gate reviews. - ---- - -## Phase 3 — Restack on rework - -### T3.1 — Restack algorithm · architect→coder · [deps: T0.6, T0.3] -Base `Reworked` marks descendants `stale` at dispatch; rebase each descendant in dependency -order via `git2`; clean rebases are pure git. -**Done when:** a 3-PR stack reworked at the base auto-restacks the upper two cleanly. - -### T3.2 — Conflict-resolver dispatch · coder · [deps: T3.1, T1.2] -On rebase conflict, spawn the conflict-resolver subagent (`AgentMode::Restack`); on success -clear `stale`. -**Done when:** an induced conflict is resolved via agent and the stack settles. - -**Phase 3 exit:** stacked rework is hands-off except for genuine conflicts. - ---- - -## Phase 4 — Tauri shell - -Goal: wrap the proven core. The shell is presentation over working logic. - -### T4.1 — Tauri scaffold + state + IPC codegen · architect→coder · [deps: Phase 1] -`app/src-tauri` with `AppState` holding core handles (`Arc`), `generate_handler!` -registration, `CommandError` (Serialize) mapping from core errors, `ts-rs` codegen for -domain types with a CI staleness check. Least-privilege capabilities. -**Done when:** a trivial command round-trips and generated TS types are committed + checked. - -### T4.2 — Frontier view + agent status · coder · [deps: T4.1] -React + Zustand: the frontier list, per-object agent status, gate controls; subscribe to the -Stop-hook completion event to flip state live. -**Done when:** the morning frontier renders and updates on agent completion. - -### T4.3 — Diff gate UI (Monaco) · coder · [deps: T4.2] -Monaco diff editor with inline comment threads + the `ci_delta`/`test_count_delta` flags; -`request changes` calls the command. Do not hand-roll the diff viewer. -**Done when:** a real PR is reviewed and reworked entirely from the desktop app. - -### T4.4 — Plan gate UI · coder · [deps: T4.2, T2.2] -Render `PlanDoc` as a commentable document (Plannotator annotation port); approve → build. -**Done when:** a plan is reviewed and approved from the app, triggering the batch. - -**Phase 4 exit:** the full loop is usable from the desktop app, both gates. - ---- - -## Phase 5 — Polish - -- **T5.1** Batch-approve the clean frontier (size/CI/test-delta heuristics, advisory). -- **T5.2** Optional GitHub comment mirror (confirmed side effect). -- **T5.3** Multi-stack view. - ---- - -## Critical path & risks - -- **Critical path:** T0.1 → T0.2 → T0.3 → (T1.1,T1.2,T1.3) → T1.4 → **T1.5**. Everything - downstream assumes the loop proven at T1.5. -- **Top risk:** the Stop-hook → reconcile round-trip (T1.3). It's the difference between a - loop and babysitting. De-risk it first inside Phase 1; reuse the Plannotator interceptor. -- **Second risk:** plan-doc parsing (T2.1). Pin the format or anchors will be flaky. Doesn't - block Phase 1. -- **Do not** start Phase 4 (Tauri) before T1.5 is green. Building UI over an unproven loop is - the main way this project would waste a week. diff --git a/cockpit-docs/SPEC.md b/cockpit-docs/SPEC.md deleted file mode 100644 index 9161a11..0000000 --- a/cockpit-docs/SPEC.md +++ /dev/null @@ -1,301 +0,0 @@ -# SPEC.md — Cockpit - -A local-first desktop tool that takes a Linear project to merged PRs. A project may start -from an existing plan or a kickoff; if a plan exists it is reviewed once at a project-level -**plan gate**; then the agent implements the whole batch of PRs; then each PR is reviewed -at a per-PR **diff gate** with a Monaco diff and in-app comments, and `request changes` -hands the comments back to the agent. - -Working name: `cockpit`. Stack: Rust core + Tauri 2 shell. Reuses the PTY + axum -hook-interception (and, for the plan gate, the annotation surface) from Plannotator. - ---- - -## 1. Problem - -A Linear project is a graph of dependent issues handed to local Claude Code agents. Two -things are missing from today's flow and both are expensive: - -- **No plan gate.** When there is a project plan, the human doesn't get to confirm the - approach before the batch is built, so wrong-approach mistakes surface at diff-review - time (expensive) instead of plan time (free). -- **No reliable rework loop.** Comments don't reliably reach the agent, the branch - doesn't reliably update, and re-review is unstructured — worse under a stack, where - reworking a base invalidates everything above it. - -## 2. Goal - -One reliable review loop, run on one optional project plan and on each PR diff. "Reliable" -means every state transition is explicit, the agent reliably picks up comments, the -artifact reliably updates, and the stack reliably restacks. - -Non-goals (v1): - -- AI that reviews the code *for* you (CodeRabbit/Greptile do that). cockpit is a human - review cockpit; a reviewer subagent may pre-flag, advisory only. -- Cloud execution. Agents run locally in worktrees — the whole advantage. -- Team/multi-user, auth, sharing. Single-user, local. - -## 3. Core principle - -**The local app is the source of truth; the GitHub PR is a published artifact.** - -Reviewed objects are tied to worktrees on disk, not to GitHub's review API. Comments live -in the app's local store, anchored to a location in the current artifact so they survive -the artifact changing. Rework is dispatched in-process to a `claude` run in the worktree. -GitHub is touched only at the edges (read PRs/diffs, optionally mirror comments, merge). - -## 4. One loop, two places it runs - -The review loop is a trait, implemented by two kinds of object: - -- `ProjectPlan` — **optional, one per project.** Runs the loop once on the plan doc. -- `Review` — **one per PR.** Runs the loop on the PR diff. - -``` -review loop (shared): - Pending → InReview → (request changes) Dispatched → Reworked → InReview → … → Approved -``` - -`ProjectPlan::Approved` triggers implementing the whole batch. `Review::Approved` triggers -merge. The plan gate can be absent entirely (no plan → skip straight to implementation). - -Lifecycle (runtime order): - -``` -New project - ├─ plan exists → PROJECT PLAN GATE (loop on the plan) ─┐ - └─ kick off → (optional plan, else skip) ──────────────┤ - plan approved / skipped - ▼ - Implement all PRs (agent builds the batch) - ▼ - per PR: DIFF GATE (loop on the diff) - ▼ - Merge (+ restack on rework) -``` - -## 5. Entry + kickoff - -- Read the Linear project's issues and dependency relations → build the DAG (the same - graph used for restack). -- **If a plan exists** (Claude produced one, or one is attached): load it into a - `ProjectPlan` and enter the plan gate. -- **If kicking off:** optionally spawn the planner to produce a project plan (→ plan - gate), or skip planning and go straight to implementation. -- **On plan approval (or skip):** spawn implementation for every issue. Establish a - worktree per issue; a stacked issue's worktree base is its parent issue's branch, so the - stack is wired at build time. Each build opens a PR → a `Review` at the diff gate. -- **Linkage:** branches follow Linear's generated name with the issue id embedded, so - cockpit maps PR → issue by parsing the branch. This drives both the diff-gate↔issue link - and the stack edges. - -## 6. Data model - -```rust -struct ProjectPlan { // optional, one per project - project: ProjectRef, - doc: PlanDoc, - gate_state: GateState, // shared loop - comments: Vec, - agent: Option, // planner -} - -struct Review { // one per PR (diff gate) - id: ReviewId, - issue: IssueRef, - pr: PrRef, - branch: String, - base: String, // base branch OR parent's branch (stacked) - worktree: PathBuf, - gate_state: GateState, // shared loop - diff: DiffData, - head_sha: String, - comments: Vec, - parents: Vec, // ancestors in the stack (from Linear deps) - children: Vec, - stale: bool, // an ancestor is in rework; don't deep-review yet - agent: Option, // fixer / restack -} - -/// The shared loop. Both ProjectPlan and Review implement it. -trait Gated { - fn gate_state(&self) -> GateState; - fn comments(&self) -> &[Comment]; - fn dispatch(&mut self) -> Result; // assemble prompt + spawn in worktree - fn reconcile(&mut self) -> Result<()>; // after Stop hook: re-read artifact -} - -enum GateState { Pending, InReview, Dispatched, Reworked, Approved } - -struct PlanDoc { // parsed from plan output - summary: String, - steps: Vec, // ordered; comment anchors - files: Vec, // intended touch set; comment anchors - risks: Vec, // migrations, new deps, breaking changes - raw: String, -} - -// Ephemeral: a comment lives for one review→rework cycle, cleared on Reworked. -struct Comment { - id: CommentId, - anchor: Anchor, // points into the *current* artifact only - body: String, - origin: CommentOrigin, // Local | GitHubMirror -} - -enum Anchor { - PlanStep(usize), - PlanFile(PathBuf), - DiffLine { path: PathBuf, range: (u32, u32) }, // current head; not durable -} - -struct AgentRun { pid: u32, mode: AgentMode, started_at: Instant, prompt_hash: String, log_path: PathBuf } -enum AgentMode { Plan, Implement, Fix, Restack } -``` - -## 7. State machine - -The shared loop (applies to `ProjectPlan` and to each `Review`): - -| From | Event | To | -|------------|----------------------------------|------------| -| Pending | open in cockpit | InReview | -| InReview | request changes (≥1 comment) | Dispatched | -| InReview | approve | Approved | -| Dispatched | Stop hook + artifact reconciled | Reworked | -| Dispatched | agent failed / no change | InReview | -| Reworked | open in cockpit | InReview | - -Cross-object / DAG transitions: - -| From | Event | To | -|----------------------------|--------------------------------|--------------------------| -| ProjectPlan / Approved | spawn implementation (batch) | N Reviews / Pending | -| (no plan) | skip planning | N Reviews / Pending | -| Review / Approved | merge succeeds → prune worktree | Merged (terminal) | -| Review | a parent enters Dispatched | this.stale = true | -| Review.stale | parent Reworked + restack ok | this.stale = false | - -`stale` gates the *frontier* (what's safe to deep-review), not the loop. - -## 8. The loop (must not break) - -Same steps for the plan gate and each diff gate; only the artifact, `AgentMode`, and the -reconcile step differ. - -1. **Render artifact.** Plan gate → `PlanDoc` as a commentable document (steps + file set - + risks; Plannotator port). Diff gate → Monaco diff with inline `ci_delta` and - `test_count_delta` flags. -2. **Comment.** In-app, stored locally, anchored. -3. **Request changes.** Gather all open comments → one rework request. -4. **Assemble prompt.** §9. Deterministic, hashed, logged. -5. **Spawn.** `claude` in the worktree via PTY. Plan gate → plan mode (planner). Diff gate - → fixer. -6. **Agent works.** Plan gate → revised plan. Diff gate → edit, test, commit, - `git push --force-with-lease`. -7. **Close the loop.** Claude Code Stop hook POSTs the axum endpoint (§11). cockpit maps - session → object, calls `reconcile` (re-parse plan, or re-read git + rerun `ci_delta`), - clears the dispatched comments (they're ephemeral), → `Reworked`. -8. **Re-review / advance.** Reworked → InReview. Plan approved → implement the batch. Diff - approved → merge. On any base change with children, restack (§13). - -v1 reliability bar: steps 1–7 round-trip on one real PR at the diff gate, from the CLI. - -## 9. Prompt assembly - -Deterministic, ordered. Prevents the "agent misses the point and loops" failure. - -Plan prompt: project intent + issue list + dependency notes + conventions/skills → -"produce a plan; name the files, the order, and the risks." - -Rework prompt (either gate): -1. Intent (project plan, or the issue's acceptance criteria). -2. The approved plan (diff gate only — the contract the code was built against). -3. The current artifact (plan doc, or diff). -4. Gathered comments, each with its anchor rendered. -5. Scope guard: "Address only the comments above. Don't refactor unrelated code. Don't - weaken or delete tests. If a comment is wrong or impossible, stop and say so." The - test-weakening clause is the highest-ROI line — agents reach for `|| true` and test - deletion to get green. - -## 10. Subagents & skills - -cockpit selects a subagent per dispatch by `AgentMode`; definitions live in the repo's -`.claude/`: - -- **planner** — project plan + plan rework (plan mode). -- **implementer** — initial batch build of all PRs from the approved plan. -- **fixer** — diff-gate rework (scoped execute). -- **reviewer** — optional ingest pre-flag, advisory only. -- **conflict-resolver** — restack conflicts. - -Skills encode repo conventions (monorepo layout, "prefer existing util X") so neither the -planner nor the fixer reimplements prior art. - -## 11. Stop-hook listener - -`cockpit-core` runs an axum server on a fixed localhost port. The repo's Claude Code config -registers a Stop hook that POSTs `{ session_id }` to `/hook/stop`. cockpit keeps a -`session_id → (object, AgentMode)` map populated at spawn; on callback it reconciles the -right artifact and transitions. Reuse the Plannotator interceptor wholesale. - -## 12. Guardrails on side effects - -Require explicit UI confirmation; never triggered by ingested content or agent output: -`gh pr merge`; mirroring local comments to the GitHub thread; approving the plan (which -spawns the batch build). Force-push happens inside the agent's worktree, not from cockpit. - -## 13. Restack-on-rework - -When a base `Review` reaches `Reworked`, descendants were already marked `stale` at -dispatch. Rebase each descendant onto the new base in dependency order: clean rebases via -`git2`; conflicts spawn the conflict-resolver subagent. Successful restack clears `stale`. - -## 14. Adapters - -- **linear.rs** — read project issues + dependency relations (DAG). GraphQL. cockpit writes - nothing to Linear in v1. -- **github.rs** — shell out to `gh` (`pr list --json`, `pr diff`, `pr checks`, `pr merge`). - Parses the Linear issue id out of each PR's head branch to link PR → issue. -- **git.rs** — `git2`: `ensure_worktree` (stacked base = parent branch), `reconcile`, - `restack`, conflict detection, `prune_worktree` (called on `Merged`). -- **agent.rs** — PTY spawn of `claude` (plan / implement / fix), plan-doc parsing, prompt - assembly, log capture, pid tracking. - -## 15. Build phases - -Validation order, not runtime order — build and prove the shared loop first (on the diff -gate), then reuse it for the plan gate, because both share it. - -- **Phase 0 — core + adapters, headless.** Domain model, `Gated` trait + state machine, - all adapters. Proof: `cockpit project ` reads issues, builds the DAG, prints the - frontier; `cockpit ingest` lists existing PRs. -- **Phase 1 — the loop at the diff gate, headless.** `comment add`, `request-changes`, - spawn fixer, Stop-hook reconcile to `Reworked`. Proof: comment → dispatch → agent fixes - + pushes → state flips, all from the CLI. **The product in miniature.** -- **Phase 2 — batch kickoff + optional plan gate.** Reuse the loop with the planner on a - `ProjectPlan`; `cockpit kickoff ` plans (or skips), approve, then the - implementer spawns all PRs. Proof: a project goes plan→approved→batch of PRs (or - skip→PRs) with no manual terminal steps. -- **Phase 3 — restack-on-rework.** Base rework marks children stale; auto-rebase; conflict - resolver only on conflict. Proof: a 3-PR stack reworked at the base. -- **Phase 4 — Tauri shell.** Wrap the proven core. Frontier list; Monaco diff + in-app - comment threads (diff gate); plan renderer (Plannotator port, plan gate); per-object - agent status. Don't hand-roll the diff viewer — Monaco's diff editor. -- **Phase 5 — polish.** Batch-approve the clean frontier, optional GitHub comment mirror, - multi-stack view. - -## 16. Decisions - -- **Issue→PR linkage — settled.** Linear embeds the issue identifier in the branch name it - generates (e.g. `alejandro/nex-123-...`). github.rs parses the issue id from the PR head - branch; no PR-body markers or attachment lookups needed. -- **Comments are ephemeral — settled.** A comment lives for one review→rework cycle. On - `Reworked` the dispatched comments are cleared; the next cycle starts fresh on the new - artifact. No fuzzy re-anchoring, no durability across diff churn. -- **Worktree GC — settled.** On `Merged`, prune the worktree (git.rs `prune_worktree`). -- **Axum port — open.** Default to a single fixed localhost port (single-user); revisit - only if it ever needs to be per-project. -- **Plan-doc parsing — open.** Pin a structured plan-output format via the planner - subagent, or parse loose markdown? Pinning makes the plan anchors reliable. From c3ca1d9cc080531ed734688ac1ec13035264b91c Mon Sep 17 00:00:00 2001 From: Alejandro Perez Date: Wed, 1 Jul 2026 17:37:06 +0100 Subject: [PATCH 3/3] test(app): add Vitest harness and run it in CI vitest + jsdom + testing-library; typed Tauri invoke/listen mocks; 48 tests across ci.ts, shortcuts, store, StateFilter, ReviewCard, CiPanel. CI frontend job now runs npx vitest run after tsc. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 1 + app/package-lock.json | 1198 ++++++++++++++++++++++- app/package.json | 12 +- app/src/components/CiPanel.test.tsx | 105 ++ app/src/components/ReviewCard.test.tsx | 74 ++ app/src/components/StateFilter.test.tsx | 94 ++ app/src/lib/ci.test.ts | 154 +++ app/src/lib/shortcuts.test.tsx | 62 ++ app/src/store.test.ts | 207 ++++ app/src/test/fixtures.ts | 72 ++ app/src/test/setup.ts | 16 + app/src/test/tauri-mock.ts | 116 +++ app/vite.config.ts | 9 +- 13 files changed, 2111 insertions(+), 9 deletions(-) create mode 100644 app/src/components/CiPanel.test.tsx create mode 100644 app/src/components/ReviewCard.test.tsx create mode 100644 app/src/components/StateFilter.test.tsx create mode 100644 app/src/lib/ci.test.ts create mode 100644 app/src/lib/shortcuts.test.tsx create mode 100644 app/src/store.test.ts create mode 100644 app/src/test/fixtures.ts create mode 100644 app/src/test/setup.ts create mode 100644 app/src/test/tauri-mock.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a36482..ab07315 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,4 @@ jobs: cache-dependency-path: app/package-lock.json - run: npm ci - run: npx tsc --noEmit + - run: npx vitest run diff --git a/app/package-lock.json b/app/package-lock.json index 934cd87..d30846e 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -31,13 +31,77 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.0.0", + "jsdom": "^29.1.1", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^3.2.6" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -544,6 +608,159 @@ } } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.75.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", @@ -1141,6 +1358,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -2317,6 +2552,95 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -2328,6 +2652,13 @@ "path-browserify": "^1.0.1" } }, + "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" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2373,6 +2704,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2426,6 +2775,121 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xterm/addon-fit": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", @@ -2505,11 +2969,24 @@ "node": ">=8" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" }, "node_modules/aria-hidden": { "version": "1.2.6", @@ -2523,6 +3000,26 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -2565,6 +3062,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -2683,6 +3190,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2741,6 +3258,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -2753,6 +3287,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -3015,6 +3559,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3034,6 +3599,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debounce-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", @@ -3066,6 +3645,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -3080,6 +3666,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -3138,6 +3734,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3162,6 +3768,13 @@ "node": ">=0.3.1" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", @@ -3266,6 +3879,19 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -3302,6 +3928,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -3383,6 +4016,16 @@ "node": ">=4" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -3439,6 +4082,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -3843,6 +4496,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3913,6 +4579,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4051,6 +4727,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -4163,6 +4846,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4522,6 +5256,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4540,6 +5281,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4571,6 +5322,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -4678,6 +5436,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -5006,6 +5774,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5049,6 +5830,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5141,6 +5939,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -5191,6 +6004,16 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -5276,6 +6099,13 @@ "react": "^19.2.7" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5371,6 +6201,20 @@ "node": ">= 4" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -5537,6 +6381,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5742,6 +6599,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -5778,6 +6642,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", @@ -5793,6 +6664,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -5911,6 +6789,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/systeminformation": { "version": "5.31.11", "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.11.tgz", @@ -5972,6 +6890,20 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -5988,6 +6920,56 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.5.tgz", + "integrity": "sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.5" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.5.tgz", + "integrity": "sha512-pGrwzZDvPwKe+7NNUqAunb6rqTfynr0VOUhCMdqbu5xlvNiszsAJygRzwvpVycdzejlbpY+SWJOn+s75Og7FEA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6009,6 +6991,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-morph": { "version": "26.0.0", "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", @@ -6312,6 +7320,150 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -6327,6 +7479,23 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -6349,6 +7518,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/app/package.json b/app/package.json index 63c8d56..18ab37e 100644 --- a/app/package.json +++ b/app/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@base-ui/react": "^1.6.0", @@ -32,10 +34,16 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.0.0", + "jsdom": "^29.1.1", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^3.2.6" } } diff --git a/app/src/components/CiPanel.test.tsx b/app/src/components/CiPanel.test.tsx new file mode 100644 index 0000000..0178e78 --- /dev/null +++ b/app/src/components/CiPanel.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, waitFor, act } from "@testing-library/react"; +import { + mockInvoke, + emitEvent, + listenerCount, +} from "../test/tauri-mock"; +import { makeCheck } from "../test/fixtures"; + +// The panel talks to Tauri via the store (`invoke`) and directly via `listen`. +vi.mock("@tauri-apps/api/core", async () => { + const mock = await import("../test/tauri-mock"); + return { invoke: mock.invoke }; +}); +vi.mock("@tauri-apps/api/event", async () => { + const mock = await import("../test/tauri-mock"); + return { listen: mock.listen }; +}); +// openExternal routes through the opener plugin; stub it so no real IPC fires. +vi.mock("@tauri-apps/plugin-opener", () => ({ + openUrl: vi.fn(() => Promise.resolve()), +})); + +const { CiPanel } = await import("./CiPanel"); +const { useAppStore } = await import("../store"); + +const pristine = useAppStore.getState(); + +beforeEach(() => { + useAppStore.setState(pristine, true); +}); + +describe("CiPanel", () => { + it("renders the empty state when the PR has no checks", async () => { + mockInvoke("list_ci_checks", () => []); + + render(); + + expect(await screen.findByText("No CI checks")).toBeInTheDocument(); + }); + + it("groups checks into workflow pipelines after loading", async () => { + mockInvoke("list_ci_checks", () => [ + makeCheck({ name: "build", workflow: "CI", bucket: "pass" }), + makeCheck({ name: "e2e", workflow: "Nightly", bucket: "pass" }), + ]); + + render(); + + // Two distinct workflows -> two pipeline section headers. + expect(await screen.findByText("CI")).toBeInTheDocument(); + expect(screen.getByText("Nightly")).toBeInTheDocument(); + }); + + it("updates live from a matching ci-updated event", async () => { + mockInvoke("list_ci_checks", () => []); + + render(); + expect(await screen.findByText("No CI checks")).toBeInTheDocument(); + + // Backend pushes a fresh checks list for this PR. + act(() => { + emitEvent("ci-updated", [ + "https://gh/pr/1", + [makeCheck({ name: "lint", workflow: "Lint", bucket: "pass" })], + ]); + }); + + expect(await screen.findByText("Lint")).toBeInTheDocument(); + expect(screen.queryByText("No CI checks")).not.toBeInTheDocument(); + }); + + it("ignores ci-updated events for a different PR", async () => { + mockInvoke("list_ci_checks", () => []); + + render(); + expect(await screen.findByText("No CI checks")).toBeInTheDocument(); + + act(() => { + emitEvent("ci-updated", [ + "https://gh/pr/OTHER", + [makeCheck({ name: "lint", workflow: "Lint" })], + ]); + }); + + // Still empty: the event was for another PR. + expect(screen.getByText("No CI checks")).toBeInTheDocument(); + expect(screen.queryByText("Lint")).not.toBeInTheDocument(); + }); + + it("unsubscribes from ci-updated on unmount", async () => { + mockInvoke("list_ci_checks", () => []); + + const { unmount } = render( + , + ); + await screen.findByText("No CI checks"); + expect(listenerCount("ci-updated")).toBe(1); + + unmount(); + await waitFor(() => { + expect(listenerCount("ci-updated")).toBe(0); + }); + }); +}); diff --git a/app/src/components/ReviewCard.test.tsx b/app/src/components/ReviewCard.test.tsx new file mode 100644 index 0000000..3c9ce91 --- /dev/null +++ b/app/src/components/ReviewCard.test.tsx @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ReviewCard } from "./ReviewCard"; +import { makeReview, makeAgentRun, ALL_GATE_STATES } from "../test/fixtures"; + +describe("ReviewCard", () => { + it("shows the Restack button only when the review is stale", () => { + const fresh = makeReview({ stale: false }); + const { rerender } = render( + , + ); + expect( + screen.queryByRole("button", { name: /Restack/ }), + ).not.toBeInTheDocument(); + + const stale = makeReview({ stale: true }); + rerender( + , + ); + expect( + screen.getByRole("button", { name: /Restack/ }), + ).toBeInTheDocument(); + }); + + it("disables Restack and shows 'Restacking…' while an agent is active", () => { + const restacking = makeReview({ + stale: true, + agent: makeAgentRun({ mode: "Restack" }), + }); + render( + , + ); + const btn = screen.getByRole("button", { name: /Restacking/ }); + expect(btn).toBeDisabled(); + }); + + it("fires onRestack with the PR ref when clicked", async () => { + const user = userEvent.setup(); + const onRestack = vi.fn<(pr: string) => void>(); + const stale = makeReview({ pr: "pr-xyz", stale: true, agent: null }); + render( + , + ); + + await user.click(screen.getByRole("button", { name: /Restack/ })); + expect(onRestack).toHaveBeenCalledWith("pr-xyz"); + }); + + it("renders a context-aware primary action for every gate state", () => { + // Exercising the valid GateState union proves the assertNever default is + // unreachable without forcing an invalid value. + const labels: Record<(typeof ALL_GATE_STATES)[number], string> = { + Pending: "Review", + InReview: "Continue", + Dispatched: "Waiting…", + Reworked: "Re-review", + Approved: "View", + }; + for (const state of ALL_GATE_STATES) { + const { unmount } = render( + , + ); + expect( + screen.getByRole("button", { name: labels[state] }), + ).toBeInTheDocument(); + unmount(); + } + }); +}); diff --git a/app/src/components/StateFilter.test.tsx b/app/src/components/StateFilter.test.tsx new file mode 100644 index 0000000..015d9dd --- /dev/null +++ b/app/src/components/StateFilter.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { GateState } from "../bindings/GateState"; +import { StateFilter } from "./StateFilter"; +import { makeReview } from "../test/fixtures"; + +describe("StateFilter", () => { + const reviews = [ + makeReview({ pr: "a", gate_state: "InReview" }), + makeReview({ pr: "b", gate_state: "InReview" }), + makeReview({ pr: "c", gate_state: "Approved" }), + makeReview({ pr: "d", gate_state: "Dispatched", stale: true }), + ]; + + it("renders per-state counts and the total", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "All (4)" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "In Review (2)" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Approved (1)" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Pending (0)" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Stale (1)" }), + ).toBeInTheDocument(); + }); + + it("invokes onFilterChange with the chosen gate state", async () => { + const user = userEvent.setup(); + const onFilterChange = vi.fn<(s: GateState | null) => void>(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "In Review (2)" })); + expect(onFilterChange).toHaveBeenCalledWith("InReview"); + + await user.click(screen.getByRole("button", { name: "All (4)" })); + expect(onFilterChange).toHaveBeenCalledWith(null); + }); + + it("invokes onToggleStale when the stale chip is clicked", async () => { + const user = userEvent.setup(); + const onToggleStale = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Stale (1)" })); + expect(onToggleStale).toHaveBeenCalledOnce(); + }); + + it("renders zero counts for an empty review list", () => { + render( + , + ); + expect(screen.getByRole("button", { name: "All (0)" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Stale (0)" }), + ).toBeInTheDocument(); + }); +}); diff --git a/app/src/lib/ci.test.ts b/app/src/lib/ci.test.ts new file mode 100644 index 0000000..8f9606f --- /dev/null +++ b/app/src/lib/ci.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from "vitest"; +import type { CiCheck } from "../bindings/CiCheck"; +import { + checkOutcome, + summarizeChecks, + ciState, + parseCiUpdate, +} from "./ci"; + +/** Build a CiCheck with sane defaults; override the fields under test. */ +function check(overrides: Partial): CiCheck { + return { + name: "build", + state: "SUCCESS", + bucket: "pass", + link: "https://github.com/o/r/runs/1", + workflow: "CI", + ...overrides, + }; +} + +describe("checkOutcome", () => { + it("classifies bucket 'pass' as pass", () => { + expect(checkOutcome(check({ bucket: "pass" }))).toBe("pass"); + }); + + it("classifies bucket 'fail' as fail", () => { + expect(checkOutcome(check({ bucket: "fail" }))).toBe("fail"); + }); + + it("classifies bucket 'pending' as pending", () => { + expect(checkOutcome(check({ bucket: "pending" }))).toBe("pending"); + }); + + it("treats neutral/skipped/cancelled as pass", () => { + for (const signal of ["neutral", "skipped", "cancelled", "canceled"]) { + expect(checkOutcome(check({ bucket: signal }))).toBe("pass"); + } + }); + + it("falls back to raw state when bucket is empty", () => { + // Empty bucket means "use `state`": SUCCESS -> pass, FAILURE -> fail. + expect(checkOutcome(check({ bucket: "", state: "SUCCESS" }))).toBe("pass"); + expect(checkOutcome(check({ bucket: "", state: "FAILURE" }))).toBe("fail"); + expect(checkOutcome(check({ bucket: "", state: "timed_out" }))).toBe( + "fail", + ); + }); + + it("is case-insensitive on the signal", () => { + expect(checkOutcome(check({ bucket: "PASS" }))).toBe("pass"); + expect(checkOutcome(check({ bucket: "", state: "Failure" }))).toBe("fail"); + }); + + it("maps unknown signals to pending", () => { + expect(checkOutcome(check({ bucket: "in_progress" }))).toBe("pending"); + expect(checkOutcome(check({ bucket: "", state: "queued" }))).toBe( + "pending", + ); + }); +}); + +describe("summarizeChecks", () => { + it("returns all-zero for empty input", () => { + expect(summarizeChecks([])).toEqual({ + passed: 0, + failed: 0, + pending: 0, + total: 0, + }); + }); + + it("rolls up a mixed set counting skipped as passed", () => { + const summary = summarizeChecks([ + check({ bucket: "pass" }), + check({ bucket: "skipped" }), // counts as passed + check({ bucket: "fail" }), + check({ bucket: "pending" }), + check({ bucket: "queued" }), // unknown -> pending + ]); + expect(summary).toEqual({ passed: 2, failed: 1, pending: 2, total: 5 }); + }); +}); + +describe("ciState", () => { + it("is 'none' when there are no checks", () => { + expect(ciState({ passed: 0, failed: 0, pending: 0, total: 0 })).toBe( + "none", + ); + }); + + it("prioritizes fail over pending", () => { + expect(ciState({ passed: 1, failed: 1, pending: 3, total: 5 })).toBe( + "fail", + ); + }); + + it("is 'pending' when only pending remains", () => { + expect(ciState({ passed: 2, failed: 0, pending: 1, total: 3 })).toBe( + "pending", + ); + }); + + it("is 'pass' when everything passed", () => { + expect(ciState({ passed: 3, failed: 0, pending: 0, total: 3 })).toBe( + "pass", + ); + }); +}); + +describe("parseCiUpdate", () => { + it("parses a well-formed [pr, checks] tuple", () => { + const c = check({ name: "lint" }); + const result = parseCiUpdate(["https://gh/pr/1", [c]]); + expect(result).not.toBeNull(); + // Real guard, not a cast: only proceed once we know it parsed. + if (result === null) throw new Error("expected a parsed update"); + expect(result.pr).toBe("https://gh/pr/1"); + expect(result.checks).toHaveLength(1); + const [only] = result.checks; + expect(only?.name).toBe("lint"); + }); + + it("rejects a payload that is not a 2-tuple", () => { + expect(parseCiUpdate(null)).toBeNull(); + expect(parseCiUpdate("nope")).toBeNull(); + expect(parseCiUpdate([])).toBeNull(); + expect(parseCiUpdate(["only-one"])).toBeNull(); + expect(parseCiUpdate(["pr", [], "extra"])).toBeNull(); + }); + + it("rejects when pr is not a string or checks is not an array", () => { + expect(parseCiUpdate([1, []])).toBeNull(); + expect(parseCiUpdate(["pr", "not-an-array"])).toBeNull(); + }); + + it("drops malformed checks but keeps well-formed ones", () => { + const good = check({ name: "good" }); + const result = parseCiUpdate([ + "pr", + [ + good, + { name: "missing-fields" }, // missing state/bucket/link/workflow + { name: 1, state: "S", bucket: "b", link: "l", workflow: "w" }, // wrong type + null, + "string-check", + ], + ]); + if (result === null) throw new Error("expected a parsed update"); + expect(result.checks).toHaveLength(1); + const [only] = result.checks; + expect(only?.name).toBe("good"); + }); +}); diff --git a/app/src/lib/shortcuts.test.tsx b/app/src/lib/shortcuts.test.tsx new file mode 100644 index 0000000..0e64816 --- /dev/null +++ b/app/src/lib/shortcuts.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { + SHORTCUTS, + shortcutById, + comboFor, + Kbd, + type ShortcutId, +} from "./shortcuts"; + +describe("shortcut registry", () => { + it("has a unique id per entry", () => { + const ids = SHORTCUTS.map((s) => s.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("has a unique combo per entry (no accidental collisions)", () => { + const combos = SHORTCUTS.map((s) => s.combo); + expect(new Set(combos).size).toBe(combos.length); + }); +}); + +describe("shortcutById", () => { + it("resolves a known id to its declaration", () => { + const found = shortcutById("command-palette"); + expect(found).toBeDefined(); + // Real narrowing, not a cast. + if (found === undefined) throw new Error("expected a shortcut"); + expect(found.combo).toBe("meta+k"); + expect(found.label).toBe("Command Palette"); + }); + + it("returns undefined for an id not in the registry", () => { + // Cast is confined to the test to feed a deliberately bogus id; the + // production API only accepts ShortcutId. + const bogus = "does-not-exist" as ShortcutId; + expect(shortcutById(bogus)).toBeUndefined(); + }); +}); + +describe("comboFor", () => { + it("returns the combo string for a known id", () => { + expect(comboFor("nav-settings")).toBe("meta+5"); + expect(comboFor("escape")).toBe("escape"); + }); +}); + +describe("Kbd", () => { + it("renders each combo token as a separate glyph", () => { + // navigator.platform in jsdom is not Mac, so meta -> 'Ctrl'. + render(); + const kbd = screen.getByText("Ctrl").closest("kbd"); + expect(kbd).not.toBeNull(); + expect(kbd).toHaveTextContent("Ctrl"); + expect(kbd).toHaveTextContent("1"); + }); + + it("renders single-key combos", () => { + render(); + expect(screen.getByText("Esc")).toBeInTheDocument(); + }); +}); diff --git a/app/src/store.test.ts b/app/src/store.test.ts new file mode 100644 index 0000000..6ed26ac --- /dev/null +++ b/app/src/store.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { Review } from "./bindings/Review"; +import type { CiCheck } from "./bindings/CiCheck"; +import { + mockInvoke, + mockInvokeReject, + callsFor, +} from "./test/tauri-mock"; +import { makeReview, makeCheck, makeAgentRun } from "./test/fixtures"; + +// Route the store's Tauri imports through the typed mock. The factory returns +// the singleton `invoke`/`listen` from the shared mock module so tests can +// register handlers and inspect calls. +vi.mock("@tauri-apps/api/core", async () => { + const mock = await import("./test/tauri-mock"); + return { invoke: mock.invoke }; +}); +vi.mock("@tauri-apps/api/event", async () => { + const mock = await import("./test/tauri-mock"); + return { listen: mock.listen }; +}); + +// Import after the mocks are registered. +const { useAppStore } = await import("./store"); + +// Snapshot the pristine store so each test starts clean (Zustand's store is a +// module singleton). +const pristine = useAppStore.getState(); + +beforeEach(() => { + useAppStore.setState(pristine, true); +}); + +describe("fetchReviews", () => { + it("populates reviews and clears loading on success", async () => { + const reviews: Review[] = [makeReview({ pr: "pr-a" })]; + mockInvoke("list_reviews", () => reviews); + + await useAppStore.getState().fetchReviews(); + + const state = useAppStore.getState(); + expect(state.reviews).toEqual(reviews); + expect(state.loading).toBe(false); + expect(state.error).toBeNull(); + }); + + it("sets error (non-fatal) and clears loading on rejection", async () => { + mockInvokeReject("list_reviews", "boom"); + + await useAppStore.getState().fetchReviews(); + + const state = useAppStore.getState(); + expect(state.error).toContain("boom"); + expect(state.loading).toBe(false); + expect(state.reviews).toEqual([]); + }); +}); + +describe("restackPr", () => { + it("replaces the review across every list and clears stale on success", async () => { + const stale = makeReview({ pr: "pr-1", stale: true }); + const restacked = makeReview({ pr: "pr-1", stale: false }); + useAppStore.setState({ + reviews: [stale], + frontier: [stale], + authoredPrs: [stale], + reviewRequests: [], + activeReview: stale, + }); + mockInvoke("restack_pr", (args) => { + expect(args.pr).toBe("pr-1"); + return restacked; + }); + + await useAppStore.getState().restackPr("pr-1"); + + const state = useAppStore.getState(); + expect(state.reviews[0]?.stale).toBe(false); + expect(state.frontier[0]?.stale).toBe(false); + expect(state.authoredPrs[0]?.stale).toBe(false); + expect(state.activeReview?.stale).toBe(false); + expect(state.error).toBeNull(); + }); + + it("keeps a conflict-resolver agent on the returned review", async () => { + const stale = makeReview({ pr: "pr-1", stale: true, agent: null }); + const conflicted = makeReview({ + pr: "pr-1", + stale: true, + agent: makeAgentRun({ mode: "Restack" }), + }); + useAppStore.setState({ reviews: [stale] }); + mockInvoke("restack_pr", () => conflicted); + + await useAppStore.getState().restackPr("pr-1"); + + // Real narrowing on the nullable agent union, not a cast. + const agent = useAppStore.getState().reviews[0]?.agent; + expect(agent).not.toBeNull(); + if (agent == null) throw new Error("expected an active agent"); + expect(agent.mode).toBe("Restack"); + }); + + it("sets error and does not throw when the backend rejects", async () => { + const stale = makeReview({ pr: "pr-1", stale: true }); + useAppStore.setState({ reviews: [stale] }); + mockInvokeReject("restack_pr", "rebase exploded"); + + await useAppStore.getState().restackPr("pr-1"); + + const state = useAppStore.getState(); + expect(state.error).toContain("rebase exploded"); + // Non-fatal: the loop is not blocked, the stale review is untouched. + expect(state.reviews[0]?.stale).toBe(true); + }); +}); + +describe("listCiChecks", () => { + it("returns the checks from the backend", async () => { + const checks: CiCheck[] = [makeCheck({ name: "lint" })]; + mockInvoke("list_ci_checks", () => checks); + + const result = await useAppStore.getState().listCiChecks("pr-1"); + + expect(result).toEqual(checks); + expect(callsFor("list_ci_checks")[0]?.args.pr).toBe("pr-1"); + }); + + it("returns [] and never throws on gh error (Invariant 1)", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + mockInvokeReject("list_ci_checks", "gh not found"); + + const result = await useAppStore.getState().listCiChecks("pr-1"); + + expect(result).toEqual([]); + // Non-fatal: a CI query must not set the blocking store error. + expect(useAppStore.getState().error).toBeNull(); + spy.mockRestore(); + }); +}); + +describe("fixCi", () => { + it("updates activeReview + lists to the Dispatched review on success", async () => { + const before = makeReview({ pr: "pr-1", gate_state: "InReview" }); + const dispatched = makeReview({ + pr: "pr-1", + gate_state: "Dispatched", + agent: makeAgentRun({ mode: "Fix" }), + }); + useAppStore.setState({ + activeReview: before, + reviews: [before], + frontier: [before], + }); + mockInvoke("fix_ci", (args) => { + expect(args.pr).toBe("pr-1"); + return dispatched; + }); + + await useAppStore.getState().fixCi("pr-1"); + + const state = useAppStore.getState(); + expect(state.activeReview?.gate_state).toBe("Dispatched"); + expect(state.reviews[0]?.gate_state).toBe("Dispatched"); + expect(state.frontier[0]?.gate_state).toBe("Dispatched"); + expect(state.error).toBeNull(); + }); + + it("sets error (non-fatal) when dispatch rejects", async () => { + const before = makeReview({ pr: "pr-1", gate_state: "InReview" }); + useAppStore.setState({ activeReview: before, reviews: [before] }); + mockInvokeReject("fix_ci", "spawn failed"); + + await useAppStore.getState().fixCi("pr-1"); + + const state = useAppStore.getState(); + expect(state.error).toContain("spawn failed"); + expect(state.activeReview?.gate_state).toBe("InReview"); + }); +}); + +describe("requestChanges", () => { + it("no-ops when not on the diff view", async () => { + useAppStore.setState({ view: { kind: "prs" } }); + mockInvokeReject("request_changes", "should not be called"); + + await useAppStore.getState().requestChanges(); + + expect(callsFor("request_changes")).toHaveLength(0); + expect(useAppStore.getState().error).toBeNull(); + }); + + it("transitions the active review when on the diff view", async () => { + const before = makeReview({ pr: "pr-1", gate_state: "InReview" }); + const dispatched = makeReview({ pr: "pr-1", gate_state: "Dispatched" }); + useAppStore.setState({ + view: { kind: "diff", pr: "pr-1" }, + activeReview: before, + reviews: [before], + }); + mockInvoke("request_changes", () => dispatched); + + await useAppStore.getState().requestChanges(); + + expect(useAppStore.getState().activeReview?.gate_state).toBe("Dispatched"); + }); +}); diff --git a/app/src/test/fixtures.ts b/app/src/test/fixtures.ts new file mode 100644 index 0000000..9eb1ad1 --- /dev/null +++ b/app/src/test/fixtures.ts @@ -0,0 +1,72 @@ +/** + * Fully-typed fixtures for the domain types crossing the IPC boundary. Built + * against the generated `bindings/*` types so a schema change breaks the tests + * at compile time (the point of ts-rs codegen). Each factory takes a + * `Partial` of overrides so a test states only the fields it cares about. + */ +import type { Review } from "../bindings/Review"; +import type { GateState } from "../bindings/GateState"; +import type { CiCheck } from "../bindings/CiCheck"; +import type { AgentRun } from "../bindings/AgentRun"; + +/** A minimal running agent, for the stale/agent-active edges. */ +export function makeAgentRun(overrides: Partial = {}): AgentRun { + return { + pid: 4242, + mode: "Fix", + started_at: { secs_since_epoch: 1_700_000_000, nanos_since_epoch: 0 }, + prompt_hash: "deadbeef", + log_path: "/tmp/agent.log", + ...overrides, + }; +} + +/** + * A valid `Review`. `pr`/`id`/`issue` are branded newtypes in Rust but plain + * strings on the wire; the fixture supplies concrete strings and the factory + * signature keeps them typed as `Review`. + */ +export function makeReview(overrides: Partial = {}): Review { + const base: Review = { + id: "rev-1", + issue: "NEX-1", + pr: "https://github.com/o/r/pull/1", + branch: "alejandro/nex-1-do-thing", + base: "main", + base_sha: "abc123", + source: "Authored", + worktree: "/tmp/wt/rev-1", + gate_state: "InReview", + diff: { raw: "" }, + head_sha: "def456", + comments: [], + parents: [], + children: [], + stale: false, + agent: null, + repo_slug: "o/r", + project: null, + }; + return { ...base, ...overrides }; +} + +/** A valid `CiCheck` with pass defaults. */ +export function makeCheck(overrides: Partial = {}): CiCheck { + return { + name: "build", + state: "SUCCESS", + bucket: "pass", + link: "https://github.com/o/r/runs/1", + workflow: "CI", + ...overrides, + }; +} + +/** The five gate states, for exhaustive iteration in tests. */ +export const ALL_GATE_STATES = [ + "Pending", + "InReview", + "Dispatched", + "Reworked", + "Approved", +] as const satisfies readonly GateState[]; diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts new file mode 100644 index 0000000..68795af --- /dev/null +++ b/app/src/test/setup.ts @@ -0,0 +1,16 @@ +/** + * Vitest global setup. Registered via `test.setupFiles` in `vite.config.ts`. + * + * Adds jest-dom matchers (`toBeInTheDocument`, `toHaveTextContent`, …) and + * resets the shared Tauri IPC mocks between tests so state never leaks across + * cases. + */ +import "@testing-library/jest-dom/vitest"; +import { afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; +import { resetTauriMocks } from "./tauri-mock"; + +afterEach(() => { + cleanup(); + resetTauriMocks(); +}); diff --git a/app/src/test/tauri-mock.ts b/app/src/test/tauri-mock.ts new file mode 100644 index 0000000..e709e77 --- /dev/null +++ b/app/src/test/tauri-mock.ts @@ -0,0 +1,116 @@ +/** + * Typed test doubles for the Tauri IPC surface used by the store and + * components: `@tauri-apps/api/core`'s `invoke` and `@tauri-apps/api/event`'s + * `listen`. + * + * Tests register per-command handlers with {@link mockInvoke} instead of + * asserting against a bare `vi.fn()`, so each test states exactly what the + * backend would return (or reject with) for the commands it exercises. No + * `any`: handlers are typed `(args) => unknown`, and callers cast the awaited + * result at the call site where the command's return type is known. + * + * The `vi.mock` factories below live in this module so a single + * `vi.mock("@tauri-apps/api/core", …)` in each test file can delegate here. + */ +import { vi } from "vitest"; + +/** A single command handler: receives the invoke args, returns/throws a value. */ +export type InvokeHandler = (args: Record) => unknown; + +/** A registered event listener callback (mirrors Tauri's `EventCallback`). */ +type EventCallback = (event: { readonly payload: unknown }) => void; + +/** Command name -> handler registry, consulted by the mocked `invoke`. */ +const handlers = new Map(); + +/** Event name -> registered listener callbacks, driven by {@link emitEvent}. */ +const listeners = new Map>(); + +/** Ordered record of every `invoke` call, for asserting call args. */ +export interface InvokeCall { + readonly cmd: string; + readonly args: Record; +} +const calls: InvokeCall[] = []; + +/** Register (or replace) the handler for a single command. */ +export function mockInvoke(cmd: string, handler: InvokeHandler): void { + handlers.set(cmd, handler); +} + +/** + * Register a command whose handler rejects, to exercise error paths (the store + * treats a rejected `invoke` as non-fatal and sets `error`). + */ +export function mockInvokeReject(cmd: string, reason: unknown): void { + handlers.set(cmd, () => { + throw reason instanceof Error ? reason : new Error(String(reason)); + }); +} + +/** Every recorded `invoke` call in order. */ +export function invokeCalls(): readonly InvokeCall[] { + return calls; +} + +/** The recorded calls for a single command, in order. */ +export function callsFor(cmd: string): readonly InvokeCall[] { + return calls.filter((c) => c.cmd === cmd); +} + +/** Fire a Tauri event to every listener registered for `name`. */ +export function emitEvent(name: string, payload: unknown): void { + const set = listeners.get(name); + if (set === undefined) return; + for (const cb of set) cb({ payload }); +} + +/** Number of live listeners for an event (asserts unlisten cleanup). */ +export function listenerCount(name: string): number { + return listeners.get(name)?.size ?? 0; +} + +/** Clear all handlers, listeners, and recorded calls. Runs in `afterEach`. */ +export function resetTauriMocks(): void { + handlers.clear(); + listeners.clear(); + calls.length = 0; +} + +/** + * The mocked `invoke`. Looks up the registered handler; an unregistered command + * rejects loudly so tests never silently pass against a missing stub. + */ +export const invoke = vi.fn( + (cmd: string, args?: Record): Promise => { + const resolvedArgs = args ?? {}; + calls.push({ cmd, args: resolvedArgs }); + const handler = handlers.get(cmd); + if (handler === undefined) { + return Promise.reject( + new Error(`no mock registered for invoke("${cmd}")`), + ); + } + try { + return Promise.resolve(handler(resolvedArgs)); + } catch (e: unknown) { + return Promise.reject(e instanceof Error ? e : new Error(String(e))); + } + }, +); + +/** + * The mocked `listen`. Records the callback and returns a Promise of an + * unlisten function that removes it, mirroring Tauri's contract so components + * can be asserted to clean up on unmount. + */ +export const listen = vi.fn( + (name: string, cb: EventCallback): Promise<() => void> => { + const set = listeners.get(name) ?? new Set(); + set.add(cb); + listeners.set(name, set); + return Promise.resolve(() => { + set.delete(cb); + }); + }, +); diff --git a/app/vite.config.ts b/app/vite.config.ts index 566d75e..f9256cd 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -1,5 +1,5 @@ import path from "path"; -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; @@ -15,4 +15,11 @@ export default defineConfig({ port: 5173, strictPort: true, }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup.ts"], + css: false, + include: ["src/**/*.test.{ts,tsx}"], + }, });