From e3f98253ccd2134616fe27c63a419338648dcc21 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 20:28:11 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20Quality=20plugin=20=E2=80=94=20Task=20Q?= =?UTF-8?q?A=20tab,=20preview=20servers,=20tests,=20and=20suggested=20case?= =?UTF-8?q?s=20(#2127)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a bundled **Quality** plugin (`fusion-plugin-quality`) that makes task QA easier and more visual: - **Task QA tab** (action-first): preview/test server for the task worktree, allowlisted test runs, report viewer, screenshots CTA, suggested test cases, CI handoff - **Quality hub** (left sidebar): project-wide run history and preset launches - Host **task-detail slot context** (`taskId`, worktree, `projectId`) so plugin tabs can scope correctly - `superviseSpawn` re-exported on the plugin packaging shim for published plugins - Plan: `docs/plans/2026-07-14-001-feat-quality-plugin-plan.md` ## Design constraints - Does **not** replace the merge gate — advisory orchestration only - Composes Dev Server process patterns and artifact registry (no second browser stack) - Never free-form shell; never port 4040 - Full-suite requires explicit confirm ## Test plan - [x] `pnpm --filter @fusion-plugin-examples/quality test` (15 tests) - [x] PluginSlot unit tests still pass - [ ] Enable Quality plugin in dashboard Settings → Built-in Plugins - [ ] Open Task Detail → **QA** tab with a worktree; start preview, run verify:fast, generate suggestions - [ ] Open left sidebar **Quality** hub and list runs - [ ] Confirm merge gate / PR checks unchanged ## Residual / follow-up (same plan, later units) - Deeper hub CI (host route) - Full browser-verification toggle UX + agent QA sessions (U7/U9/U10) - Richer screenshots gallery wiring to live artifacts API - Test plans CRUD polish ## Summary by CodeRabbit * **New Features** * Added the Quality plugin with a project Quality hub and task-focused QA tab. * Added test runs, reports, preview server controls, suggested test cases, and run history. * Added configurable test presets, cancellation, status tracking, and safe command execution. * Added experimental-feature controls for enabling Quality functionality. * Bundled Quality with the CLI and made it available through the plugin manager. * **Documentation** * Added Quality plugin guidance, terminology, configuration details, and implementation planning documentation. * **Bug Fixes** * Improved process supervision so command failures and shutdown timers are handled safely. --- .changeset/friendly-shims-fix.md | 4 +- .changeset/quality-plugin-mvp.md | 7 + .changeset/safe-plugin-spawn.md | 7 + CONCEPTS.md | 18 + ...2026-07-14-001-feat-quality-plugin-plan.md | 939 ++++++++++++++++++ .../plugin-sdk-core-runtime-shim.test.ts | 62 ++ .../src/__tests__/plugin-sdk-export.test.ts | 3 +- .../cli/src/plugin-sdk-core-runtime-shim.mjs | 83 +- .../src/plugins/staged-bundled-plugin-ids.ts | 1 + packages/cli/tsup.config.ts | 8 + .../postgres/plugin-schema-hook.test.ts | 11 +- .../src/plugins/bundled-plugin-install.ts | 1 + .../app/components/PluginManager.tsx | 12 + .../dashboard/app/components/PluginSlot.tsx | 40 +- .../app/components/SettingsModal.tsx | 1 + .../app/components/TaskDetailModal.tsx | 14 + .../app/plugins/pluginSlotRegistry.tsx | 46 +- .../app/plugins/registerBundledPluginViews.ts | 23 + packages/dashboard/package.json | 3 +- packages/dashboard/src/routes.ts | 1 + packages/dashboard/vite.config.ts | 12 + packages/dashboard/vitest.config.ts | 12 + plugins/fusion-plugin-quality/README.md | 25 + plugins/fusion-plugin-quality/manifest.json | 25 + plugins/fusion-plugin-quality/package.json | 40 + .../src/__tests__/cancel-and-plans.test.ts | 84 ++ .../src/__tests__/command-presets.test.ts | 64 ++ .../src/__tests__/heuristic-cases.test.ts | 26 + .../src/__tests__/manifest.test.ts | 17 + .../src/__tests__/preview-sessions.test.ts | 33 + .../src/__tests__/quality-store.test.ts | 89 ++ .../src/dashboard-view.tsx | 142 +++ plugins/fusion-plugin-quality/src/index.ts | 56 ++ .../src/preview/preview-sessions.ts | 204 ++++ plugins/fusion-plugin-quality/src/qa-tab.tsx | 428 ++++++++ .../src/quality-schema.ts | 121 +++ .../src/routes/create-routes.ts | 415 ++++++++ .../src/runner/command-presets.ts | 105 ++ .../src/runner/command-runner.ts | 154 +++ plugins/fusion-plugin-quality/src/settings.ts | 53 + .../src/store/quality-store.ts | 364 +++++++ .../src/store/quality-types.ts | 97 ++ .../src/suggestions/heuristic-cases.ts | 92 ++ plugins/fusion-plugin-quality/tsconfig.json | 11 + .../fusion-plugin-quality/vitest.config.ts | 42 + pnpm-lock.yaml | 234 +---- pnpm-workspace.yaml | 1 + 47 files changed, 4028 insertions(+), 202 deletions(-) create mode 100644 .changeset/quality-plugin-mvp.md create mode 100644 .changeset/safe-plugin-spawn.md create mode 100644 docs/plans/2026-07-14-001-feat-quality-plugin-plan.md create mode 100644 packages/cli/src/__tests__/plugin-sdk-core-runtime-shim.test.ts create mode 100644 plugins/fusion-plugin-quality/README.md create mode 100644 plugins/fusion-plugin-quality/manifest.json create mode 100644 plugins/fusion-plugin-quality/package.json create mode 100644 plugins/fusion-plugin-quality/src/__tests__/cancel-and-plans.test.ts create mode 100644 plugins/fusion-plugin-quality/src/__tests__/command-presets.test.ts create mode 100644 plugins/fusion-plugin-quality/src/__tests__/heuristic-cases.test.ts create mode 100644 plugins/fusion-plugin-quality/src/__tests__/manifest.test.ts create mode 100644 plugins/fusion-plugin-quality/src/__tests__/preview-sessions.test.ts create mode 100644 plugins/fusion-plugin-quality/src/__tests__/quality-store.test.ts create mode 100644 plugins/fusion-plugin-quality/src/dashboard-view.tsx create mode 100644 plugins/fusion-plugin-quality/src/index.ts create mode 100644 plugins/fusion-plugin-quality/src/preview/preview-sessions.ts create mode 100644 plugins/fusion-plugin-quality/src/qa-tab.tsx create mode 100644 plugins/fusion-plugin-quality/src/quality-schema.ts create mode 100644 plugins/fusion-plugin-quality/src/routes/create-routes.ts create mode 100644 plugins/fusion-plugin-quality/src/runner/command-presets.ts create mode 100644 plugins/fusion-plugin-quality/src/runner/command-runner.ts create mode 100644 plugins/fusion-plugin-quality/src/settings.ts create mode 100644 plugins/fusion-plugin-quality/src/store/quality-store.ts create mode 100644 plugins/fusion-plugin-quality/src/store/quality-types.ts create mode 100644 plugins/fusion-plugin-quality/src/suggestions/heuristic-cases.ts create mode 100644 plugins/fusion-plugin-quality/tsconfig.json create mode 100644 plugins/fusion-plugin-quality/vitest.config.ts diff --git a/.changeset/friendly-shims-fix.md b/.changeset/friendly-shims-fix.md index a1adefd194..50675047b6 100644 --- a/.changeset/friendly-shims-fix.md +++ b/.changeset/friendly-shims-fix.md @@ -2,6 +2,6 @@ "@runfusion/fusion": patch --- -summary: Fix clean-CI typechecking for bundled plugins that use PostgreSQL schemas. +summary: Fix clean-CI packaging for bundled Quality and PostgreSQL plugins. category: fix -dev: Bundle the core schema through a runtime-only shim instead of requiring an unbuilt core dist artifact. +dev: Use a runtime-only MJS core shim that bundles schema source and preserves Quality process supervision. diff --git a/.changeset/quality-plugin-mvp.md b/.changeset/quality-plugin-mvp.md new file mode 100644 index 0000000000..842dae4642 --- /dev/null +++ b/.changeset/quality-plugin-mvp.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add Quality plugin with Task QA tab for preview servers, test runs, reports, and suggested cases. +category: feature +dev: Bundled fusion-plugin-quality; host slot task context; superviseSpawn re-exported from plugin-sdk-core-runtime-shim. diff --git a/.changeset/safe-plugin-spawn.md b/.changeset/safe-plugin-spawn.md new file mode 100644 index 0000000000..dd180d4e99 --- /dev/null +++ b/.changeset/safe-plugin-spawn.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Prevent bundled plugin commands from delaying or crashing the Fusion CLI on spawn failures. +category: fix +dev: Absorbs child spawn errors and unrefs SIGKILL escalation timers in the plugin SDK runtime shim. diff --git a/CONCEPTS.md b/CONCEPTS.md index 1aabaa7088..48ace438e4 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -224,6 +224,24 @@ The server-derived `canInstall` flag for a Plugin Registry Entry. `canInstall: t ### Workflow Extension A plugin-contributed workflow capability registered through the engine rather than hardcoded into core workflow logic: column metadata, movement policies, column work engines, workflow node handlers, task verdict providers, or merge-routing facts. A Workflow Extension is opt-in by workflow metadata and must degrade or park by an explicit fallback policy when its plugin is disabled or missing, preserving the Default workflow baseline when no extension is active. +### Quality Hub +The project-wide Quality plugin dashboard destination (left sidebar on desktop; More sheet on mobile) for test dashboards, test plans, orchestrated test runs, and read-only CI status. Quality Hub runs are advisory visualization/orchestration — they do not redefine or replace the Merge Gate. + +### Test Plan (Quality) +A project-scoped, ordered list of allowlisted verification presets (and optional browser/agent QA references) owned by the Quality plugin. Distinct from agent planning sessions or mission planning artifacts; operators author and run these from the Quality Hub. + +### Test Run (Quality) +A single supervised execution of a Quality preset or plan step, with lifecycle status (queued → running → passed|failed|timed_out|cancelled|error), cwd policy, truncated logs, and optional task/plan linkage. Separate from engine `fn_run_verification` runs, though it may orchestrate the same underlying commands. + +### Task QA Tab +A task-detail plugin tab (`task-detail-tab`) for human-friendly task quality work: start a task-scoped preview/test server, run targeted tests and view reports, browse screenshots/visual evidence (task artifacts and design-preview), review suggested test cases, see PR check status, and hand off into browser-verification / agent QA. Complements the Quality Hub with worktree-local verification rather than replacing workflow review/merge. + +### Task Preview Server +A Quality/Dev-Server–backed process started for a single Task’s worktree so operators can exercise the change in a browser. Distinct from the project-level Dev Server view: same process safety rules (supervised spawn, free port, never 4040), but session identity and default cwd are task-scoped. + +### Suggested Test Cases (Quality) +An advisory checklist of what to verify for a Task, generated from the task prompt, file scope/modified files, and optional AI enrichment. Not a merge gate and not a substitute for automated tests; operators tick, copy, or run related targeted tests from the Task QA Tab. + ## Workflow columns & traits *Controlled by the default-on `experimentalFeatures.workflowColumns` flag. With an explicit flag-off override, the legacy fixed pipeline (the closed column enum + `VALID_TRANSITIONS`) is authoritative and unchanged.* diff --git a/docs/plans/2026-07-14-001-feat-quality-plugin-plan.md b/docs/plans/2026-07-14-001-feat-quality-plugin-plan.md new file mode 100644 index 0000000000..e27bf4ae37 --- /dev/null +++ b/docs/plans/2026-07-14-001-feat-quality-plugin-plan.md @@ -0,0 +1,939 @@ +--- +title: "feat: Fusion Quality (QA) plugin — hub, task tab, and agent QA flows" +type: feat +status: active +date: 2026-07-14 +origin: null +updated: 2026-07-14 +--- + +# feat: Fusion Quality (QA) plugin — hub, task tab, and agent QA flows + +## Summary + +Ship a bundled Fusion plugin that makes **task QA easy and visual**: a Task QA tab where operators start a **task-scoped preview/test server**, run targeted tests, see **screenshots of changes**, open **test reports**, and get **suggested test cases** — backed by a project Quality hub for history, plans, and CI. Compose existing Dev Server, artifacts, agent-browser, and verification seams; do not invent a second merge gate or browser stack. + +--- + +## Problem Frame + +Fusion already has strong quality *substrate* — thin merge gate (`pnpm test:gate`), `fn_run_verification`, project `testCommand` / `verify:fast`, built-in `browser-verification` + `fusion-plugin-agent-browser`, **project Dev Server** (preview URL detection), **artifact registry** (screenshots/images gallery), design-preview task docs, and PR check rollups — but operators still lack a **single task-level QA surface**. Doing real QA on a task means jumping between terminal, Dev Server view, Artifacts, workflow toggles, and PR checks. + +What operators actually want when “performing QA on a task”: + +1. **Start a test/preview server for this task’s worktree** and open the app. +2. **See screenshots of the change** (before/after, browser evidence, registered images). +3. **View test reports** from runs they just triggered (not only raw terminal logs). +4. **Suggested test cases** derived from the task prompt, file scope, and diff — so they know what to check. + +`ROADMAP.md` lists **QA interface** as planned work. The Quality plugin should make that path intuitive on the task, with a project hub as the secondary control plane. + +--- + +## Requirements + +### Surfaces and registration + +- R1. A bundled plugin (`fusion-plugin-quality`) appears as a left-sidebar **Quality** destination when installed and enabled (desktop primary nav; mobile under More). +- R12. Full bundled registration matrix is complete (workspace, dashboard package dependency, staged/auto-install IDs, tsup, Vite/Vitest aliases, `registerBundledPluginViews`, `pluginSlotRegistry` + slot context props, Plugin Manager card) so the hub and tab never ship as “unavailable” shells. +- R13. New UI affordances satisfy **Surface Enumeration** across desktop, mobile, empty/auth/missing-plugin states. + +### Hub runs and plans + +- R2. Quality hub shows a **test dashboard**: recent runs with lifecycle statuses `passed` | `failed` | `timed_out` | `cancelled` | `error` (plus in-flight `queued`/`running`), last command, duration (`startedAt`/`finishedAt` or `durationMs`), and links into logs. +- R3. Operators can **run tests** from the hub via an allowlisted preset set: `project-test` (settings `testCommand` / auto-detect), `test-gate` → `pnpm test:gate`, `verify-fast` → `pnpm verify:fast`, `file-scoped` when task/changed-files context exists, and `full-suite` only behind explicit confirm (never default) — never free-form shell as the default path. +- R4. Operators can **create, edit, archive, and run Test Plans** composed of ordered command-preset steps (optional browser-verification intent flag only; agent-QA start is not a plan step in this plan). + +### CI + +- R5. **Task QA** surfaces read-only PR check status by reusing host `GET /api/tasks/:id/pr/checks`. **Hub CI** is a thin read of open-task PR check rollups and/or default-branch summary via a **host route or `gh` CLI path** (not a direct plugin import of dashboard `GitHubClient`); no write actions to CI. + +### Task QA (primary product surface) + +- R6. Task detail gains a **QA tab** for the open task: preview server, targeted tests, reports, screenshots, suggested cases, PR checks, browser verification, and agent QA entry. Host must inject task context (`taskId`, worktree, `projectId`) into the slot component. +- R7. Browser verification **composes** `fusion-plugin-agent-browser` and the built-in `browser-verification` optional group — does not reimplement Playwright/driver. Enable/toggle lives on **Task QA** (hub may deep-link only). +- R8. Plugin contributes **advisory** workflow-step palette templates for targeted QA (`defaultOn: false`); they must not become the merge gate. +- R9. Long-running **agent-driven QA** sessions use detach + progress (Compound Engineering pattern), not blocking HTTP handlers; probe `createInteractiveAiSession` and empty-state when the engine factory is missing. +- R16. Operators can **start / stop / open a task-scoped preview (test) server** from Task QA with cwd = task worktree (required), command from project Dev Server config / package scripts (`dev`, `start`, etc.), free port (never 4040), and detected or manual preview URL. Compose existing Dev Server process/manager patterns rather than a second process subsystem when feasible. +- R17. Task QA shows a **Screenshots / visual evidence** gallery for the task: task-scoped image artifacts, browser-verification captures, and design-preview docs when present — with empty-state CTA (“capture with browser verification” / “register artifact”). Link into Artifacts view for full media. +- R18. After a test run, operators can **view a test report** for that run: status, duration, command, truncated logs, and optional structured summary when parsers exist (e.g. vitest JSON / junit if output path is known). History list of task-scoped reports is first-class in the tab. +- R19. Task QA offers **suggested test cases** derived from task title/PROMPT, File Scope / modified files, and optional diff summary — displayed as a checklist operators can tick, copy, or “run related tests.” Generation is advisory (AI session or deterministic heuristics); never blocks merge. +- R20. Primary Task QA layout is **action-first**: Preview server | Run tests | Screenshots | Reports | Suggested cases (then CI / browser / agent). Mobile uses stacked sections with the same Surface Enumeration rules. + +### Process, policy, storage + +- R10. Command runs and preview servers use supervised async process spawning available on the **published plugin packaging path** (extend `plugin-sdk-core-runtime-shim` to export `superviseSpawn` **or** a plugin-local supervised wrapper — decide in U3), with timeouts where applicable, cancel, and process-group cleanup — never `execSync`, never port 4040, never raw detached spawn. +- R11. Quality runs are **visualization/orchestration only**: they do not change merge eligibility, rewrite `testCommand`, or force `allowFullSuite`. +- R14. Plugin-owned schema stores plans, run history, suggested-case snapshots, and preview-session metadata with `projectId` isolation and retention caps. Runners fail-soft when the plugin is disabled mid-run. +- R15. Preset resolution never interpolates unsanitized user strings into a shell: allowlisted argv/command builders only; project settings / Dev Server commands treated as trusted operator config, not task/agent free text. + +--- + +## Scope Boundaries + +### In scope + +- Bundled workspace plugin package under `plugins/fusion-plugin-quality` +- **Task QA tab as primary UX** (action-first: preview server, tests, screenshots, reports, suggested cases) +- Quality hub (`dashboardViews` + host registry) for project history / plans / CI +- TestPlan / TestRun / SuggestedCases domain + plugin routes + dashboard UI +- Allowlisted command orchestration + progress + **report view** +- **Task-scoped preview/test server** composing Dev Server patterns (worktree cwd, preview URL, never 4040) +- **Screenshot / visual evidence** panel composing task artifacts + browser captures + design-preview docs +- **Suggested test cases** (heuristic + optional AI generation) +- Read-only CI: task PR checks reuse; hub thin rollup via host route or `gh` (KTD 5) +- Soft composition with agent-browser + built-in browser-verification group (Task QA) +- Advisory plugin `workflowSteps` + agent QA session entry (later units; after core Task QA) +- Host work: task-detail slot context injection + supervised-spawn packaging export +- Settings schema; docs; CONCEPTS; changeset when packaging ships + +### Out of scope + +- Replacing or widening the PR merge gate / inventing a second blocking suite +- Full suite discovery / coverage engines / flaky auto-quarantine product +- Multi-provider CI beyond GitHub read-only +- `task-card-badge` CI badges (host surface still Planned) +- New Playwright stack inside Quality (compose agent-browser) +- Making Quality plugin workflow steps merge-blocking by default +- Full-suite as a one-click default action +- Pixel-perfect visual regression product (Percy/Chromatic) — only gallery of existing captures +- Replacing the global Dev Server view (compose / deep-link; task-scoped start is additive) +- Desktop-native notifications product +- Releasing from a Fusion task (`pnpm release`) +- Direct plugin imports of private dashboard `GitHubClient` modules + +### Deferred to follow-up work + +- Project-wide GitHub Actions deep history / log streaming explorer +- Cross-project Quality portfolio view +- Automatic “attach to in-flight executor verification” process sharing +- Rich standalone HTML export of QA runs (could compose reports plugin) +- Fixing sibling footgun: `fusion-plugin-reports` missing host view registration +- Agent-QA start as a TestPlan step type +- Auto-capture before/after screenshots on every code change without operator/agent action + +--- + +## Key Technical Decisions + +1. **Ship as bundled first-party plugin `fusion-plugin-quality` (package `@fusion-plugin-examples/quality`), not core dashboard hardcoding.** Rationale: matches STRATEGY ecosystem track, reports/CE patterns, and ROADMAP “QA interface” without expanding core lifecycle. + +2. **Dual surface from day one: Quality hub + Task QA tab; workflows as palette contributions.** Rationale: confirmed scope; hub answers project-wide health, task tab answers worktree-local verification — both needed for “intuitive QA.” + +3. **Orchestrate allowlisted presets only — no suite discovery / coverage invent in v1.** Rationale: thin gate culture. Preset id → command map: `project-test` → settings `testCommand` (or merger auto-detect), `test-gate` → `pnpm test:gate`, `verify-fast` → `pnpm verify:fast`, `file-scoped` → vitest path list from `task.modifiedFiles` and/or parsed File Scope when non-empty else **disabled**, `full-suite` → explicit confirm only. Never free-form shell UI. + +4. **Compose `fusion-plugin-agent-browser` + built-in `browser-verification` group; soft dependency with setup CTA.** Rationale: browser stack already exists; re-shipping Playwright would duplicate setup/skills and diverge from engine preflight. Enable/toggle is Task QA–owned; hub deep-links. + +5. **GitHub CI is read-only with two access paths.** Task: browser/client calls host `GET /api/tasks/:id/pr/checks`. Hub: implement either (A) a host-owned read route that reuses dashboard GitHub auth/client, or (B) plugin-side `gh` CLI read with project auth empty-states — **do not** import `packages/dashboard/src/github.ts` into the plugin package. Prefer (A) when adding a small host route is acceptable. + +6. **Command runs: verification-like hard timeout + log tail + supervised spawn available on the packaged plugin path.** Extend `packages/cli/src/plugin-sdk-core-runtime-shim.ts` to re-export `superviseSpawn` (preferred) **or** vendor a plugin-local supervisor matching core semantics. Agent QA: CE detach + `onProgress` + inactivity watchdog. Never import `@fusion/engine` from the plugin. + +7. **cwd policy: hub = project root; task tab = task worktree if present, otherwise block with clear CTA (optional advanced “run on root”).** Rationale: silent root fallback produces false greens for unbuilt task branches. + +8. **Concurrency: at most one active project-scoped command run and one active run per task; 409 when busy.** Rationale: avoid thrashing shared resources; cancel reaps process groups. + +9. **All Quality workflow steps advisory (`defaultOn: false`); UX copy distinguishes advisory local results from merge gate.** Rationale: product promise — do not replace merge gate. + +10. **Static host registration is acceptance criteria, not polish.** Rationale: reports historically shipped dashboardViews without host registry → “unavailable”; registration-drift solution doc is mandatory checklist. + +11. **Plugin-owned SQLite/Postgres tables via `onSchemaInit` (reports pattern); every row carries `projectId`.** Rationale: isolation + hybrid storage; no core schema migration for Quality domain. + +12. **Server entry never re-exports React/CSS views** (`fusion/no-plugin-view-reexport`). Dashboard modules live in separate package exports (`./dashboard-view`, `./qa-tab`). + +13. **Host must inject task context into `task-detail-tab` slot components.** Extend `PluginSlot` / `PluginSlotComponentProps` with a typed bag (`taskId`, optional `worktree`, `projectId`, optional `onTaskUpdated`) and pass it from `TaskDetailModal` (and any other task-detail hosts). Without this, Task QA cannot scope runs or PR checks. + +14. **Ship slices inside this plan (acceptance gates), not only sequencing.** **MVP = Task QA usable for human QA:** U1 + U2 + U3 + U6 (action-first tab: targeted tests + reports) + **U11 task preview server** + **U12 screenshots gallery** + **U13 suggested cases** + task CI. Same plan continues with U4 (plans), hub CI, U7/U9/U10 (browser / workflow / agent QA). + +15. **Compose existing Dev Server and Artifacts substrates; do not fork process managers.** Task preview servers reuse `DevServerProcessManager` / route patterns (`packages/dashboard/src/dev-server-*.ts`) with **worktree cwd** and task-scoped session identity. Screenshots are task-filtered artifact registry + known task documents (`design-preview`), not a new media store — unless a thin Quality index is needed for browser-capture paths. + +16. **Suggested test cases are advisory checklists, not executable gates.** Generate from task PROMPT + file scope + optional diff; store snapshot on the task (document or Quality row). Operators tick/copy/run related tests; generation failures degrade to empty + “regenerate.” + +--- + +## High-Level Technical Design + +### Component topology + +```mermaid +flowchart TB + subgraph Dashboard + Sidebar[LeftSidebarNav] + Hub[QualityDashboardView] + TaskModal[TaskDetailModal] + QATab[QualityTaskTab] + SlotReg[pluginSlotRegistry] + ViewReg[registerBundledPluginViews] + end + + subgraph Plugin server + Routes["/api/plugins/fusion-plugin-quality/*"] + Store[QualityStore plans + runs] + Runner[Command runner superviseSpawn] + AgentQA[Agent QA session CE-style] + WF[workflowSteps palette] + end + + subgraph Existing substrate + Verify[fn_run_verification / testCommand] + Browser[fusion-plugin-agent-browser] + BV[browser-verification optional group] + GH[GitHubClient pr/checks] + Gate[Merge gate Lint/Typecheck/Build/Gate] + end + + Sidebar --> Hub + ViewReg --> Hub + TaskModal --> QATab + SlotReg --> QATab + Hub --> Routes + QATab --> Routes + Routes --> Store + Routes --> Runner + Routes --> AgentQA + Runner -.orchestrates.-> Verify + QATab --> BV + QATab --> Browser + QATab --> GH + Hub --> GH + WF --> BV + Runner -.->|never writes| Gate +``` + +### TestRun lifecycle + +```mermaid +stateDiagram-v2 + [*] --> queued + queued --> running + queued --> cancelled + queued --> error + running --> passed + running --> failed + running --> timed_out + running --> cancelled + running --> error + passed --> [*] + failed --> [*] + timed_out --> [*] + cancelled --> [*] + error --> [*] +``` + +### Surface → cwd → runner contract + +| Surface | cwd | Runner | Concurrency key | +|---------|-----|--------|-----------------| +| Quality hub command | project root | `superviseSpawn` + hard timeout | `project:{projectId}` | +| Task QA targeted tests | task worktree (required) | same | `task:{taskId}` | +| Agent QA session | project root (CE-style) unless session opts into worktree | interactive AI session detach+progress | `agent-qa:{taskId\|projectId}` | +| Workflow browser-verification | engine worktree path | existing executor group | workflow-owned | + +### Handoffs + +```mermaid +flowchart LR + Hub --> Plans[TestPlan] + Hub --> Runs[TestRun] + Hub --> CI[CI read] + TaskTab --> Runs + TaskTab --> Preview[Task preview server] + Preview --> DevSrv[Dev Server process patterns] + TaskTab --> Shots[Screenshots gallery] + Shots --> Artifacts[Artifact registry] + TaskTab --> Cases[Suggested test cases] + TaskTab --> Reports[Test report viewer] + Reports --> Runs + TaskTab --> EnableBV["enabledWorkflowSteps browser-verification"] + EnableBV --> Exec[Executor + agent-browser skill] + TaskTab --> PRChecks["GET /api/tasks/:id/pr/checks"] + Palette[Plugin workflowSteps] --> Graph[Workflow graph] + AgentQA --> Browser + Runs -.-> NoGate[Does not update merge eligibility] +``` + +### Task QA information architecture (operator mental model) + +```text +Task Detail → QA tab +├── Preview server [Start] [Stop] [Open URL] (worktree, free port ≠4040) +├── Run tests presets → report opens +├── Reports last N task runs (status, duration, logs, optional structured) +├── Screenshots task images + browser evidence + design-preview +├── Suggested cases checklist + Regenerate + copy / run related +├── CI checks PR rollup (host API) +├── Browser verification enable optional group + setup CTA +└── Agent QA start session (later unit) +``` + +--- + +## Output Structure + +```text +plugins/fusion-plugin-quality/ + package.json + manifest.json + README.md + vitest.config.ts + tsconfig.json + src/ + index.ts # definePlugin: routes, schema, settings, workflowSteps, dashboardViews, uiSlots metadata only + settings.ts + quality-schema.ts # onSchemaInit DDL + store/ + quality-store.ts + quality-types.ts + runner/ + command-presets.ts + command-runner.ts + preview/ # task-scoped preview server (compose Dev Server patterns) + suggestions/ # suggested test cases heuristics + optional AI + routes/ + plan-routes.ts + run-routes.ts + ci-routes.ts + preview-routes.ts + suggestions-routes.ts + agent-qa-routes.ts + workflow-steps.ts + dashboard-view.tsx # Quality hub export only + qa-tab.tsx # Task tab export only + dashboard/ + QualityView.tsx + task/ + PreviewSection.tsx + ReportsSection.tsx + ScreenshotsSection.tsx + SuggestedCasesSection.tsx + RunTestsSection.tsx + components/... + hooks/... + __tests__/ + manifest.test.ts + store.test.ts + command-presets.test.ts + command-runner.test.ts + preview.test.ts + suggestions.test.ts + routes.test.ts + workflow-steps.test.ts +``` + +Host touchpoints (existing files, not new tree): + +- `pnpm-workspace.yaml` +- `packages/core/src/plugins/bundled-plugin-install.ts` +- `packages/cli/src/plugins/staged-bundled-plugin-ids.ts` +- `packages/cli/tsup.config.ts` +- `packages/cli/src/plugin-sdk-core-runtime-shim.ts` (superviseSpawn export if KTD 6 option A) +- `packages/dashboard/package.json` (`@fusion-plugin-examples/quality` workspace dep) +- `packages/dashboard/src/routes.ts` (bundled id fallback) +- `packages/dashboard/app/components/PluginManager.tsx` +- `packages/dashboard/app/plugins/registerBundledPluginViews.ts` +- `packages/dashboard/app/plugins/pluginSlotRegistry.tsx` + slot prop types +- `packages/dashboard/app/components/PluginSlot.tsx` +- `packages/dashboard/app/components/TaskDetailModal.tsx` (pass task context into plugin tabs) +- `packages/dashboard/vite.config.ts` + `vitest.config.ts` aliases +- Optional host CI route module under `packages/dashboard/src/routes/` if KTD 5 option A +- `packages/desktop` package resolution for `@fusion-plugin-examples/quality` when required (mirror CE/graph) + +--- + +## Implementation Units + +### U1. Plugin scaffold, host registration, and task slot context + +**Goal:** Loadable, installable bundled plugin with empty Quality hub shell and QA tab shell that render real components (not “unavailable”), and **task-detail slots receive task context**. + +**Requirements:** R1, R6, R12, R13 + +**Dependencies:** None + +**Files:** +- `plugins/fusion-plugin-quality/**` (new package skeleton) +- `pnpm-workspace.yaml` +- `packages/core/src/plugins/bundled-plugin-install.ts` +- `packages/cli/src/plugins/staged-bundled-plugin-ids.ts` +- `packages/cli/tsup.config.ts` +- `packages/dashboard/package.json` +- `packages/dashboard/src/routes.ts` +- `packages/dashboard/app/components/PluginManager.tsx` +- `packages/dashboard/app/plugins/registerBundledPluginViews.ts` +- `packages/dashboard/app/plugins/pluginSlotRegistry.tsx` +- `packages/dashboard/app/components/PluginSlot.tsx` +- `packages/dashboard/app/components/TaskDetailModal.tsx` +- `packages/dashboard/vite.config.ts` +- `packages/dashboard/vitest.config.ts` +- `plugins/fusion-plugin-quality/src/__tests__/manifest.test.ts` +- `packages/dashboard/app/plugins/__tests__/registerBundledPluginViews.test.tsx` (extend) +- `packages/dashboard/app/components/__tests__/PluginSlot.test.tsx` (context bag) +- host tests proving `taskId` reaches the Quality slot component + +**Approach:** +- Mirror compound-engineering package layout: `definePlugin` with `dashboardViews` (`viewId: "quality"`, label Quality, `placement: "primary"`, lucide icon e.g. `ShieldCheck` or `FlaskConical`, `order` ~30–40) and `uiSlots` (`task-detail-tab`, label QA). +- Register lazy view + slot components with **literal** `import()` (no `@vite-ignore` for production paths). +- Grep every registration hit for `fusion-plugin-compound-engineering` including `packages/dashboard/package.json`; do not copy reports’ incomplete view registration. +- **Host contract (load-bearing):** extend slot component props with `{ taskId, worktree?, projectId, onTaskUpdated? }`; `TaskDetailModal` passes the open task; other task-detail hosts updated if they render plugin tabs. +- Empty shells: hub title + empty states; tab placeholder sections that can read `taskId` from props. + +**Patterns to follow:** `plugins/fusion-plugin-dependency-graph`, `plugins/fusion-plugin-compound-engineering`, registration-drift + vite-alias solution docs + +**Test scenarios:** +- Manifest id/name/version and `dashboardViews` / `uiSlots` shapes match host contracts. +- Host registry resolves `plugin:fusion-plugin-quality:quality` to a component (not missing shell). +- Slot registry resolves Quality task-detail-tab entry. +- Active task id is passed into the slot component (regression). +- Bundled plugin id is present in core `BUNDLED_PLUGIN_IDS` and staged id set. + +**Verification:** Plugin package builds; registry tests pass; Settings shows Built-in Quality card; enabling plugin shows sidebar Quality entry; Task Detail QA tab receives taskId. + +--- + +### U2. Quality domain schema, store, and read APIs + +**Goal:** Durable TestPlan and TestRun models with project isolation and retention hooks. + +**Requirements:** R2, R4, R14 + +**Dependencies:** U1 + +**Files:** +- `plugins/fusion-plugin-quality/src/quality-schema.ts` +- `plugins/fusion-plugin-quality/src/store/quality-types.ts` +- `plugins/fusion-plugin-quality/src/store/quality-store.ts` +- `plugins/fusion-plugin-quality/src/routes/plan-routes.ts` (list/get/create/update/archive) +- `plugins/fusion-plugin-quality/src/routes/run-routes.ts` (list/get only in this unit) +- `plugins/fusion-plugin-quality/src/__tests__/store.test.ts` +- `plugins/fusion-plugin-quality/src/__tests__/routes.test.ts` + +**Approach:** +- `onSchemaInit` idempotent DDL (reports pattern); support SQLite + Postgres async layer path if reports store does. +- **TestPlan (authoritative):** `id`, `projectId`, `name`, `status` (`draft`|`active`|`archived`), ordered steps as preset enum keys only, timestamps. No agent-QA step type in this plan. +- **TestRun (authoritative schema — U2 owns it):** `id`, `projectId`, `taskId?`, `planId?`, `source` (`hub`|`task-tab`|`workflow`|`agent-qa`), `presetId?`, `command`, `cwd`, `cwdKind` (`project-root`|`worktree`), `status` (`queued`|`running`|`passed`|`failed`|`timed_out`|`cancelled`|`error`), `exitCode?`, `errorMessage?`, `timeoutMs`, `startedAt`, `finishedAt?`, `durationMs?`, truncated `stdout`/`stderr` (or single log blob), `triggeredBy`, `progressFingerprint?`. +- Retention: cap last N runs per project (default 50) + truncate logs to N KB; prune on insert. +- Routes under `/api/plugins/fusion-plugin-quality/*` with project scoping from host context. + +**Patterns to follow:** `plugins/fusion-plugin-reports/src/report-schema.ts`, `store/report-store.ts` + +**Test scenarios:** +- Schema init is idempotent on second call. +- Create plan → list filters by projectId (no cross-project leak). +- Archive plan hides from default active list but remains gettable. +- Insert > retention cap prunes oldest finished runs. +- Invalid plan step key rejected. +- Backend/SQLite mode paths covered if dual-store pattern is used. + +**Verification:** Store unit tests green; GET list empty state works through route handlers with mocked context. + +--- + +### U3. Command presets, supervised runner, and hub run UX + +**Goal:** Operators can run allowlisted test/verification commands from the Quality hub with live status and history. + +**Requirements:** R2, R3, R10, R11, R14, R15 + +**Dependencies:** U2; packaging decision for supervised spawn (KTD 6) + +**Files:** +- `packages/cli/src/plugin-sdk-core-runtime-shim.ts` (if option A: export superviseSpawn) +- `plugins/fusion-plugin-quality/src/runner/command-presets.ts` +- `plugins/fusion-plugin-quality/src/runner/command-runner.ts` +- `plugins/fusion-plugin-quality/src/routes/run-routes.ts` (start/cancel/progress) +- `plugins/fusion-plugin-quality/src/dashboard/*` (run panel, history, presets) +- `plugins/fusion-plugin-quality/src/__tests__/command-presets.test.ts` +- `plugins/fusion-plugin-quality/src/__tests__/command-runner.test.ts` +- `plugins/fusion-plugin-quality/src/dashboard/__tests__/*` +- shim tests if packaging path changes + +**Approach:** +- **Prerequisite:** land KTD 6 so published `bundled.js` can spawn supervised children (prefer shim re-export). +- **Start-run body schema (security, R15):** only `{ preset, projectId, taskId?, confirmFullSuite?, planId? }` — **reject** client `command` / `argv` / `cwd` / `shell` overrides (400). Server resolves command and cwd only. +- **projectId required** on start/cancel/logs/get; no fallback to default task store for Quality runs; get-by-id must verify `row.projectId === request.projectId`. +- **cwd server-only:** hub → project root; task → `task.worktree` (validated task belongs to project); never accept client cwd. +- Preset id → command map (R3/KTD 3). `file-scoped`: server reads `task.modifiedFiles` / File Scope; empty → disable (never full `pnpm test`). Path tokens argv-safe; reject `..`, escapes outside worktree/root, shell metacharacters if shell mode used. +- Settings `testCommand` is trusted operator config for `project-test` only; still no client free-form shell. +- Start run: create `queued` → detach HTTP with `runId` → `running` via supervised spawn → `startedAt` → terminal + `finishedAt`/`durationMs`. Persist **resolved** command for audit; never re-exec client-supplied command. +- Timeouts: inherit `verificationCommandTimeoutMs` when set; hard ceiling ≤1800s; cancel/timeout SIGTERM then SIGKILL process group. +- Concurrency: one active project run; 409 with link to live run. +- Plugin disable mid-run: cancel children; mark `cancelled`/`error`. +- UX copy: “Advisory local run — does not change merge eligibility.” +- Progress: poll and/or `ctx.emitEvent`; tests use fakes (no real network). + +**Patterns to follow:** `packages/engine/src/run-verification-tool.ts`, CE progress for event shape only + +**Execution note:** Implement runner pure logic test-first with fake supervised child. + +**Test scenarios:** +- Happy: start `verify-fast` → `passed` on exit 0 with duration fields set. +- Non-zero exit → `failed` with exitCode. +- Timeout → `timed_out` and kill invoked. +- Cancel while running → `cancelled`. +- Second concurrent project run → 409. +- Full-suite without confirm flag → rejected; with confirm → allowed. +- Unresolved `testCommand` → preset disabled, hub still loads. +- Empty file-scoped inputs → preset disabled. +- Body with `command` override → 400, no spawn. +- Client-supplied `cwd` → ignored/rejected; server cwd used. +- Missing `projectId` → 400; cross-project get/cancel → 404/403. +- Path injection (`../`, metacharacters) rejected for file-scoped. +- Packaged entry can resolve supervised spawn (smoke or unit on shim). +- Disable mid-run marks terminal cancelled/error. + +**Verification:** Hub can start a fake run end-to-end in component tests; runner unit tests cover state machine; packaging path does not strip spawn. + +--- + +### U4. Test plans: author, run, and dashboard + +**Goal:** Operators build reusable ordered plans and execute them as sequenced runs. + +**Requirements:** R4, R2 + +**Dependencies:** U3 (MVP ship may land without U4; keep in this plan as next slice) + +**Files:** +- `plugins/fusion-plugin-quality/src/routes/plan-routes.ts` +- `plugins/fusion-plugin-quality/src/runner/plan-runner.ts` (or glue in `command-runner.ts`) +- `plugins/fusion-plugin-quality/src/dashboard/components/*Plan*` +- `plugins/fusion-plugin-quality/src/__tests__/plan-runner.test.ts` +- dashboard plan component tests + +**Approach:** +- CRUD for draft/active/archived plans; steps = command preset ids only. +- Run plan: sequential presets; each step creates a TestRun linked by `planId`; stop-on-fail default. +- No `enableBrowserVerification` schema field until a plan run can act on it; document browser handoff in README only. +- Empty states and edit validation. + +**Test scenarios:** +- Create active plan with two presets → run creates two linked runs in order. +- Fail first step with stop-on-fail → second not started; plan run marked failed. +- Cannot run archived plan without restore. +- Plan step with unknown preset key rejected at save. + +**Verification:** Hub Plans section supports create → run → history linked to plan. + +--- + +### U5. Read-only GitHub CI status + +**Goal:** Show CI/check health without inventing a second CI system or leaking dashboard-private clients into the plugin. + +**Requirements:** R5 + +**Dependencies:** U1 (UI + task context); soft dep on host GitHub auth + +**Files:** +- Task QA CI panel (client fetch to host PR checks) +- Optional: `packages/dashboard/src/routes/*` host hub-CI route (KTD 5A) +- Optional: `plugins/fusion-plugin-quality/src/routes/ci-routes.ts` if KTD 5B (`gh` path) +- tests with mocked fetch / mocked gh + +**Approach:** +- **U5 task path (MVP):** Task QA calls `GET /api/tasks/:id/pr/checks` with `taskId` from slot context; map host 404 “no PR” to friendly empty; surface 429 retry-after; auth empty CTA. +- **U5 hub path (same plan, may ship after task path):** Choose KTD 5A or 5B explicitly in implementation notes; thin rollup only — no Actions log streaming. +- No write operations; no multi-provider CI. + +**Patterns to follow:** `packages/dashboard/src/routes/register-git-github.ts` PR checks; `packages/dashboard/src/github.ts` for **host** reuse only + +**Test scenarios:** +- Auth missing → empty state + CTA, not spinner forever. +- Task with no PR → friendly empty (not hard error). +- Mocked successful rollup → success/failure counts render. +- 429 → shows retry guidance once (no tight loop). +- Hub path does not import private dashboard GitHub module into plugin package (architecture test or review checklist). + +**Verification:** Task with PR shows checks in QA tab; hub CI section has empty/auth/success states when hub path lands. + +--- + +### U6. Task QA tab shell: action-first layout, targeted tests, reports, CI + +**Goal:** Per-task quality surface that operators open first when doing QA — wired to worktree-scoped runs, report viewer, and host PR checks. Preview/screenshots/suggestions land in U11–U13 but the shell reserves those sections. + +**Requirements:** R6, R3, R5, R10, R13, R18, R20 + +**Dependencies:** U1 (slot context), U3; U5-task for CI panel + +**Files:** +- `plugins/fusion-plugin-quality/src/qa-tab.tsx` + `dashboard/task/*` components +- run routes accept `taskId` + enforce cwd policy +- report panel components (status, duration, command, logs) +- `packages/dashboard/app/plugins/pluginSlotRegistry.tsx` (if not fully done in U1) +- component tests for empty worktree, section layout, desktop/mobile + +**Approach:** +- **Action-first section order (R20):** Preview (U11) → Run tests → Reports → Screenshots (U12) → Suggested cases (U13) → CI → Browser verification (U7) → Agent QA (U10). +- U6 ships Run tests + Reports + CI + placeholders/CTAs for U11–U13 if not yet merged. +- Targeted run: require worktree; block with “start/checkout task” when missing. +- **Report viewer:** selecting a task-scoped TestRun shows R18 fields; “View report” auto-opens after a successful start when the run completes. +- File-scoped preset uses task.modifiedFiles / File Scope when available (else disabled). +- Deep-link: “Open Quality hub” for project history. +- Surface Enumeration: desktop tab, mobile stacked sections, empty task, no worktree, no PR, plugin loading. + +**Test scenarios:** +- Task with worktree starts run with `cwdKind=worktree`. +- Task without worktree blocks default targeted run. +- Task-filtered run history excludes other tasks; report panel shows duration/logs. +- Concurrent second run for same task → 409. +- CI empty when no PR. +- Section order matches R20 on desktop and mobile layout classes. + +**Verification:** Opening task detail QA tab shows run + report UX when worktree exists; empty states for missing preview/screenshots/suggestions until U11–U13. + +--- + +### U11. Task-scoped preview / test server + +**Goal:** One-click start of a preview (test) server **for this task’s worktree** so operators can exercise the change in a browser. + +**Requirements:** R16, R10, R13, R15 + +**Dependencies:** U1, U6 + +**Files:** +- `plugins/fusion-plugin-quality/src/preview/*` or host-backed routes under Quality plugin API +- Task QA Preview section UI +- Prefer reuse of `packages/dashboard/src/dev-server-process.ts`, `dev-server-store.ts`, `dev-server-routes.ts` patterns (extend host if task-scoped sessions need first-class APIs) +- tests: command allowlist, worktree cwd, port ≠4040, stop/reap + +**Approach:** +- Start/stop/restart from Task QA; cwd = task worktree (block without worktree). +- Command from project Dev Server selected script/config when present, else allowlisted package scripts (`dev`, `start`, `preview`) — same safety posture as `assertSafeDevServerCommand`. +- Free port allocation; **never 4040**; detect preview URL from logs or manual override (compose Dev Server URL detection). +- Session identity keyed by `taskId` (and projectId); stop on task complete optional setting (default leave running with banner). +- Open URL button; deep-link to full Dev Server view for advanced logs when helpful. +- Do not replace global Dev Server view. + +**Patterns to follow:** `packages/dashboard/src/dev-server-process.ts`, `dev-server-routes.ts`, AGENTS port 4040 rule + +**Test scenarios:** +- Start with worktree → status running + detected or manual URL. +- No worktree → block with CTA. +- Unsafe command rejected. +- Stop reaps process group. +- Port 4040 never selected (unit/assert). +- Concurrent start for same task is idempotent or 409 with live session link. + +**Verification:** From Task QA, operator starts preview and opens the app for that worktree. + +--- + +### U12. Screenshots / visual evidence gallery + +**Goal:** Operators see visual proof of the change without hunting Artifacts or terminal paths. + +**Requirements:** R17, R13 + +**Dependencies:** U1, U6; soft U7 for browser-capture CTA + +**Files:** +- Task QA Screenshots section components +- API aggregation: task-scoped artifacts (image MIME), optional `design-preview` task document, known browser evidence paths +- tests for empty/populated/filter + +**Approach:** +- Query existing artifact registry filtered by `taskId` + image types; show thumbnails + open full Artifacts / floating viewer. +- Surface `design-preview` task document when present (UI workflows already encourage it). +- Empty state: CTAs — “Enable browser verification” (U7), “Capture via agent browser” (when available), “Open Artifacts”. +- No new pixel-diff product; gallery only. Optional “refresh” after agent/browser run. +- Retention = underlying artifacts; Quality does not duplicate blobs. + +**Patterns to follow:** Artifacts view gallery, `fn_artifact_register`, dashboard artifact APIs + +**Test scenarios:** +- Task with image artifacts → thumbnails render. +- Task with no images → empty state with CTAs (no error). +- design-preview doc link appears when document exists. +- Cross-task artifacts never shown. + +**Verification:** QA tab Screenshots section shows task images or a clear empty state. + +--- + +### U13. Suggested test cases + +**Goal:** Operators get a ready checklist of what to verify for this task (manual + automated hints). + +**Requirements:** R19, R13 + +**Dependencies:** U1, U6; optional AI session for rich generation + +**Files:** +- `plugins/fusion-plugin-quality/src/suggestions/*` +- routes: generate / get / update checklist ticks +- Task QA Suggested cases UI +- tests: heuristic generation without AI; AI path mocked + +**Approach:** +- **v1 heuristics (always available):** parse File Scope / `modifiedFiles` → “exercise changed modules X”; PROMPT headings/acceptance lines → case bullets; bug-fix tasks → “reproduce original symptom” item. +- **Optional AI enrich:** short prompt via `createAiSession` / interactive when available; fail soft to heuristics. +- Persist snapshot: task document key `qa-suggested-cases` or Quality table row with `projectId`+`taskId`. +- UI: checklist with tick state (local or persisted), copy-all, “Regenerate”, optional “Run related tests” for file-scoped preset when paths match. +- Explicitly advisory — not merge-blocking. + +**Test scenarios:** +- Task with file scope produces non-empty heuristic list. +- Empty prompt/files → empty state with regenerate disabled reason. +- Tick state persists across reopen (if persisted). +- AI path failure falls back to heuristics without 500. + +**Verification:** Opening QA tab on a scoped task shows suggested cases without requiring agent QA. + +--- + +### U7. Browser verification composition (Task QA) + +**Goal:** One-click enable of built-in browser verification from Task QA without a parallel browser stack. + +**Requirements:** R7, R11 + +**Dependencies:** U6 + +**Files:** +- Task QA browser section UI +- helpers to update `enabledWorkflowSteps` for group id `browser-verification` +- `plugins/fusion-plugin-quality/src/__tests__/browser-verification-handoff.test.ts` + +**Approach:** +- Primary action: enable/toggle stable id `browser-verification` via the same task-update path Task Detail uses for optional groups. +- Soft-detect agent-browser install/setup; setup CTA when missing; never re-ship binary. +- Hub may deep-link to a task’s QA tab; hub does **not** write `enabledWorkflowSteps`. +- Optional secondary “run browser QA now” waits for U10 if it needs an interactive session; otherwise document “enabled for next graph pass.” +- Do not duplicate `browser-evidence-review` from agent-browser. + +**Patterns to follow:** `packages/core/src/builtin-browser-verification-group.ts`, agent-browser plugin, optional-group id remapping solution doc + +**Test scenarios:** +- Enable writes stable group id `browser-verification`. +- Agent-browser missing → warning + setup CTA; enable still allowed. +- Hub has no enable control that mutates task workflow state. + +**Verification:** Task QA can enable browser verification for the open task. + +--- + +### U9. Advisory workflow-step palette + +**Goal:** Contribute advisory QA templates to the workflow editor palette without merge-blocking defaults. + +**Requirements:** R8, R11 + +**Dependencies:** U1 (plugin load); soft after U7 + +**Files:** +- `plugins/fusion-plugin-quality/src/workflow-steps.ts` +- `plugins/fusion-plugin-quality/src/__tests__/workflow-steps.test.ts` + +**Approach:** +- Palette templates e.g. `quality-targeted-verify` (prompt or script), `defaultOn: false`, structured verdict JSON. +- Advisory labeling; do not set merge-blocking gate mode in v1. +- Stable step ids; materializer-safe tests. + +**Test scenarios:** +- Contribution shape validates (`stepId` slug, `defaultOn: false`). +- Templates appear in plugin workflow step aggregation. + +**Verification:** Workflow palette lists Quality steps when plugin enabled. + +--- + +### U10. Agent-driven QA sessions + +**Goal:** Long-running agent QA with detach + progress, without blocking dashboard HTTP. + +**Requirements:** R9, R11, R14 + +**Dependencies:** U6, U7 (soft) + +**Files:** +- `plugins/fusion-plugin-quality/src/routes/agent-qa-routes.ts` +- plugin-local session helpers (CE-inspired) +- Task QA (and optional hub) start/cancel UI +- agent QA route tests with mocked interactive session + +**Approach:** +- Probe `ctx.createInteractiveAiSession`; empty state when engine factory missing. +- CE pattern: detach, `onProgress`, inactivity watchdog, persist linkage; cancel → interrupted; honor mock/testMode. +- **Session bounds:** default cwd = task worktree when task-scoped; project-root only with explicit setting. System prompt: advisory QA only (no merge/release/credential actions). Prefer read + browser tools; document tool posture limits. +- Use agent-browser skills when available; never re-ship browser stack. +- Never log GH tokens or Authorization headers in run/session events. +- May ship after MVP hub/tab command runs (KTD 14). + +**Patterns to follow:** CE session orchestrator + observable long-running agent turns solution doc + +**Test scenarios:** +- Start returns session id immediately (detached). +- Progress updates status; failure persists without rejecting void promise. +- Factory missing → CTA, not hang. +- Mock/testMode does not call real provider. +- Disable mid-session fail-soft. + +**Verification:** Agent QA start is non-blocking when engine sessions available. + +--- + +### U8. Settings, docs, surface enumeration hardening, and packaging + +**Goal:** Operator-facing polish, documentation, and ship readiness for `@runfusion/fusion`. + +**Requirements:** R12–R15, packaging + +**Dependencies:** U1–U3, U6 at minimum for first ship; remaining units as they land + +**Files:** +- `plugins/fusion-plugin-quality/src/settings.ts` + tests +- `plugins/fusion-plugin-quality/README.md` +- `docs/plugins/quality.md` (or section under plugin-management) +- `CONCEPTS.md` (already seeded; keep in sync) +- `.changeset/*.md` when published surface changes +- any missing host tests from registration matrix +- light `docs/PLUGIN_AUTHORING.md` example bullet if appropriate + +**Approach:** +- Settings: retention count, log truncate KB, default hub presets visibility, optional agent QA model override, allow-root-fallback boolean (default false). +- Document: advisory vs merge gate; agent-browser install; preset map; mobile More; packaging/shim note for spawn. +- Surface Enumeration for all shipped affordances (desktop + mobile). +- FNXC comments on cwd policy, advisory labeling, registration matrix, slot context. +- Changeset: minor feature for `@runfusion/fusion` with labeled `summary`/`category`/`dev`. + +**Test scenarios:** +- Settings defaults apply when unset. +- Registration matrix membership tests remain green. +- Do not add Quality to the App.tsx 20-view lazy inventory unless it is actually registered there (prefer plugin registry path). + +**Verification:** Plugin README accurate; install from Settings works; docs explain advisory vs gate; changeset validates when present. + +--- + +## Phased Delivery + +| Phase | Units | Outcome | Ship gate | +|-------|-------|---------|-----------| +| A — Foundation | U1, U2 | Installable plugin, slot context, TestRun domain | Required | +| B — Runs substrate | U3, U8-partial | Allowlisted runs + packaging for spawn | Required for Task QA | +| C — **Task QA core** | U6, U5-task, **U11, U12, U13** | Action-first tab: tests, reports, **preview server, screenshots, suggested cases**, PR checks | **MVP ship** | +| D — Project hub | U4, U5-hub, hub dashboard polish | Plans + project CI + hub history | After Task QA MVP | +| E — Browser / workflow / agent | U7, U9, U10 | browser-verification, palette, agent QA sessions | After Task QA MVP | +| F — Polish | U8 complete | Settings, full docs, changeset | With each ship slice | + +**MVP acceptance (what “easier task QA” means):** From a task with a worktree, an operator can start a preview server, run targeted tests and open a report, see screenshots/evidence or a clear empty state, and read suggested test cases — without leaving Task Detail. Plans, hub CI depth, and agent QA sessions ship later in the same plan. + +--- + +## System-Wide Impact + +| Stakeholder | Impact | +|-------------|--------| +| Operators | New Quality nav + Task QA tab; clearer path to run/verify without terminal archaeology | +| Agents / workflows | Optional advisory steps; browser-verification still engine-owned when enabled | +| Engine / merge | No change to merge gate authority; Quality runs are side-channel | +| Dashboard host | Additional static registry entries, Vite aliases, bundled install surfaces | +| Desktop | Needs package export resolution for `@fusion-plugin-examples/quality` if staged like other bundled plugins | +| Mobile | Quality under More; task tab in horizontal overflow — layouts must not assume wide sidebar only | + +--- + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Incomplete host registration → blank “unavailable” hub | U1 acceptance = full matrix checklist; tests for registry resolution | +| Operators treat hub red as merge-blocking | Persistent advisory copy; R11; no write path to merge eligibility | +| cwd wrong (root vs worktree) | Hard policy table; block task runs without worktree by default | +| Concurrent test thrash | 1 project + 1 per-task concurrency; 409 | +| Shell injection via start-run | R15 + U3 body schema: server-only preset resolution; no client command/cwd | +| Cross-project IDOR on runs/logs | Require projectId; composite ownership on get/cancel | +| agent-browser missing | Soft dependency + setup CTA; do not hard-fail plugin load | +| Unbounded agent QA tools | U10 session bounds + defer after MVP; probe factory | +| Long HTTP timeouts on agent QA | CE detach + progress mandatory (U10) | +| GH token leakage | Prefer host GH routes; never log tokens (U5) | +| GH auth / rate limits | Empty + CTA; surface 429; no poll without credentials | +| Full-suite accidental click | Confirm friction; never default | +| Plugin disabled mid-run | Cancel supervised runs; mark cancelled/error | +| Port 4040 / execSync regressions | Code review + AGENTS rules; no test servers on 4040 | +| Flake-retry product pressure | Hub never auto-retries failed tests; optional quarantine ledger link only | +| Secrets in test logs | Truncation + best-effort redaction; tight retention | + +**Dependencies:** Existing GitHub auth for CI panel; agent-browser install for full browser path; project `testCommand` or lockfile for useful presets. + +--- + +## Alternative Approaches Considered + +1. **Core dashboard “Quality” view (not a plugin)** — Rejected: fights plugin ecosystem strategy; harder to disable; expands core surface area. +2. **Project hub only in v1** — Rejected by confirmed scope; task worktree verification is half the product value. +3. **Promote teaching `fusion-plugin-ci-status`** — Rejected as production path: generic poller, not real GH Actions; use as conceptual shape only. +4. **Deep suite discovery + coverage in v1** — Rejected: out of confirmed intelligence depth; high cost, thin local patterns. +5. **Own Playwright browser stack in Quality** — Rejected: duplicates agent-browser and engine preflight. + +--- + +## Success Metrics + +- From Task QA, an operator with a worktree can **start a preview server and open a URL** without using the terminal or global Dev Server first. +- Task with worktree can **run targeted tests and open a report** (status, duration, logs) on the same tab. +- Task with image artifacts or design-preview shows them under **Screenshots**; empty state is actionable. +- Task with File Scope / PROMPT shows **suggested test cases** without requiring a full agent session. +- Browser verification can be enabled from Task QA without a second browser plugin. +- Zero “Plugin view unavailable” for Quality on a clean bundled install. +- Merge gate behavior unchanged (no new required check from Quality). + +--- + +## Documentation Plan + +- Plugin README: install, presets, advisory vs gate, agent-browser composition +- `docs/plugins/quality.md` short operator guide +- CONCEPTS entries: Quality Hub, Test Plan (Quality), Test Run (Quality), Task QA Tab +- Optional light PLUGIN_AUTHORING example bullet pointing at Quality as dual-surface reference + +--- + +## Open Questions + +Deferred to implementation (non-blocking): + +- Exact lucide icon and sidebar `order` relative to Reports/Compound. +- Hub CI first paint: rollup of open task PRs vs single default-branch query (after KTD 5 access path chosen). +- Desktop package.json / desktop stage wiring for `@fusion-plugin-examples/quality` (mirror CE). +- KTD 6 final choice (shim export vs plugin-local supervisor) if packaging constraints force option B. + +Resolved during planning/doc-review: + +- Plan steps are command presets only (no agent-QA plan step). +- Browser enable is Task QA–owned. +- Task-detail slot must receive host-injected task context. +- `superviseSpawn` is not available on today’s plugin shim until host packaging work lands. + +--- + +## Assumptions + +- Confirmed defaults: full dual surface; compose agent-browser; orchestrate existing commands (no suite/coverage invent); GitHub Actions read-only first. +- **2026-07-14 refinement:** Task QA prioritizes preview server, screenshots, test reports, and suggested test cases (R16–R20; U11–U13); MVP ship gate is Task QA usability, not hub-first. +- Compose project Dev Server + Artifacts rather than forking them. +- `task-card-badge` remains out of v1. +- Quality does not require new engine lifecycle nodes beyond plugin workflow step palette + existing optional groups. +- External research was skipped: local plugin/verification patterns are dense enough for architecture. +- Doc review (coherence/feasibility/scope) findings for host slot context, packaging spawn, and unit splits are incorporated above. + +--- + +## Sources & Research + +- Repo patterns: `docs/PLUGIN_AUTHORING.md` §§7–8,16; `plugins/fusion-plugin-reports`, `fusion-plugin-compound-engineering`, `fusion-plugin-agent-browser`, `plugins/examples/fusion-plugin-ci-status` +- Institutional: `docs/solutions/integration-issues/bundled-plugin-registration-drift.md`, `bundled-plugin-vite-alias-missing.md`, `docs/solutions/architecture-patterns/thin-trusted-merge-gate.md`, `observable-long-running-agent-turns-through-blocking-plugin-route-seam.md`, `docs/solutions/logic-errors/optional-group-toggle-id-remapped-by-step-materializer.md` +- Verification: `docs/testing.md`, `packages/engine/src/run-verification-tool.ts`, `docs/workflow-steps.md` +- CI: `packages/dashboard/src/github.ts`, `GET /api/tasks/:id/pr/checks` +- Product: `ROADMAP.md` (QA interface), `STRATEGY.md` (ecosystem track) +- Flow analysis: dual-surface flows, TestRun/TestPlan state machines, registration and cwd risks + +--- + +## Deferred Implementation Notes + +- Exact helper names for preset resolution and GitHub wrapper modules. +- Final log storage (DB text vs task-document-like file) — prefer DB truncated text in v1 unless size forces files. +- Whether to share process supervisor utilities by importing from `@fusion/core` only (no engine import from plugin). +- Optional future: fix reports host registration as unrelated cleanup. diff --git a/packages/cli/src/__tests__/plugin-sdk-core-runtime-shim.test.ts b/packages/cli/src/__tests__/plugin-sdk-core-runtime-shim.test.ts new file mode 100644 index 0000000000..5677fdd53a --- /dev/null +++ b/packages/cli/src/__tests__/plugin-sdk-core-runtime-shim.test.ts @@ -0,0 +1,62 @@ +import { EventEmitter } from "node:events"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + spawn: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + spawn: mocks.spawn, +})); + +import { superviseSpawn } from "../plugin-sdk-core-runtime-shim.mjs"; + +class FakeChild extends EventEmitter { + pid = 1234; + kill = vi.fn(); +} + +describe("plugin SDK core runtime shim supervision", () => { + beforeEach(() => { + mocks.spawn.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("absorbs child spawn errors without throwing", () => { + const child = new FakeChild(); + mocks.spawn.mockReturnValue(child); + + superviseSpawn("missing-command"); + + expect(() => child.emit("error", new Error("ENOENT"))).not.toThrow(); + }); + + it("unrefs escalation timers and never SIGKILLs a closed child", () => { + const child = new FakeChild(); + mocks.spawn.mockReturnValue(child); + const timerCallbacks: Array<() => void> = []; + const timerUnrefs: Array> = []; + + vi.spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void) => { + timerCallbacks.push(callback); + const timer = { unref: vi.fn() }; + timerUnrefs.push(timer.unref); + return timer as unknown as ReturnType; + }) as typeof setTimeout); + + const supervised = superviseSpawn("command", [], { maxLifetimeMs: 10 }); + supervised.kill(); + timerCallbacks[0]!(); + child.emit("close", 0, null); + timerCallbacks[1]!(); + timerCallbacks[2]!(); + + expect(timerUnrefs).toHaveLength(3); + expect(timerUnrefs.every((unref) => unref.mock.calls.length === 1)).toBe(true); + expect(child.kill).not.toHaveBeenCalledWith("SIGKILL"); + }); +}); diff --git a/packages/cli/src/__tests__/plugin-sdk-export.test.ts b/packages/cli/src/__tests__/plugin-sdk-export.test.ts index 140023350b..635b204b2e 100644 --- a/packages/cli/src/__tests__/plugin-sdk-export.test.ts +++ b/packages/cli/src/__tests__/plugin-sdk-export.test.ts @@ -43,7 +43,7 @@ describe("plugin-sdk export surface", () => { expect(tsupRaw).toContain("/^@fusion\\//"); }); - it("uses a runtime-only core shim that bundles schema source without requiring core dist", () => { + it("uses a runtime-only core shim that bundles schema source and Quality supervision without core dist", () => { const tsupPath = join(workspaceRoot, "packages", "cli", "tsup.config.ts"); const tsupRaw = readFileSync(tsupPath, "utf-8"); const shimPath = join(workspaceRoot, "packages", "cli", "src", "plugin-sdk-core-runtime-shim.mjs"); @@ -51,6 +51,7 @@ describe("plugin-sdk export surface", () => { expect(tsupRaw).toContain('"plugin-sdk-core-runtime-shim.mjs"'); expect(shimRaw).toContain('from "../../core/src/postgres/schema/index.js"'); + expect(shimRaw).toContain("export function superviseSpawn"); expect(shimRaw).not.toContain("../../core/dist/"); }); diff --git a/packages/cli/src/plugin-sdk-core-runtime-shim.mjs b/packages/cli/src/plugin-sdk-core-runtime-shim.mjs index 0543892680..088fc56908 100644 --- a/packages/cli/src/plugin-sdk-core-runtime-shim.mjs +++ b/packages/cli/src/plugin-sdk-core-runtime-shim.mjs @@ -1,11 +1,90 @@ +import { spawn } from "node:child_process"; + /* - * FNXC:BundledPlugins 2026-07-15-13:11: - * Clean CI typechecks the CLI before @fusion/core emits dist, but published bundled plugins still need postgresSchema runtime values. Keep this alias implementation in an untyped .mjs module so CLI tsc does not cross the package rootDir boundary; esbuild follows the core source import and inlines the schema into each bundled.js artifact, leaving no private @fusion/core runtime dependency. + * FNXC:BundledPlugins 2026-07-15-13:40: + * Clean CI typechecks the CLI before @fusion/core emits dist, but published + * bundled plugins need both postgresSchema runtime values and Quality's + * process-group supervisor. Keep this alias implementation in untyped MJS so + * tsc stays inside the CLI root while esbuild follows the core source schema + * and bundles every runtime export without a private @fusion/core dependency. */ import * as postgresSchema from "../../core/src/postgres/schema/index.js"; export { postgresSchema }; +export const FUSION_RESTART_EXIT_CODE = 86; + +export function superviseSpawn(command, args = [], options = {}) { + const killGraceMs = options.killGraceMs ?? 2_000; + const maxLifetimeMs = options.maxLifetimeMs; + const spawnOptions = { ...options }; + delete spawnOptions.killGraceMs; + delete spawnOptions.maxLifetimeMs; + const processGroup = globalThis.process.platform !== "win32"; + const child = spawn(command, [...args], { ...spawnOptions, detached: processGroup }); + const pgid = processGroup && typeof child.pid === "number" ? child.pid : null; + let settled = false; + let resolveExit; + const waitExit = new Promise((resolve) => { + resolveExit = resolve; + }); + + const killProcess = (signal = "SIGTERM") => { + if (typeof child.pid !== "number") return; + try { + if (pgid != null) globalThis.process.kill(-pgid, signal); + else child.kill(signal); + } catch { + try { + child.kill(signal); + } catch { + // FNXC:Quality 2026-07-15-13:40: A concurrently exited child needs no further cancellation action. + } + } + }; + + let lifetimeTimer = null; + if (typeof maxLifetimeMs === "number" && Number.isFinite(maxLifetimeMs) && maxLifetimeMs > 0) { + lifetimeTimer = globalThis.setTimeout(() => { + if (settled) return; + killProcess("SIGTERM"); + const escalationTimer = globalThis.setTimeout(() => { + if (!settled) killProcess("SIGKILL"); + }, killGraceMs); + escalationTimer.unref?.(); + }, maxLifetimeMs); + lifetimeTimer.unref?.(); + } + + child.once("close", (code, signal) => { + settled = true; + if (lifetimeTimer) globalThis.clearTimeout(lifetimeTimer); + resolveExit?.({ code, signal }); + }); + // FNXC:Quality 2026-07-15-13:40: Spawn failures must not crash a bundled plugin; close settles waitExit. + child.on("error", () => {}); + + return { + pid: child.pid, + pgid, + child, + kill(signal = "SIGTERM") { + if (settled) return; + killProcess(signal); + if (signal === "SIGTERM") { + const escalationTimer = globalThis.setTimeout(() => { + if (!settled) killProcess("SIGKILL"); + }, killGraceMs); + escalationTimer.unref?.(); + } + }, + waitExit() { + return waitExit; + }, + }; +} + +export const ProcessSupervisor = { superviseSpawn }; export const WORKFLOW_EXTENSION_SCHEMA_VERSION = 1; export function workflowExtensionRegistryId(pluginId, extensionId) { diff --git a/packages/cli/src/plugins/staged-bundled-plugin-ids.ts b/packages/cli/src/plugins/staged-bundled-plugin-ids.ts index 633b3a4fa2..9e54364c1a 100644 --- a/packages/cli/src/plugins/staged-bundled-plugin-ids.ts +++ b/packages/cli/src/plugins/staged-bundled-plugin-ids.ts @@ -23,4 +23,5 @@ export const ALL_STAGED_BUNDLED_IDS = [ "fusion-plugin-reports", "fusion-plugin-cli-printing-press", "fusion-plugin-linear-import", + "fusion-plugin-quality", ] as const; diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 2861ad62b2..ab735e96b1 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -64,6 +64,8 @@ const cliPrintingPressPluginSrc = join(__dirname, "..", "..", "plugins", "fusion const cliPrintingPressPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-cli-printing-press"); const compoundEngineeringPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-compound-engineering"); const compoundEngineeringPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-compound-engineering"); +const qualityPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-quality"); +const qualityPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-quality"); const linearImportPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-linear-import"); const linearImportPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-linear-import"); const pluginSdkCoreRuntimeShim = join(__dirname, "src", "plugin-sdk-core-runtime-shim.mjs"); @@ -509,6 +511,12 @@ const cliBuildConfig = { destDir: linearImportPluginDest, }); + await bundlePluginEntry({ + pluginId: "fusion-plugin-quality", + srcDir: qualityPluginSrc, + destDir: qualityPluginDest, + }); + await bundlePluginEntry({ pluginId: "fusion-plugin-reports", srcDir: reportsPluginSrc, diff --git a/packages/core/src/__tests__/postgres/plugin-schema-hook.test.ts b/packages/core/src/__tests__/postgres/plugin-schema-hook.test.ts index 793f8c9ed5..d69a561766 100644 --- a/packages/core/src/__tests__/postgres/plugin-schema-hook.test.ts +++ b/packages/core/src/__tests__/postgres/plugin-schema-hook.test.ts @@ -31,7 +31,10 @@ function executedSql(execute: ReturnType): string { describe("PostgreSQL plugin schema registry", () => { /* FNXC:PluginPostgresSchema 2026-07-14-18:45: - Every bundled legacy onSchemaInit declaration requires a named PostgreSQL equivalent. Derive the declarations from the bundled plugin entrypoints so adding a hook cannot leave a second hardcoded inventory green after the cutover. + Every bundled legacy onSchemaInit declaration requires either a named default + PostgreSQL hook or a plugin-owned declarative PostgreSQL contract. Derive + declarations from entrypoints so adding a SQLite hook cannot bypass the + backend compatibility requirement. */ it("registers every bundled plugin that declares onSchemaInit", () => { const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../.."); @@ -43,13 +46,15 @@ describe("PostgreSQL plugin schema registry", () => { if (!/\bonSchemaInit\s*:/.test(source)) return []; const pluginId = source.match(/\bid\s*:\s*["']([^"']+)["']/)?.[1]; if (!pluginId) throw new Error(`Bundled plugin ${entry.name} declares onSchemaInit without a literal manifest id`); - return [pluginId]; + return [{ pluginId, hasDeclarativePostgresSchema: /\bonPostgresSchemaInit\s*:/.test(source) }]; }) .sort(); const registered = new Set(DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS.map((hook) => hook.pluginId)); expect(declaredLegacyHooks).not.toHaveLength(0); - expect(declaredLegacyHooks.filter((pluginId) => !registered.has(pluginId))).toEqual([]); + expect(declaredLegacyHooks.filter(({ pluginId, hasDeclarativePostgresSchema }) => ( + !hasDeclarativePostgresSchema && !registered.has(pluginId) + ))).toEqual([]); }); it("runs the registered PostgreSQL hook instead of the legacy callback", async () => { diff --git a/packages/core/src/plugins/bundled-plugin-install.ts b/packages/core/src/plugins/bundled-plugin-install.ts index a9802456d3..aa3b1f3c4d 100644 --- a/packages/core/src/plugins/bundled-plugin-install.ts +++ b/packages/core/src/plugins/bundled-plugin-install.ts @@ -40,6 +40,7 @@ export const BUNDLED_PLUGIN_IDS = [ "fusion-plugin-cli-printing-press", "fusion-plugin-compound-engineering", "fusion-plugin-linear-import", + "fusion-plugin-quality", ] as const; export type BundledPluginId = (typeof BUNDLED_PLUGIN_IDS)[number]; diff --git a/packages/dashboard/app/components/PluginManager.tsx b/packages/dashboard/app/components/PluginManager.tsx index ae25328ec3..89bc2f78fe 100644 --- a/packages/dashboard/app/components/PluginManager.tsx +++ b/packages/dashboard/app/components/PluginManager.tsx @@ -176,6 +176,18 @@ export const BUILTIN_PLUGINS: BuiltinPlugin[] = [ category: "integration", path: "./plugins/fusion-plugin-compound-engineering", }, + /* + FNXC:Quality 2026-07-14-21:50: + Quality is a bundled first-party plugin (Task QA tab + Quality hub). Register in Plugin Manager + so operators can enable/manage it like other built-in integrations. + */ + { + id: "fusion-plugin-quality", + name: "Quality", + description: "Task QA tab and Quality hub: preview servers, test runs, reports, screenshots, and suggested cases.", + category: "integration", + path: "./plugins/fusion-plugin-quality", + }, /* * FNXC:PluginManager 2026-07-02-17:56: * FN-7454 keeps Linear Import in the built-in catalog because FN-7443 shipped the plugin package, registry entry, and dashboard view, but users still could not install or manage it from Plugin Manager without this bundled-plugin registration. diff --git a/packages/dashboard/app/components/PluginSlot.tsx b/packages/dashboard/app/components/PluginSlot.tsx index 7f260bdf2f..000f8bb165 100644 --- a/packages/dashboard/app/components/PluginSlot.tsx +++ b/packages/dashboard/app/components/PluginSlot.tsx @@ -2,7 +2,11 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { ErrorBoundary } from "./ErrorBoundary"; import { usePluginUiSlots } from "../hooks/usePluginUiSlots"; -import { resolvePluginSlotComponent, type PluginSlotHostActions } from "../plugins/pluginSlotRegistry"; +import { + resolvePluginSlotComponent, + type PluginSlotHostActions, + type PluginSlotTaskContext, +} from "../plugins/pluginSlotRegistry"; import "./PluginSlot.css"; interface PluginSlotProps { @@ -16,6 +20,10 @@ interface PluginSlotProps { renderPlaceholder?: boolean; /** Optional host-controlled callbacks that slot components can call */ actions?: PluginSlotHostActions; + /** Optional task context for task-detail-tab and similar surfaces */ + context?: PluginSlotTaskContext; + taskId?: string; + worktree?: string; } function PluginSlotMissingComponent({ slotId, pluginId }: { slotId: string; pluginId: string }): ReactNode { @@ -41,7 +49,16 @@ function PluginSlotMissingComponent({ slotId, pluginId }: { slotId: string; plug /** * Renders plugin slot registrations for a host surface. */ -export function PluginSlot({ slotId, projectId, pluginIds, renderPlaceholder = true, actions }: PluginSlotProps): ReactNode { +export function PluginSlot({ + slotId, + projectId, + pluginIds, + renderPlaceholder = true, + actions, + context, + taskId, + worktree, +}: PluginSlotProps): ReactNode { const { getSlotsForId, loading, error } = usePluginUiSlots(projectId); if (loading || error || !slotId) { @@ -56,6 +73,13 @@ export function PluginSlot({ slotId, projectId, pluginIds, renderPlaceholder = t return null; } + const resolvedContext: PluginSlotTaskContext = { + ...context, + projectId: context?.projectId ?? projectId, + taskId: context?.taskId ?? taskId, + worktree: context?.worktree ?? worktree, + }; + return ( <> @@ -64,7 +88,17 @@ export function PluginSlot({ slotId, projectId, pluginIds, renderPlaceholder = t const SlotComponent = resolvePluginSlotComponent(entry); if (SlotComponent) { - return ; + return ( + + ); } if (!renderPlaceholder) { diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 5de4257b4b..c46dfb2577 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -592,6 +592,7 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record = { todoView: "Todo List", researchView: "Research View", evalsView: "Evals View", + qualityPlugin: "Quality Plugin", goalsView: "Goals View", /* FNXC:QuickAddSubtaskFlag 2026-06-21-00:00: The AI subtask-breakdown quick-add affordance is exposed only through this default-off experimental flag so missing settings keep every quick-add Subtask button hidden. */ subtaskBreakdown: "Subtask Breakdown", diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 7929c42073..720594aac9 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -5103,10 +5103,24 @@ export function TaskDetailContent({ /> ) : activePluginTab ? (
+ {/* + FNXC:Quality 2026-07-14-21:50: + Pass task context into plugin task-detail tabs so Quality QA (and future tabs) + can scope worktree runs, preview servers, and suggestions without URL scraping. + */}
) : activeTab === "stats" ? ( diff --git a/packages/dashboard/app/plugins/pluginSlotRegistry.tsx b/packages/dashboard/app/plugins/pluginSlotRegistry.tsx index 387e312f2f..429d219aa1 100644 --- a/packages/dashboard/app/plugins/pluginSlotRegistry.tsx +++ b/packages/dashboard/app/plugins/pluginSlotRegistry.tsx @@ -1,4 +1,5 @@ import type { ComponentType, ReactNode } from "react"; +import { lazy, Suspense, createElement } from "react"; import { useTranslation } from "react-i18next"; import type { PluginUiSlotEntry } from "../api"; import { DroidCliProviderCard } from "../components/DroidCliProviderCard"; @@ -9,9 +10,26 @@ export interface PluginSlotHostActions { openModelOnboarding?: () => void; } -interface PluginSlotComponentProps { +/* +FNXC:Quality 2026-07-14-21:50: +Task-detail plugin tabs need host-injected task context (taskId, worktree, projectId). +Without this bag Task QA cannot scope runs, preview servers, or suggestions. +*/ +export interface PluginSlotTaskContext { + taskId?: string; + worktree?: string; + projectId?: string; + title?: string; + modifiedFiles?: string[]; +} + +export interface PluginSlotComponentProps { entry: PluginUiSlotEntry; actions?: PluginSlotHostActions; + context?: PluginSlotTaskContext; + taskId?: string; + worktree?: string; + projectId?: string; } interface PluginSlotRegistration { @@ -71,7 +89,33 @@ function DroidPostOnboardingRecommendation({ actions }: PluginSlotComponentProps ); } +const LazyQualityQaTab = lazy(async () => { + const mod = await import("@fusion-plugin-examples/quality/qa-tab"); + return { default: mod.QualityQaTabSlot ?? mod.default }; +}); + +function QualityTaskDetailTab(props: PluginSlotComponentProps): ReactNode { + return ( + + {createElement(LazyQualityQaTab, { + entry: props.entry, + actions: props.actions, + context: props.context, + taskId: props.taskId ?? props.context?.taskId, + worktree: props.worktree ?? props.context?.worktree, + projectId: props.projectId ?? props.context?.projectId, + })} + + ); +} + const REGISTRY: PluginSlotRegistration[] = [ + { + pluginId: "fusion-plugin-quality", + slotId: "task-detail-tab", + componentPath: "./qa-tab", + component: QualityTaskDetailTab, + }, { pluginId: "fusion-plugin-droid-runtime", slotId: "settings-provider-card", diff --git a/packages/dashboard/app/plugins/registerBundledPluginViews.ts b/packages/dashboard/app/plugins/registerBundledPluginViews.ts index be0cd8e48c..75ce57eb72 100644 --- a/packages/dashboard/app/plugins/registerBundledPluginViews.ts +++ b/packages/dashboard/app/plugins/registerBundledPluginViews.ts @@ -81,6 +81,23 @@ async function loadLinearImportView(): Promise<{ default: PluginViewComponent }> return { default: component as PluginViewComponent }; } +/* +FNXC:Quality 2026-07-14-21:50: +Static host registry for Quality hub. Literal import() so Vite can code-split; +do not use @vite-ignore (reports footgun). +*/ +async function loadQualityView(): Promise<{ default: PluginViewComponent }> { + const moduleId = "@fusion-plugin-examples/quality/dashboard-view"; + const exportName = "QualityDashboardView"; + const mod = await import("@fusion-plugin-examples/quality/dashboard-view") as unknown as Record>; + const component = mod[exportName]; + if (!component) { + console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`); + return { default: createMissingPluginView(moduleId, exportName) }; + } + return { default: component as PluginViewComponent }; +} + export function registerBundledPluginViews(): void { if (registered) return; registered = true; @@ -114,6 +131,12 @@ export function registerBundledPluginViews(): void { "linear-import", lazy(loadLinearImportView), ); + + registerPluginView( + "fusion-plugin-quality", + "quality", + lazy(loadQualityView), + ); } export function __test_resetBundledPluginViewRegistration(): void { diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index f775f10d06..8980275f9e 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -150,7 +150,8 @@ "remark-gfm": "^4.0.1", "unified": "^11.0.5", "ws": "^8.18.0", - "zod": "^3.25.76" + "zod": "^3.25.76", + "@fusion-plugin-examples/quality": "workspace:*" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 99635d3a88..6017605b63 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -107,6 +107,7 @@ const BUNDLED_PLUGIN_IDS = new Set([ "fusion-plugin-omp-runtime", "fusion-plugin-cli-printing-press", "fusion-plugin-compound-engineering", + "fusion-plugin-quality", ]); function extractBundledPluginId(pathInput: string): string | null { diff --git a/packages/dashboard/vite.config.ts b/packages/dashboard/vite.config.ts index 1c37626edc..c200b1026d 100644 --- a/packages/dashboard/vite.config.ts +++ b/packages/dashboard/vite.config.ts @@ -161,6 +161,18 @@ export default defineConfig({ __dirname, "../../plugins/fusion-plugin-linear-import/src/index.ts", ), + "@fusion-plugin-examples/quality/dashboard-view": resolve( + __dirname, + "../../plugins/fusion-plugin-quality/src/dashboard-view.tsx", + ), + "@fusion-plugin-examples/quality/qa-tab": resolve( + __dirname, + "../../plugins/fusion-plugin-quality/src/qa-tab.tsx", + ), + "@fusion-plugin-examples/quality": resolve( + __dirname, + "../../plugins/fusion-plugin-quality/src/index.ts", + ), }, }, optimizeDeps: { diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index c5f30ccade..e8021e965a 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -517,6 +517,18 @@ export default defineConfig({ __dirname, "../../plugins/fusion-plugin-compound-engineering/src/index.ts", ), + "@fusion-plugin-examples/quality/dashboard-view": resolve( + __dirname, + "../../plugins/fusion-plugin-quality/src/dashboard-view.tsx", + ), + "@fusion-plugin-examples/quality/qa-tab": resolve( + __dirname, + "../../plugins/fusion-plugin-quality/src/qa-tab.tsx", + ), + "@fusion-plugin-examples/quality": resolve( + __dirname, + "../../plugins/fusion-plugin-quality/src/index.ts", + ), "@fusion-plugin-examples/linear-import/dashboard-view": resolve( __dirname, "../../plugins/fusion-plugin-linear-import/src/dashboard-view.tsx", diff --git a/plugins/fusion-plugin-quality/README.md b/plugins/fusion-plugin-quality/README.md new file mode 100644 index 0000000000..afdbe5c3a6 --- /dev/null +++ b/plugins/fusion-plugin-quality/README.md @@ -0,0 +1,25 @@ +# fusion-plugin-quality + +First-party Fusion plugin that makes task QA easy and visual. + +## Surfaces + +- **Quality hub** (left sidebar): project-wide test run history and plans +- **Task QA tab**: action-first task quality work + - Task-scoped preview / test server (worktree cwd) + - Allowlisted targeted tests + report viewer + - Screenshots / visual evidence (task artifacts) + - Suggested test cases (advisory checklist) + - PR checks, browser verification handoff + +## Design principles + +- Orchestrates existing verification (`testCommand`, gate, verify-fast) — does **not** replace the merge gate +- Composes Dev Server process patterns and the artifact registry +- Composes `fusion-plugin-agent-browser` for browser verification (soft dependency) +- Never uses port 4040; never free-form shell as the default path +- Advisory results only — does not change merge eligibility + +## Settings + +See plugin `settingsSchema` in `src/settings.ts`. diff --git a/plugins/fusion-plugin-quality/manifest.json b/plugins/fusion-plugin-quality/manifest.json new file mode 100644 index 0000000000..376cb0f1be --- /dev/null +++ b/plugins/fusion-plugin-quality/manifest.json @@ -0,0 +1,25 @@ +{ + "id": "fusion-plugin-quality", + "name": "Quality", + "version": "0.1.0", + "description": "Task QA tab and Quality hub: preview servers, test runs, reports, screenshots, and suggested cases", + "author": "Fusion Team", + "dashboardViews": [ + { + "viewId": "quality", + "label": "Quality", + "componentPath": "./dashboard-view", + "icon": "ShieldCheck", + "placement": "primary", + "order": 32 + } + ], + "uiSlots": [ + { + "slotId": "task-detail-tab", + "label": "QA", + "icon": "FlaskConical", + "componentPath": "./qa-tab" + } + ] +} diff --git a/plugins/fusion-plugin-quality/package.json b/plugins/fusion-plugin-quality/package.json new file mode 100644 index 0000000000..fbc73ac61b --- /dev/null +++ b/plugins/fusion-plugin-quality/package.json @@ -0,0 +1,40 @@ +{ + "name": "@fusion-plugin-examples/quality", + "version": "0.1.0", + "type": "module", + "description": "Quality hub and Task QA surfaces for Fusion", + "private": true, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./dashboard-view": { + "types": "./src/dashboard-view.tsx", + "import": "./src/dashboard-view.tsx" + }, + "./qa-tab": { + "types": "./src/qa-tab.tsx", + "import": "./src/qa-tab.tsx" + } + }, + "scripts": { + "build": "tsc", + "pretest": "node ../../scripts/ensure-test-artifacts.mjs", + "test": "vitest run --silent=passed-only --reporter=dot" + }, + "dependencies": { + "@fusion/core": "workspace:*", + "@fusion/plugin-sdk": "workspace:*", + "lucide-react": "^0.542.0" + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/node": "^25.5.2", + "@types/react": "^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.2.4", + "typescript": "^5.7.0", + "vitest": "^4.1.0" + } +} diff --git a/plugins/fusion-plugin-quality/src/__tests__/cancel-and-plans.test.ts b/plugins/fusion-plugin-quality/src/__tests__/cancel-and-plans.test.ts new file mode 100644 index 0000000000..186772dccb --- /dev/null +++ b/plugins/fusion-plugin-quality/src/__tests__/cancel-and-plans.test.ts @@ -0,0 +1,84 @@ +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as core from "@fusion/core"; +import { DatabaseSync } from "@fusion/core"; +import { ensureQualitySchema } from "../quality-schema.js"; +import { QualityStore } from "../store/quality-store.js"; +import { + __clearActiveQualityRunsForTests, + __registerActiveQualityRunForTests, + cancelQualityRun, + executeQualityRun, +} from "../runner/command-runner.js"; +import { validatePlanSteps } from "../routes/create-routes.js"; + +describe("cancelQualityRun", () => { + afterEach(() => { + __clearActiveQualityRunsForTests(); + vi.restoreAllMocks(); + }); + + it("kills the supervised child and marks queued/running runs cancelled", () => { + __clearActiveQualityRunsForTests(); + const db = new DatabaseSync(":memory:"); + ensureQualitySchema(db as never); + const store = new QualityStore(db as never); + const run = store.createRun({ + projectId: "p1", + source: "hub", + command: "echo hi", + cwd: "/tmp", + cwdKind: "project-root", + timeoutMs: 1000, + triggeredBy: "test", + }); + store.updateRun("p1", run.id, { status: "running", startedAt: new Date().toISOString() }); + const kill = vi.fn(); + __registerActiveQualityRunForTests("p1", run.id, { kill }); + const cancelled = cancelQualityRun(store, "p1", run.id); + expect(kill).toHaveBeenCalledWith("SIGTERM"); + expect(cancelled?.status).toBe("cancelled"); + expect(cancelled?.errorMessage).toMatch(/Cancelled/); + + const again = cancelQualityRun(store, "p1", run.id); + expect(again?.status).toBe("cancelled"); + }); + + it("retains cancelled when the terminated child later closes", async () => { + const db = new DatabaseSync(":memory:"); + ensureQualitySchema(db as never); + const store = new QualityStore(db as never); + const run = store.createRun({ + projectId: "p1", + source: "hub", + command: "safe-command", + cwd: "/tmp", + cwdKind: "project-root", + timeoutMs: 1_000, + triggeredBy: "test", + }); + const child = new EventEmitter(); + const kill = vi.fn(() => queueMicrotask(() => child.emit("close", null, "SIGTERM"))); + vi.spyOn(core, "superviseSpawn").mockReturnValue({ child, kill } as never); + + const execution = executeQualityRun({ + store, + projectId: "p1", + runId: run.id, + command: "safe-command", + cwd: "/tmp", + timeoutMs: 1_000, + logTruncateKb: 1, + }); + cancelQualityRun(store, "p1", run.id); + + await expect(execution).resolves.toMatchObject({ status: "cancelled", errorMessage: "Cancelled by operator" }); + expect(kill).toHaveBeenCalledWith("SIGTERM"); + }); +}); + +describe("plan step validation", () => { + it("rejects mixed valid and unknown steps without silently filtering", () => { + expect(() => validatePlanSteps(["verify-fast", "not-a-preset", "test-gate"])).toThrow("Unknown plan steps: not-a-preset"); + }); +}); diff --git a/plugins/fusion-plugin-quality/src/__tests__/command-presets.test.ts b/plugins/fusion-plugin-quality/src/__tests__/command-presets.test.ts new file mode 100644 index 0000000000..445bea876e --- /dev/null +++ b/plugins/fusion-plugin-quality/src/__tests__/command-presets.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + isQualityPresetId, + isSafeFilePathToken, + resolvePresetCommand, +} from "../runner/command-presets.js"; + +describe("command-presets", () => { + it("accepts known preset ids only", () => { + expect(isQualityPresetId("verify-fast")).toBe(true); + expect(isQualityPresetId("evil")).toBe(false); + }); + + it("resolves verify-fast and test-gate", () => { + expect(resolvePresetCommand({ preset: "verify-fast", projectRoot: "/repo" })).toEqual({ + ok: true, + command: "pnpm verify:fast", + label: "Verify fast (test-free)", + }); + expect(resolvePresetCommand({ preset: "test-gate", projectRoot: "/repo" }).ok).toBe(true); + }); + + it("disables project-test without testCommand", () => { + const r = resolvePresetCommand({ preset: "project-test", projectRoot: "/repo" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.code).toBe("disabled"); + }); + + it("requires confirm for full-suite", () => { + const denied = resolvePresetCommand({ preset: "full-suite", projectRoot: "/repo" }); + expect(denied.ok).toBe(false); + const ok = resolvePresetCommand({ + preset: "full-suite", + projectRoot: "/repo", + confirmFullSuite: true, + }); + expect(ok.ok).toBe(true); + }); + + it("builds file-scoped command from safe paths only", () => { + const r = resolvePresetCommand({ + preset: "file-scoped", + projectRoot: "/repo", + filePaths: ["src/a.ts", "../etc/passwd", "src/b.ts"], + }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.command).toContain("vitest run"); + expect(r.command).toContain("src/a.ts"); + expect(r.command).not.toContain("passwd"); + } + }); + + it("rejects empty file-scoped", () => { + const r = resolvePresetCommand({ preset: "file-scoped", projectRoot: "/repo", filePaths: [] }); + expect(r.ok).toBe(false); + }); + + it("rejects unsafe path tokens", () => { + expect(isSafeFilePathToken("../x")).toBe(false); + expect(isSafeFilePathToken("a;rm -rf /")).toBe(false); + expect(isSafeFilePathToken("src/ok.ts")).toBe(true); + }); +}); diff --git a/plugins/fusion-plugin-quality/src/__tests__/heuristic-cases.test.ts b/plugins/fusion-plugin-quality/src/__tests__/heuristic-cases.test.ts new file mode 100644 index 0000000000..9c8a2bfe06 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/__tests__/heuristic-cases.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildHeuristicSuggestedCases, extractPromptBullets } from "../suggestions/heuristic-cases.js"; + +describe("heuristic suggested cases", () => { + it("extracts markdown bullets", () => { + const bullets = extractPromptBullets("# Title\n\n- First case\n- Second case\n"); + expect(bullets).toContain("First case"); + expect(bullets).toContain("Second case"); + }); + + it("builds cases from title, files, and UI extensions", () => { + const cases = buildHeuristicSuggestedCases({ + title: "Fix login button", + prompt: "## Acceptance\n- Button works on mobile\n", + filePaths: ["packages/dashboard/app/components/Login.tsx", "packages/dashboard/app/Login.css"], + }); + expect(cases.length).toBeGreaterThan(0); + expect(cases.some((c) => /login button/i.test(c.text))).toBe(true); + expect(cases.some((c) => /mobile/i.test(c.text))).toBe(true); + }); + + it("always returns at least smoke cases", () => { + const cases = buildHeuristicSuggestedCases({}); + expect(cases.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/plugins/fusion-plugin-quality/src/__tests__/manifest.test.ts b/plugins/fusion-plugin-quality/src/__tests__/manifest.test.ts new file mode 100644 index 0000000000..1e1bd3d30f --- /dev/null +++ b/plugins/fusion-plugin-quality/src/__tests__/manifest.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import plugin from "../index.js"; + +describe("fusion-plugin-quality manifest", () => { + it("exports a valid plugin with quality hub and QA tab", () => { + expect(plugin.manifest.id).toBe("fusion-plugin-quality"); + expect(plugin.dashboardViews?.[0]?.viewId).toBe("quality"); + expect(plugin.dashboardViews?.[0]?.placement).toBe("primary"); + expect(plugin.uiSlots?.[0]?.slotId).toBe("task-detail-tab"); + expect(plugin.routes?.length).toBeGreaterThan(0); + expect(plugin.hooks?.onSchemaInit).toBeTypeOf("function"); + expect(plugin.hooks?.onPostgresSchemaInit?.()).toMatchObject({ + version: 1, + tablePrefix: "quality_", + }); + }); +}); diff --git a/plugins/fusion-plugin-quality/src/__tests__/preview-sessions.test.ts b/plugins/fusion-plugin-quality/src/__tests__/preview-sessions.test.ts new file mode 100644 index 0000000000..8cf515df21 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/__tests__/preview-sessions.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { pruneTerminalPreviewSessions } from "../preview/preview-sessions.js"; + +function terminalSession(stoppedAt: string) { + return { + projectId: "project", + taskId: stoppedAt, + status: "stopped" as const, + command: "pnpm run dev", + cwd: "/workspace", + stoppedAt, + logTail: [], + }; +} + +describe("preview session retention", () => { + it("removes expired terminal sessions and bounds retained terminal metadata", () => { + const now = Date.parse("2026-07-15T14:10:00.000Z"); + const sessions = new Map>([ + ["expired", terminalSession("2026-07-15T12:00:00.000Z")], + ...Array.from({ length: 51 }, (_, index) => [ + `recent-${index}`, + terminalSession(new Date(now - (51 - index) * 1_000).toISOString()), + ] as const), + ]); + + pruneTerminalPreviewSessions(sessions, now); + + expect(sessions.has("expired")).toBe(false); + expect(sessions.size).toBe(50); + expect(sessions.has("recent-0")).toBe(false); + }); +}); diff --git a/plugins/fusion-plugin-quality/src/__tests__/quality-store.test.ts b/plugins/fusion-plugin-quality/src/__tests__/quality-store.test.ts new file mode 100644 index 0000000000..8427450f22 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/__tests__/quality-store.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { DatabaseSync } from "@fusion/core"; +import { ensureQualitySchema } from "../quality-schema.js"; +import { QualityStore } from "../store/quality-store.js"; + +describe("QualityStore", () => { + function makeStore() { + const db = new DatabaseSync(":memory:"); + ensureQualitySchema(db as never); + return new QualityStore(db as never); + } + + it("creates and lists runs scoped by project", () => { + const store = makeStore(); + store.createRun({ + projectId: "p1", + source: "hub", + command: "pnpm verify:fast", + cwd: "/repo", + cwdKind: "project-root", + timeoutMs: 60_000, + triggeredBy: "test", + presetId: "verify-fast", + }); + store.createRun({ + projectId: "p2", + source: "hub", + command: "pnpm verify:fast", + cwd: "/other", + cwdKind: "project-root", + timeoutMs: 60_000, + triggeredBy: "test", + }); + expect(store.listRuns("p1")).toHaveLength(1); + expect(store.listRuns("p2")).toHaveLength(1); + }); + + it("getRun enforces project ownership", () => { + const store = makeStore(); + const run = store.createRun({ + projectId: "p1", + source: "task-tab", + taskId: "FN-1", + command: "pnpm test:gate", + cwd: "/wt", + cwdKind: "worktree", + timeoutMs: 60_000, + triggeredBy: "test", + }); + expect(store.getRun("p1", run.id)?.id).toBe(run.id); + expect(store.getRun("p2", run.id)).toBeNull(); + }); + + it("prunes finished runs beyond retention", () => { + const store = makeStore(); + for (let i = 0; i < 5; i++) { + const run = store.createRun({ + projectId: "p1", + source: "hub", + command: `echo ${i}`, + cwd: "/repo", + cwdKind: "project-root", + timeoutMs: 1000, + triggeredBy: "test", + }); + store.updateRun("p1", run.id, { + status: "passed", + finishedAt: new Date().toISOString(), + durationMs: 1, + }); + } + store.pruneRuns("p1", 2); + expect(store.listRuns("p1")).toHaveLength(2); + }); + + it("saves and loads suggested cases", () => { + const store = makeStore(); + store.saveSuggestedCases({ + projectId: "p1", + taskId: "FN-1", + cases: [{ id: "c1", text: "Check login", done: false, source: "heuristic" }], + generatedAt: new Date().toISOString(), + method: "heuristic", + }); + const snap = store.getSuggestedCases("p1", "FN-1"); + expect(snap?.cases).toHaveLength(1); + expect(store.getSuggestedCases("p2", "FN-1")).toBeNull(); + }); +}); diff --git a/plugins/fusion-plugin-quality/src/dashboard-view.tsx b/plugins/fusion-plugin-quality/src/dashboard-view.tsx new file mode 100644 index 0000000000..098efb5604 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/dashboard-view.tsx @@ -0,0 +1,142 @@ +import { createElement, useCallback, useEffect, useState, type ReactElement } from "react"; + +/* +FNXC:Quality 2026-07-14-21:45: +Quality hub dashboard view — project-wide run history and preset catalog. +Host registers this via registerBundledPluginViews (static registry). +*/ + +export interface QualityDashboardViewContext { + projectId?: string; +} + +interface RunRow { + id: string; + status: string; + command: string; + durationMs?: number; + presetId?: string; + createdAt: string; + taskId?: string; +} + +async function fetchRuns(projectId: string): Promise { + const res = await fetch(`/api/plugins/fusion-plugin-quality/runs?projectId=${encodeURIComponent(projectId)}`); + if (!res.ok) return []; + const data = (await res.json()) as { runs?: RunRow[] }; + return data.runs ?? []; +} + +export function QualityDashboardView({ + context, +}: { + context?: QualityDashboardViewContext; +}): ReactElement { + const projectId = context?.projectId; + const [runs, setRuns] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const refresh = useCallback(async () => { + if (!projectId) return; + setLoading(true); + setError(null); + try { + setRuns(await fetchRuns(projectId)); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [projectId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const startPreset = async (preset: string, confirmFullSuite = false) => { + if (!projectId) return; + const res = await fetch(`/api/plugins/fusion-plugin-quality/runs?projectId=${encodeURIComponent(projectId)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId, preset, source: "hub", confirmFullSuite }), + }); + if (!res.ok) { + const text = await res.text(); + setError(text || res.statusText); + return; + } + await refresh(); + }; + + return createElement( + "div", + { className: "quality-hub", "data-testid": "quality-hub", style: { padding: 16 } }, + createElement("h2", { style: { marginTop: 0 } }, "Quality"), + createElement( + "p", + { style: { opacity: 0.8, maxWidth: 640 } }, + "Project-wide test runs. Advisory only — does not change merge eligibility. Prefer Task QA for worktree-scoped preview, screenshots, and suggested cases.", + ), + !projectId + ? createElement("p", null, "Select a project to view Quality data.") + : createElement( + "div", + null, + createElement( + "div", + { style: { display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 } }, + createElement("button", { type: "button", className: "btn btn-sm", onClick: () => void startPreset("verify-fast") }, "Run verify:fast"), + createElement("button", { type: "button", className: "btn btn-sm", onClick: () => void startPreset("test-gate") }, "Run test:gate"), + createElement("button", { type: "button", className: "btn btn-sm", onClick: () => void startPreset("project-test") }, "Run project test"), + createElement("button", { type: "button", className: "btn btn-sm", onClick: () => void refresh() }, "Refresh"), + ), + loading ? createElement("p", null, "Loading…") : null, + error ? createElement("p", { role: "alert", style: { color: "var(--error, #c00)" } }, error) : null, + createElement( + "table", + { style: { width: "100%", borderCollapse: "collapse", fontSize: 13 } }, + createElement( + "thead", + null, + createElement( + "tr", + null, + createElement("th", { style: { textAlign: "left", padding: 6 } }, "Status"), + createElement("th", { style: { textAlign: "left", padding: 6 } }, "Preset"), + createElement("th", { style: { textAlign: "left", padding: 6 } }, "Command"), + createElement("th", { style: { textAlign: "left", padding: 6 } }, "Duration"), + createElement("th", { style: { textAlign: "left", padding: 6 } }, "When"), + ), + ), + createElement( + "tbody", + null, + runs.length === 0 + ? createElement( + "tr", + null, + createElement("td", { colSpan: 5, style: { padding: 6, opacity: 0.7 } }, "No runs yet."), + ) + : runs.map((run) => + createElement( + "tr", + { key: run.id }, + createElement("td", { style: { padding: 6 } }, run.status), + createElement("td", { style: { padding: 6 } }, run.presetId ?? "—"), + createElement("td", { style: { padding: 6, fontFamily: "monospace" } }, run.command), + createElement( + "td", + { style: { padding: 6 } }, + run.durationMs != null ? `${Math.round(run.durationMs / 1000)}s` : "—", + ), + createElement("td", { style: { padding: 6 } }, run.createdAt), + ), + ), + ), + ), + ), + ); +} + +export default QualityDashboardView; diff --git a/plugins/fusion-plugin-quality/src/index.ts b/plugins/fusion-plugin-quality/src/index.ts new file mode 100644 index 0000000000..034f4e87d3 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/index.ts @@ -0,0 +1,56 @@ +import { definePlugin } from "@fusion/plugin-sdk"; +import { ensureQualitySchema, qualityPostgresSchema } from "./quality-schema.js"; +import { createQualityRoutes } from "./routes/create-routes.js"; +import { settingsSchema } from "./settings.js"; + +/* +FNXC:Quality 2026-07-14-21:45: +Bundled Quality plugin: hub dashboard view + Task QA tab metadata. +Server entry must not re-export React/CSS views (fusion/no-plugin-view-reexport). +*/ + +const plugin = definePlugin({ + manifest: { + id: "fusion-plugin-quality", + name: "Quality", + version: "0.1.0", + description: + "Task QA tab and Quality hub: preview servers, test runs, reports, screenshots, and suggested cases", + author: "Fusion Team", + fusionVersion: ">=0.1.0", + settingsSchema, + }, + state: "installed", + hooks: { + onSchemaInit: ensureQualitySchema, + onPostgresSchemaInit: () => qualityPostgresSchema, + }, + routes: createQualityRoutes(), + dashboardViews: [ + { + viewId: "quality", + label: "Quality", + componentPath: "./dashboard-view", + icon: "ShieldCheck", + placement: "primary", + order: 32, + description: "Project quality: runs, plans, and CI", + }, + ], + uiSlots: [ + { + slotId: "task-detail-tab", + label: "QA", + icon: "FlaskConical", + componentPath: "./qa-tab", + order: 20, + }, + ], +}); + +export default plugin; + +export { ensureQualitySchema, qualityPostgresSchema } from "./quality-schema.js"; +export { QualityStore } from "./store/quality-store.js"; +export { resolvePresetCommand, isQualityPresetId, listPresetCatalog } from "./runner/command-presets.js"; +export { buildHeuristicSuggestedCases } from "./suggestions/heuristic-cases.js"; diff --git a/plugins/fusion-plugin-quality/src/preview/preview-sessions.ts b/plugins/fusion-plugin-quality/src/preview/preview-sessions.ts new file mode 100644 index 0000000000..38ce251d48 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/preview/preview-sessions.ts @@ -0,0 +1,204 @@ +import { createServer } from "node:net"; +import { superviseSpawn, type SupervisedChild } from "@fusion/core"; + +/* +FNXC:Quality 2026-07-14-21:45: +Task-scoped preview servers for QA. Supervised spawn, free port (never 4040), worktree cwd. +Composes Dev Server safety ideas without replacing the global Dev Server view. +*/ + +export type PreviewStatus = "starting" | "running" | "stopped" | "failed"; + +export interface PreviewSession { + projectId: string; + taskId: string; + status: PreviewStatus; + command: string; + cwd: string; + port?: number; + url?: string; + pid?: number; + startedAt?: string; + stoppedAt?: string; + errorMessage?: string; + logTail: string[]; +} + +const FORBIDDEN_PORT = 4040; +const MAX_TERMINAL_SESSIONS = 50; +const TERMINAL_SESSION_TTL_MS = 60 * 60 * 1000; + +async function allocateFreePort(): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + const port = await new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (addr && typeof addr === "object") { + const p = addr.port; + server.close(() => resolve(p)); + } else { + server.close(() => reject(new Error("Failed to allocate port"))); + } + }); + server.on("error", reject); + }); + if (port !== FORBIDDEN_PORT) return port; + } + throw new Error("Could not allocate a free port"); +} + +function isSafeScriptName(script: string): boolean { + return /^[a-zA-Z0-9:_-]+$/.test(script); +} + +interface LiveSession extends PreviewSession { + supervised?: SupervisedChild; +} + +/** Remove stale terminal session metadata without touching live preview processes. */ +export function pruneTerminalPreviewSessions( + sessions: Map, + now = Date.now(), +): void { + const terminal = [...sessions.entries()] + .filter(([, session]) => session.status === "stopped" || session.status === "failed") + .sort(([, left], [, right]) => Date.parse(left.stoppedAt ?? left.startedAt ?? "") - Date.parse(right.stoppedAt ?? right.startedAt ?? "")); + for (const [sessionKey, session] of terminal) { + const stoppedAt = Date.parse(session.stoppedAt ?? ""); + if (Number.isFinite(stoppedAt) && now - stoppedAt > TERMINAL_SESSION_TTL_MS) { + sessions.delete(sessionKey); + } + } + for (const [sessionKey] of terminal) { + if (sessions.size <= MAX_TERMINAL_SESSIONS) break; + sessions.delete(sessionKey); + } +} + +export function createPreviewSessionManager() { + const sessions = new Map(); + + function key(projectId: string, taskId: string): string { + return `${projectId}::${taskId}`; + } + + return { + get(projectId: string, taskId: string): PreviewSession | null { + pruneTerminalPreviewSessions(sessions); + const s = sessions.get(key(projectId, taskId)); + if (!s) return null; + const { supervised: _s, ...publicSession } = s; + return publicSession; + }, + + async start(input: { + projectId: string; + taskId: string; + cwd: string; + script: string; + }): Promise { + pruneTerminalPreviewSessions(sessions); + const k = key(input.projectId, input.taskId); + const existing = sessions.get(k); + if (existing && (existing.status === "running" || existing.status === "starting")) { + const { supervised: _s, ...pub } = existing; + return pub; + } + + if (!isSafeScriptName(input.script)) { + throw Object.assign(new Error("Invalid preview script name"), { statusCode: 400 }); + } + + const port = await allocateFreePort(); + if (port === FORBIDDEN_PORT) { + throw new Error("Refusing to bind reserved port 4040"); + } + + const command = `pnpm run ${input.script}`; + const session: LiveSession = { + projectId: input.projectId, + taskId: input.taskId, + status: "starting", + command, + cwd: input.cwd, + port, + url: `http://127.0.0.1:${port}`, + startedAt: new Date().toISOString(), + logTail: [], + }; + sessions.set(k, session); + + try { + const supervised = superviseSpawn(command, [], { + cwd: input.cwd, + shell: true, + env: { + ...process.env, + PORT: String(port), + // Common Vite / Next conventions + VITE_PORT: String(port), + }, + }); + session.supervised = supervised; + session.pid = supervised.child.pid; + session.status = "running"; + + const pushLog = (chunk: Buffer | string) => { + const line = String(chunk); + session.logTail.push(...line.split(/\r?\n/).filter(Boolean)); + if (session.logTail.length > 100) { + session.logTail = session.logTail.slice(-100); + } + // Best-effort URL detection + const m = line.match(/https?:\/\/(?:localhost|127\.0\.0\.1):\d+\S*/); + if (m) session.url = m[0]; + }; + supervised.child.stdout?.on("data", pushLog); + supervised.child.stderr?.on("data", pushLog); + supervised.child.on("close", (code) => { + session.status = code === 0 ? "stopped" : "failed"; + session.stoppedAt = new Date().toISOString(); + if (code && code !== 0) { + session.errorMessage = `Exited with code ${code}`; + } + session.supervised = undefined; + pruneTerminalPreviewSessions(sessions); + }); + } catch (err) { + session.status = "failed"; + session.errorMessage = err instanceof Error ? err.message : String(err); + session.stoppedAt = new Date().toISOString(); + pruneTerminalPreviewSessions(sessions); + } + + const { supervised: _s, ...pub } = session; + return pub; + }, + + async stop(projectId: string, taskId: string): Promise { + const k = key(projectId, taskId); + const session = sessions.get(k); + if (!session) return null; + if (session.supervised) { + /* + FNXC:Quality 2026-07-15-14:10: + A stopped preview may still be a live child that ignored SIGTERM. Keep + the captured supervisor until its close event so the escalation always + targets the original process group instead of an already-cleared field. + */ + const supervised = session.supervised; + supervised.kill("SIGTERM"); + const escalationTimer = setTimeout(() => { + supervised.kill("SIGKILL"); + }, 2000); + escalationTimer.unref?.(); + } + session.status = "stopped"; + session.stoppedAt = new Date().toISOString(); + pruneTerminalPreviewSessions(sessions); + const { supervised: _s, ...pub } = session; + return pub; + }, + }; +} diff --git a/plugins/fusion-plugin-quality/src/qa-tab.tsx b/plugins/fusion-plugin-quality/src/qa-tab.tsx new file mode 100644 index 0000000000..be22230bbc --- /dev/null +++ b/plugins/fusion-plugin-quality/src/qa-tab.tsx @@ -0,0 +1,428 @@ +import { createElement, useCallback, useEffect, useState, type ReactElement, type ReactNode } from "react"; + +/* +FNXC:Quality 2026-07-14-21:45: +Task QA tab — action-first: Preview server | Run tests | Reports | Screenshots | Suggested cases | CI. +Host injects task context via PluginSlot props (taskId, worktree, projectId). +*/ + +export interface QualityTaskContext { + taskId?: string; + worktree?: string; + projectId?: string; + title?: string; + modifiedFiles?: string[]; +} + +export interface QualityQaTabProps { + entry?: unknown; + actions?: unknown; + /** Host-injected task context (U1 contract) */ + context?: QualityTaskContext; + taskId?: string; + worktree?: string; + projectId?: string; +} + +interface RunRow { + id: string; + status: string; + command: string; + durationMs?: number; + presetId?: string; + createdAt: string; + stdout?: string; + stderr?: string; + errorMessage?: string; +} + +interface PreviewSession { + status: string; + url?: string; + port?: number; + command?: string; + errorMessage?: string; +} + +interface SuggestedCase { + id: string; + text: string; + done: boolean; +} + +function Section({ title, children, testId }: { title: string; children?: ReactNode; testId: string }): ReactElement { + return createElement( + "section", + { + "data-testid": testId, + style: { + marginBottom: 20, + padding: 12, + border: "1px solid var(--border, #3333)", + borderRadius: 8, + }, + }, + createElement("h3", { style: { marginTop: 0, marginBottom: 8, fontSize: 14 } }, title), + children, + ); +} + +export function QualityTaskQaTab(props: QualityQaTabProps): ReactElement { + const ctx = props.context ?? {}; + const taskId = props.taskId ?? ctx.taskId; + const projectId = props.projectId ?? ctx.projectId; + const worktree = props.worktree ?? ctx.worktree; + + const [runs, setRuns] = useState([]); + const [selectedRun, setSelectedRun] = useState(null); + const [preview, setPreview] = useState(null); + const [cases, setCases] = useState([]); + const [loadErrors, setLoadErrors] = useState>>({}); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const api = useCallback( + async (path: string, init?: RequestInit) => { + if (!projectId) throw new Error("projectId required"); + const sep = path.includes("?") ? "&" : "?"; + const url = `/api/plugins/fusion-plugin-quality${path}${sep}projectId=${encodeURIComponent(projectId)}`; + const res = await fetch(url, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } + return res.json(); + }, + [projectId], + ); + + const refresh = useCallback(async () => { + if (!projectId || !taskId) return; + /* + FNXC:Quality 2026-07-15-13:05: + Task QA panels are independent backend surfaces. A transient preview error + must not hide completed runs or suggested cases that loaded successfully. + */ + const [runsResult, previewResult, suggestionsResult] = await Promise.allSettled([ + api(`/runs?taskId=${encodeURIComponent(taskId)}`), + api(`/preview/${encodeURIComponent(taskId)}`), + api(`/suggestions/${encodeURIComponent(taskId)}`), + ]); + const nextErrors: Partial> = {}; + if (runsResult.status === "fulfilled") setRuns((runsResult.value as { runs?: RunRow[] }).runs ?? []); + else nextErrors.runs = runsResult.reason instanceof Error ? runsResult.reason.message : String(runsResult.reason); + if (previewResult.status === "fulfilled") setPreview((previewResult.value as { session?: PreviewSession | null }).session ?? null); + else nextErrors.preview = previewResult.reason instanceof Error ? previewResult.reason.message : String(previewResult.reason); + if (suggestionsResult.status === "fulfilled") setCases((suggestionsResult.value as { suggestions?: { cases?: SuggestedCase[] } | null }).suggestions?.cases ?? []); + else nextErrors.suggestions = suggestionsResult.reason instanceof Error ? suggestionsResult.reason.message : String(suggestionsResult.reason); + setLoadErrors(nextErrors); + }, [api, projectId, taskId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const startRun = async (preset: string, confirmFullSuite = false) => { + if (!projectId || !taskId) return; + setBusy(true); + setError(null); + try { + await api("/runs", { + method: "POST", + body: JSON.stringify({ + projectId, + taskId, + preset, + source: "task-tab", + confirmFullSuite, + }), + }); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const startPreview = async () => { + if (!taskId) return; + setBusy(true); + setError(null); + try { + const data = (await api(`/preview/${encodeURIComponent(taskId)}/start`, { + method: "POST", + body: JSON.stringify({ projectId }), + })) as { session?: PreviewSession }; + setPreview(data.session ?? null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const stopPreview = async () => { + if (!taskId) return; + setBusy(true); + try { + const data = (await api(`/preview/${encodeURIComponent(taskId)}/stop`, { + method: "POST", + body: JSON.stringify({ projectId }), + })) as { session?: PreviewSession }; + setPreview(data.session ?? null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const generateCases = async () => { + if (!taskId) return; + setBusy(true); + try { + const data = (await api(`/suggestions/${encodeURIComponent(taskId)}/generate`, { + method: "POST", + body: JSON.stringify({ projectId }), + })) as { suggestions?: { cases?: SuggestedCase[] } }; + setCases(data.suggestions?.cases ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + if (!projectId || !taskId) { + return createElement( + "div", + { "data-testid": "quality-qa-tab-missing-context", style: { padding: 12 } }, + createElement("p", null, "Task context is required for the QA tab."), + ); + } + + return createElement( + "div", + { className: "quality-qa-tab", "data-testid": "quality-qa-tab", style: { padding: 4 } }, + error + ? createElement("p", { role: "alert", style: { color: "var(--error, #c00)", marginBottom: 12 } }, error) + : null, + + // Preview server + createElement( + Section, + { title: "Preview server", testId: "quality-qa-preview" }, + !worktree + ? createElement("p", { style: { margin: 0, opacity: 0.8 } }, "Checkout/start this task to get a worktree before starting a preview server.") + : createElement( + "div", + null, + createElement( + "p", + { style: { margin: "0 0 8px", fontSize: 13, opacity: 0.85 } }, + preview + ? `Status: ${preview.status}${preview.url ? ` · ${preview.url}` : ""}${preview.port ? ` · port ${preview.port}` : ""}` + : "No preview server running for this task.", + ), + createElement( + "div", + { style: { display: "flex", gap: 8, flexWrap: "wrap" } }, + createElement( + "button", + { type: "button", className: "btn btn-sm", disabled: busy || !worktree, onClick: () => void startPreview() }, + "Start", + ), + createElement( + "button", + { type: "button", className: "btn btn-sm", disabled: busy || !preview, onClick: () => void stopPreview() }, + "Stop", + ), + preview?.url + ? createElement( + "a", + { className: "btn btn-sm", href: preview.url, target: "_blank", rel: "noreferrer" }, + "Open URL", + ) + : null, + ), + preview?.errorMessage + ? createElement("p", { style: { color: "var(--error, #c00)", fontSize: 12 } }, preview.errorMessage) + : null, + ), + loadErrors.preview + ? createElement("p", { role: "alert", style: { color: "var(--error, #c00)", margin: "8px 0 0" } }, loadErrors.preview) + : null, + ), + + // Run tests + createElement( + Section, + { title: "Run tests", testId: "quality-qa-run-tests" }, + createElement( + "p", + { style: { margin: "0 0 8px", fontSize: 12, opacity: 0.75 } }, + "Advisory local runs — do not change merge eligibility.", + ), + createElement( + "div", + { style: { display: "flex", gap: 8, flexWrap: "wrap" } }, + createElement( + "button", + { + type: "button", + className: "btn btn-sm", + disabled: busy || !worktree, + onClick: () => void startRun("file-scoped"), + }, + "File-scoped", + ), + createElement( + "button", + { + type: "button", + className: "btn btn-sm", + disabled: busy || !worktree, + onClick: () => void startRun("verify-fast"), + }, + "verify:fast", + ), + createElement( + "button", + { + type: "button", + className: "btn btn-sm", + disabled: busy || !worktree, + onClick: () => void startRun("test-gate"), + }, + "test:gate", + ), + createElement( + "button", + { + type: "button", + className: "btn btn-sm", + disabled: busy || !worktree, + onClick: () => void startRun("project-test"), + }, + "Project test", + ), + ), + !worktree + ? createElement("p", { style: { marginTop: 8, fontSize: 12, opacity: 0.8 } }, "Worktree required for targeted runs.") + : null, + ), + + // Reports + createElement( + Section, + { title: "Reports", testId: "quality-qa-reports" }, + runs.length === 0 + ? createElement("p", { style: { margin: 0, opacity: 0.7, fontSize: 13 } }, "No task runs yet.") + : createElement( + "ul", + { style: { listStyle: "none", padding: 0, margin: 0 } }, + ...runs.slice(0, 10).map((run) => + createElement( + "li", + { + key: run.id, + style: { + padding: "6px 0", + borderBottom: "1px solid var(--border, #3333)", + cursor: "pointer", + fontSize: 13, + }, + onClick: () => setSelectedRun(run), + }, + createElement("strong", null, run.status), + ` · ${run.presetId ?? "run"} · ${run.durationMs != null ? `${Math.round(run.durationMs / 1000)}s` : "…"}`, + createElement("div", { style: { fontFamily: "monospace", fontSize: 11, opacity: 0.8 } }, run.command), + ), + ), + ), + loadErrors.runs + ? createElement("p", { role: "alert", style: { color: "var(--error, #c00)", margin: "8px 0 0" } }, loadErrors.runs) + : null, + selectedRun + ? createElement( + "div", + { + "data-testid": "quality-qa-report-detail", + style: { marginTop: 10, padding: 8, background: "var(--surface-subtle, #0001)", borderRadius: 6, fontSize: 12 }, + }, + createElement("div", null, createElement("strong", null, "Report: "), selectedRun.id), + createElement("div", null, `Status: ${selectedRun.status}`), + selectedRun.errorMessage ? createElement("div", null, selectedRun.errorMessage) : null, + createElement( + "pre", + { style: { maxHeight: 160, overflow: "auto", whiteSpace: "pre-wrap" } }, + (selectedRun.stdout || selectedRun.stderr || "(no log)") as string, + ), + ) + : null, + ), + + // Screenshots + createElement( + Section, + { title: "Screenshots", testId: "quality-qa-screenshots" }, + createElement( + "p", + { style: { margin: 0, fontSize: 13, opacity: 0.8 } }, + "Task image artifacts and design-preview docs appear here when registered. Open the Artifacts view for the full gallery, or enable browser verification to capture evidence.", + ), + ), + + // Suggested cases + createElement( + Section, + { title: "Suggested test cases", testId: "quality-qa-suggestions" }, + createElement( + "div", + { style: { display: "flex", gap: 8, marginBottom: 8 } }, + createElement( + "button", + { type: "button", className: "btn btn-sm", disabled: busy, onClick: () => void generateCases() }, + cases.length ? "Regenerate" : "Generate", + ), + ), + cases.length === 0 + ? createElement("p", { style: { margin: 0, opacity: 0.7, fontSize: 13 } }, "No suggestions yet. Generate from the task prompt and file scope.") + : createElement( + "ul", + { style: { margin: 0, paddingLeft: 18, fontSize: 13 } }, + ...cases.map((c) => createElement("li", { key: c.id }, c.text)), + ), + loadErrors.suggestions + ? createElement("p", { role: "alert", style: { color: "var(--error, #c00)", margin: "8px 0 0" } }, loadErrors.suggestions) + : null, + ), + + // CI placeholder + createElement( + Section, + { title: "CI checks", testId: "quality-qa-ci" }, + createElement( + "p", + { style: { margin: 0, fontSize: 13, opacity: 0.8 } }, + "PR check status uses the host ", + createElement("code", null, "GET /api/tasks/:id/pr/checks"), + " surface when this task has a linked PR (see Task Review tab).", + ), + ), + ); +} + +/** Slot registry entry component — receives host props */ +export function QualityQaTabSlot(props: QualityQaTabProps): ReactElement { + return createElement(QualityTaskQaTab, props); +} + +export default QualityQaTabSlot; diff --git a/plugins/fusion-plugin-quality/src/quality-schema.ts b/plugins/fusion-plugin-quality/src/quality-schema.ts new file mode 100644 index 0000000000..83c3b54e85 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/quality-schema.ts @@ -0,0 +1,121 @@ +import type { Database, PluginPostgresSchemaDefinition } from "@fusion/core"; + +/* +FNXC:Quality 2026-07-14-21:45: +Plugin-owned Quality tables via onSchemaInit. projectId on every row for multi-project isolation. +*/ + +export function ensureQualitySchema(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS quality_test_runs ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + task_id TEXT, + plan_id TEXT, + source TEXT NOT NULL, + preset_id TEXT, + command TEXT NOT NULL, + cwd TEXT NOT NULL, + cwd_kind TEXT NOT NULL, + status TEXT NOT NULL, + exit_code INTEGER, + error_message TEXT, + timeout_ms INTEGER NOT NULL, + started_at TEXT, + finished_at TEXT, + duration_ms INTEGER, + stdout TEXT NOT NULL DEFAULT '', + stderr TEXT NOT NULL DEFAULT '', + triggered_by TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_quality_test_runs_project_created + ON quality_test_runs(project_id, created_at DESC, id); + + CREATE INDEX IF NOT EXISTS idx_quality_test_runs_task_created + ON quality_test_runs(project_id, task_id, created_at DESC, id); + + CREATE TABLE IF NOT EXISTS quality_test_plans ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL, + steps_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_quality_test_plans_project + ON quality_test_plans(project_id, status, updated_at DESC, id); + + CREATE TABLE IF NOT EXISTS quality_suggested_cases ( + project_id TEXT NOT NULL, + task_id TEXT NOT NULL, + cases_json TEXT NOT NULL DEFAULT '[]', + generated_at TEXT NOT NULL, + method TEXT NOT NULL, + PRIMARY KEY (project_id, task_id) + ); + `); +} + +/** + * FNXC:QualityPostgres 2026-07-15-14:55: + * Quality remains available when Fusion uses the PostgreSQL-backed task store. + * The legacy SQLite hook above serves local DatabaseSync tests, while this + * declarative contract lets the host create the same project-scoped tables + * through its privileged PostgreSQL schema executor before plugin routes run. + */ +export const qualityPostgresSchema: PluginPostgresSchemaDefinition = { + version: 1, + tablePrefix: "quality_", + statements: [ + `CREATE TABLE IF NOT EXISTS project.quality_test_runs ( + project_id text NOT NULL, + id text NOT NULL, + task_id text, + plan_id text, + source text NOT NULL, + preset_id text, + command text NOT NULL, + cwd text NOT NULL, + cwd_kind text NOT NULL, + status text NOT NULL, + exit_code integer, + error_message text, + timeout_ms integer NOT NULL, + started_at text, + finished_at text, + duration_ms integer, + stdout text NOT NULL DEFAULT '', + stderr text NOT NULL DEFAULT '', + triggered_by text NOT NULL, + created_at text NOT NULL, + updated_at text NOT NULL, + PRIMARY KEY (project_id, id) + )`, + "CREATE INDEX IF NOT EXISTS idx_quality_test_runs_project_created ON project.quality_test_runs(project_id, created_at DESC, id)", + "CREATE INDEX IF NOT EXISTS idx_quality_test_runs_task_created ON project.quality_test_runs(project_id, task_id, created_at DESC, id)", + `CREATE TABLE IF NOT EXISTS project.quality_test_plans ( + project_id text NOT NULL, + id text NOT NULL, + name text NOT NULL, + status text NOT NULL, + steps_json text NOT NULL DEFAULT '[]', + created_at text NOT NULL, + updated_at text NOT NULL, + PRIMARY KEY (project_id, id) + )`, + "CREATE INDEX IF NOT EXISTS idx_quality_test_plans_project ON project.quality_test_plans(project_id, status, updated_at DESC, id)", + `CREATE TABLE IF NOT EXISTS project.quality_suggested_cases ( + project_id text NOT NULL, + task_id text NOT NULL, + cases_json text NOT NULL DEFAULT '[]', + generated_at text NOT NULL, + method text NOT NULL, + PRIMARY KEY (project_id, task_id) + )`, + ], +}; diff --git a/plugins/fusion-plugin-quality/src/routes/create-routes.ts b/plugins/fusion-plugin-quality/src/routes/create-routes.ts new file mode 100644 index 0000000000..dc80821390 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/routes/create-routes.ts @@ -0,0 +1,415 @@ +import type { PluginContext, PluginRouteDefinition } from "@fusion/plugin-sdk"; +import type { Database } from "@fusion/core"; +import { ensureQualitySchema } from "../quality-schema.js"; +import { QualityStore } from "../store/quality-store.js"; +import { isQualityPresetId, listPresetCatalog, resolvePresetCommand } from "../runner/command-presets.js"; +import { cancelQualityRun, defaultTimeoutMs, executeQualityRun } from "../runner/command-runner.js"; +import { getAllowRootFallback, getDefaultPreviewScript, getLogTruncateKb, getRunRetentionCount } from "../settings.js"; +import { buildHeuristicSuggestedCases } from "../suggestions/heuristic-cases.js"; +import type { QualityPresetId } from "../store/quality-types.js"; +import { createPreviewSessionManager } from "../preview/preview-sessions.js"; + +/* +FNXC:Quality 2026-07-14-21:45: +Plugin routes under /api/plugins/fusion-plugin-quality/*. +Security: require projectId; server-only preset resolution; reject client command/cwd/argv. +*/ + +type Req = { + method?: string; + params?: Record; + query?: Record; + body?: unknown; +}; + +function asRecord(body: unknown): Record { + return body && typeof body === "object" && !Array.isArray(body) ? (body as Record) : {}; +} + +function requireProjectId(req: Req): string { + const q = typeof req.query?.projectId === "string" ? req.query.projectId.trim() : ""; + const body = asRecord(req.body); + const b = typeof body.projectId === "string" ? body.projectId.trim() : ""; + const id = q || b; + if (!id) { + const err = new Error("projectId is required") as Error & { statusCode?: number }; + err.statusCode = 400; + throw err; + } + return id; +} + +function getDb(ctx: PluginContext): Database { + // Prefer sync database when available (SQLite / sync facade). + return ctx.taskStore.getDatabase(); +} + +function getStore(ctx: PluginContext): QualityStore { + const db = getDb(ctx); + ensureQualitySchema(db); + return new QualityStore(db); +} + +function httpError(status: number, message: string): never { + const err = new Error(message) as Error & { statusCode?: number }; + err.statusCode = status; + throw err; +} + +const previewManager = createPreviewSessionManager(); + +const QUALITY_EXPERIMENTAL_FLAG = "qualityPlugin"; + +function requireQualityExperimental(ctx: PluginContext): void { + const settings = (typeof (ctx.taskStore as { getSettings?: () => unknown }).getSettings === "function" + ? (ctx.taskStore as { getSettings: () => unknown }).getSettings() + : {}) as { experimentalFeatures?: Record }; + if (settings.experimentalFeatures?.[QUALITY_EXPERIMENTAL_FLAG] !== true) { + httpError(404, "Quality plugin is experimental; enable experimentalFeatures.qualityPlugin to use it"); + } +} + +/* +FNXC:Quality 2026-07-15-13:05: +Test plans are execution contracts: silently dropping an unknown requested +preset makes a successful API response misrepresent the plan that was saved. +Reject the entire request unless every supplied step is allowlisted. +*/ +export function validatePlanSteps(stepsRaw: unknown[]): QualityPresetId[] { + if (stepsRaw.length === 0) httpError(400, "steps must include at least one known preset"); + const invalid = stepsRaw.filter((step) => !isQualityPresetId(step)); + if (invalid.length > 0) { + httpError(400, `Unknown plan steps: ${invalid.map(String).join(", ")}`); + } + return stepsRaw as QualityPresetId[]; +} + +export function createQualityRoutes(): PluginRouteDefinition[] { + const routes: PluginRouteDefinition[] = [ + { + method: "GET", + path: "/presets", + description: "List allowlisted Quality test presets", + handler: async () => ({ presets: listPresetCatalog() }), + }, + { + method: "GET", + path: "/runs", + description: "List Quality test runs for a project", + handler: async (req, ctx) => { + const r = req as Req; + const projectId = requireProjectId(r); + const store = getStore(ctx); + const taskId = typeof r.query?.taskId === "string" ? r.query.taskId : undefined; + const limit = typeof r.query?.limit === "string" ? Number(r.query.limit) : 50; + return { runs: store.listRuns(projectId, { taskId, limit }) }; + }, + }, + { + method: "GET", + path: "/runs/:runId", + description: "Get a single Quality test run", + handler: async (req, ctx) => { + const r = req as Req; + const projectId = requireProjectId(r); + const runId = r.params?.runId; + if (!runId) httpError(400, "runId required"); + const run = getStore(ctx).getRun(projectId, runId); + if (!run) httpError(404, "Run not found"); + return { run }; + }, + }, + { + method: "POST", + path: "/runs", + description: "Start an allowlisted Quality test run", + handler: async (req, ctx) => { + const r = req as Req; + const body = asRecord(r.body); + // Reject free-form execution inputs + if ("command" in body || "argv" in body || "cwd" in body || "shell" in body) { + httpError(400, "command/argv/cwd/shell overrides are not allowed"); + } + const projectId = requireProjectId(r); + if (!isQualityPresetId(body.preset)) { + httpError(400, "preset must be a known Quality preset id"); + } + const preset = body.preset as QualityPresetId; + const taskId = typeof body.taskId === "string" ? body.taskId.trim() : undefined; + const confirmFullSuite = body.confirmFullSuite === true; + const source = body.source === "hub" ? "hub" : "task-tab"; + + const store = getStore(ctx); + const active = store.findActiveRun(projectId, taskId); + if (active) { + httpError(409, `A run is already active (${active.id})`); + } + + // Resolve cwd server-side + const rootDir = ctx.taskStore.getRootDir?.() ?? process.cwd(); + let cwd = rootDir; + let cwdKind: "project-root" | "worktree" = "project-root"; + let filePaths: string[] = []; + + if (taskId) { + let task: { id: string; worktree?: string; modifiedFiles?: string[]; title?: string }; + try { + task = (await ctx.taskStore.getTask(taskId)) as { + id: string; + worktree?: string; + modifiedFiles?: string[]; + title?: string; + }; + } catch { + httpError(404, "Task not found"); + } + const worktree = typeof task.worktree === "string" ? task.worktree.trim() : ""; + if (worktree) { + cwd = worktree; + cwdKind = "worktree"; + } else if (!getAllowRootFallback(ctx.settings as Record)) { + httpError(400, "Task has no worktree; start/checkout the task first"); + } + filePaths = Array.isArray(task.modifiedFiles) + ? task.modifiedFiles.filter((p): p is string => typeof p === "string") + : []; + } else if (source === "task-tab") { + httpError(400, "taskId is required for task-tab runs"); + } + + // Optional filePaths only for server enrichment when provided as string[] of relative paths + if (Array.isArray(body.filePaths) && filePaths.length === 0) { + filePaths = body.filePaths.filter((p): p is string => typeof p === "string"); + } + + const settings = (typeof (ctx.taskStore as { getSettings?: () => unknown }).getSettings === "function" + ? (ctx.taskStore as { getSettings: () => unknown }).getSettings() + : {}) as { testCommand?: string; verificationCommandTimeoutMs?: number }; + const resolved = resolvePresetCommand({ + preset, + testCommand: settings.testCommand, + projectRoot: rootDir, + filePaths, + confirmFullSuite, + }); + if (!resolved.ok) { + const status = resolved.code === "confirm_required" ? 400 : 400; + httpError(status, resolved.reason); + } + + const timeoutMs = defaultTimeoutMs(settings.verificationCommandTimeoutMs); + const run = store.createRun({ + projectId, + taskId, + source, + presetId: preset, + command: resolved.command, + cwd, + cwdKind, + timeoutMs, + triggeredBy: "operator", + }); + + // Detach execution — do not block the HTTP response on full suite runtime + void executeQualityRun({ + store, + projectId, + runId: run.id, + command: resolved.command, + cwd, + timeoutMs, + logTruncateKb: getLogTruncateKb(ctx.settings as Record), + }) + .then(() => { + store.pruneRuns(projectId, getRunRetentionCount(ctx.settings as Record)); + }) + .catch((err) => { + ctx.logger?.warn?.( + `Quality run ${run.id} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + store.updateRun(projectId, run.id, { + status: "error", + errorMessage: err instanceof Error ? err.message : String(err), + finishedAt: new Date().toISOString(), + }); + }); + + return { run, detached: true }; + }, + }, + { + method: "POST", + path: "/runs/:runId/cancel", + description: "Mark a queued/running run cancelled (best-effort)", + handler: async (req, ctx) => { + const r = req as Req; + const projectId = requireProjectId(r); + const runId = r.params?.runId; + if (!runId) httpError(400, "runId required"); + const store = getStore(ctx); + const run = store.getRun(projectId, runId); + if (!run) httpError(404, "Run not found"); + if (run.status !== "queued" && run.status !== "running") { + return { run }; + } + const updated = cancelQualityRun(store, projectId, runId); + return { run: updated }; + }, + }, + { + method: "GET", + path: "/plans", + description: "List test plans", + handler: async (req, ctx) => { + const r = req as Req; + const projectId = requireProjectId(r); + return { plans: getStore(ctx).listPlans(projectId) }; + }, + }, + { + method: "POST", + path: "/plans", + description: "Create a test plan", + handler: async (req, ctx) => { + const r = req as Req; + const projectId = requireProjectId(r); + const body = asRecord(r.body); + const name = typeof body.name === "string" ? body.name.trim() : ""; + if (!name) httpError(400, "name is required"); + const steps = validatePlanSteps(Array.isArray(body.steps) ? body.steps : []); + const plan = getStore(ctx).createPlan({ projectId, name, steps }); + return { plan }; + }, + }, + { + method: "GET", + path: "/suggestions/:taskId", + description: "Get suggested test cases for a task", + handler: async (req, ctx) => { + const r = req as Req; + const projectId = requireProjectId(r); + const taskId = r.params?.taskId; + if (!taskId) httpError(400, "taskId required"); + const existing = getStore(ctx).getSuggestedCases(projectId, taskId); + return { suggestions: existing }; + }, + }, + { + method: "POST", + path: "/suggestions/:taskId/generate", + description: "Generate heuristic suggested test cases", + handler: async (req, ctx) => { + const r = req as Req; + const projectId = requireProjectId(r); + const taskId = r.params?.taskId; + if (!taskId) httpError(400, "taskId required"); + let task: { title?: string; prompt?: string; description?: string; modifiedFiles?: string[] }; + try { + task = (await ctx.taskStore.getTask(taskId)) as { + title?: string; + prompt?: string; + description?: string; + modifiedFiles?: string[]; + }; + } catch { + httpError(404, "Task not found"); + } + const body = asRecord(r.body); + const prompt = + typeof body.prompt === "string" + ? body.prompt + : (task.prompt ?? task.description ?? ""); + const cases = buildHeuristicSuggestedCases({ + title: task.title, + prompt, + filePaths: Array.isArray(task.modifiedFiles) + ? task.modifiedFiles.filter((p): p is string => typeof p === "string") + : [], + }); + const snapshot = getStore(ctx).saveSuggestedCases({ + projectId, + taskId, + cases, + generatedAt: new Date().toISOString(), + method: "heuristic", + }); + return { suggestions: snapshot }; + }, + }, + { + method: "GET", + path: "/preview/:taskId", + description: "Get task preview server session", + handler: async (req) => { + const r = req as Req; + const projectId = requireProjectId(r); + const taskId = r.params?.taskId; + if (!taskId) httpError(400, "taskId required"); + return { session: previewManager.get(projectId, taskId) }; + }, + }, + { + method: "POST", + path: "/preview/:taskId/start", + description: "Start task-scoped preview server", + handler: async (req, ctx) => { + const r = req as Req; + const projectId = requireProjectId(r); + const taskId = r.params?.taskId; + if (!taskId) httpError(400, "taskId required"); + let task: { worktree?: string }; + try { + task = (await ctx.taskStore.getTask(taskId)) as { worktree?: string }; + } catch { + httpError(404, "Task not found"); + } + const worktree = typeof task.worktree === "string" ? task.worktree.trim() : ""; + if (!worktree) httpError(400, "Task has no worktree"); + const body = asRecord(r.body); + if ("command" in body && typeof body.command === "string") { + // Only allow simple package script names, not free shell + if (!/^[a-zA-Z0-9:_-]+$/.test(body.command.trim())) { + httpError(400, "preview command must be a package script name"); + } + } + const script = + typeof body.command === "string" && body.command.trim() + ? body.command.trim() + : getDefaultPreviewScript(ctx.settings as Record); + const session = await previewManager.start({ + projectId, + taskId, + cwd: worktree, + script, + }); + return { session }; + }, + }, + { + method: "POST", + path: "/preview/:taskId/stop", + description: "Stop task-scoped preview server", + handler: async (req) => { + const r = req as Req; + const projectId = requireProjectId(r); + const taskId = r.params?.taskId; + if (!taskId) httpError(400, "taskId required"); + const session = await previewManager.stop(projectId, taskId); + return { session }; + }, + }, + ]; + return routes.map((route) => ({ + ...route, + handler: async (req, ctx) => { + /* + FNXC:Quality 2026-07-15-14:10: + The Quality plugin is an opt-in experiment. Gate every route at the + server boundary so installed bundles cannot run commands until a global + operator explicitly enables experimentalFeatures.qualityPlugin. + */ + requireQualityExperimental(ctx); + return route.handler(req, ctx); + }, + })); +} diff --git a/plugins/fusion-plugin-quality/src/runner/command-presets.ts b/plugins/fusion-plugin-quality/src/runner/command-presets.ts new file mode 100644 index 0000000000..55f7eecb8b --- /dev/null +++ b/plugins/fusion-plugin-quality/src/runner/command-presets.ts @@ -0,0 +1,105 @@ +import type { QualityPresetId } from "../store/quality-types.js"; + +/* +FNXC:Quality 2026-07-14-21:45: +Allowlisted preset id → command mapping. Server resolves only; clients never supply command/argv/cwd. +full-suite requires explicit confirmFullSuite. file-scoped builds from server-side path list. +*/ + +export const QUALITY_PRESET_IDS: readonly QualityPresetId[] = [ + "project-test", + "test-gate", + "verify-fast", + "file-scoped", + "full-suite", +] as const; + +export function isQualityPresetId(value: unknown): value is QualityPresetId { + return typeof value === "string" && (QUALITY_PRESET_IDS as readonly string[]).includes(value); +} + +export interface ResolvePresetInput { + preset: QualityPresetId; + /** Project settings.testCommand when set */ + testCommand?: string | null; + /** Absolute project root */ + projectRoot: string; + /** File paths relative to project/worktree for file-scoped preset */ + filePaths?: string[]; + confirmFullSuite?: boolean; +} + +export type ResolvePresetResult = + | { ok: true; command: string; label: string } + | { ok: false; reason: string; code: "unknown_preset" | "disabled" | "confirm_required" | "empty_files" }; + +/** + * Reject path tokens that could escape the worktree or inject shell metacharacters. + */ +export function isSafeFilePathToken(path: string): boolean { + if (!path || typeof path !== "string") return false; + if (path.includes("\0") || path.includes("\n") || path.includes("\r")) return false; + if (path.startsWith("/") || path.includes("..")) return false; + // Disallow shell metacharacters when paths are joined into a shell command string. + if (/[;&|`$<>\\]/.test(path)) return false; + return true; +} + +export function resolvePresetCommand(input: ResolvePresetInput): ResolvePresetResult { + switch (input.preset) { + case "project-test": { + const cmd = (input.testCommand ?? "").trim(); + if (!cmd) { + return { + ok: false, + reason: "Project testCommand is not configured", + code: "disabled", + }; + } + return { ok: true, command: cmd, label: "Project test" }; + } + case "test-gate": + return { ok: true, command: "pnpm test:gate", label: "Merge gate tests" }; + case "verify-fast": + return { ok: true, command: "pnpm verify:fast", label: "Verify fast (test-free)" }; + case "file-scoped": { + const paths = (input.filePaths ?? []).map((p) => p.trim()).filter(Boolean); + if (paths.length === 0) { + return { ok: false, reason: "No changed files for file-scoped run", code: "empty_files" }; + } + const safe = paths.filter(isSafeFilePathToken); + if (safe.length === 0) { + return { ok: false, reason: "No safe file paths for file-scoped run", code: "empty_files" }; + } + // Prefer vitest path list; keep command server-built. + const joined = safe.map((p) => JSON.stringify(p)).join(" "); + return { + ok: true, + command: `pnpm exec vitest run ${joined}`, + label: "File-scoped tests", + }; + } + case "full-suite": { + if (!input.confirmFullSuite) { + return { + ok: false, + reason: "full-suite requires confirmFullSuite: true", + code: "confirm_required", + }; + } + return { ok: true, command: "pnpm test:full", label: "Full suite (opt-in)" }; + } + default: + return { ok: false, reason: "Unknown preset", code: "unknown_preset" }; + } +} + +export function listPresetCatalog(): Array<{ id: QualityPresetId; label: string; needsConfirm?: boolean }> { + return [ + { id: "project-test", label: "Project test" }, + { id: "test-gate", label: "Merge gate (test:gate)" }, + { id: "verify-fast", label: "Verify fast" }, + { id: "file-scoped", label: "File-scoped" }, + { id: "full-suite", label: "Full suite", needsConfirm: true }, + ]; +} diff --git a/plugins/fusion-plugin-quality/src/runner/command-runner.ts b/plugins/fusion-plugin-quality/src/runner/command-runner.ts new file mode 100644 index 0000000000..7eac528664 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/runner/command-runner.ts @@ -0,0 +1,154 @@ +import { superviseSpawn } from "@fusion/core"; +import type { QualityStore } from "../store/quality-store.js"; +import type { TestRun, TestRunStatus } from "../store/quality-types.js"; + +/* +FNXC:Quality 2026-07-14-21:45: +Supervised command runner for Quality TestRuns. Uses superviseSpawn (core + packaging shim). +Hard timeout with process-group kill; truncates logs; never accepts client command/cwd. +*/ + +const HARD_TIMEOUT_MS = 1_800_000; +type ActiveQualityRun = Pick, "kill">; + +const activeQualityRuns = new Map(); + +function activeRunKey(projectId: string, runId: string): string { + return `${projectId}:${runId}`; +} + +/* +FNXC:Quality 2026-07-15-13:05: +Operator cancellation is a process-control action, not only a database update. +Keep each live supervisor by project/run so the cancel route can terminate its +process group, while the runner's final write preserves the cancelled terminal +state if the child closes after that request. +*/ +export function cancelQualityRun(store: QualityStore, projectId: string, runId: string): TestRun | null { + const current = store.getRun(projectId, runId); + if (!current || (current.status !== "queued" && current.status !== "running")) return current; + + activeQualityRuns.get(activeRunKey(projectId, runId))?.kill("SIGTERM"); + return store.updateRun(projectId, runId, { + status: "cancelled", + finishedAt: new Date().toISOString(), + errorMessage: "Cancelled by operator", + }); +} + +export function __clearActiveQualityRunsForTests(): void { + activeQualityRuns.clear(); +} + +export function __registerActiveQualityRunForTests(projectId: string, runId: string, run: ActiveQualityRun): void { + activeQualityRuns.set(activeRunKey(projectId, runId), run); +} + +export interface RunCommandOptions { + store: QualityStore; + projectId: string; + runId: string; + command: string; + cwd: string; + timeoutMs: number; + logTruncateKb: number; + shell?: boolean; +} + +function truncate(text: string, maxKb: number): string { + const max = Math.max(1, maxKb) * 1024; + if (text.length <= max) return text; + return text.slice(text.length - max); +} + +export async function executeQualityRun(opts: RunCommandOptions): Promise { + const { store, projectId, runId, command, cwd } = opts; + const timeoutMs = Math.min(Math.max(opts.timeoutMs, 1_000), HARD_TIMEOUT_MS); + const startedAt = new Date().toISOString(); + store.updateRun(projectId, runId, { status: "running", startedAt }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + let exitCode: number | null = null; + let status: TestRunStatus = "error"; + let errorMessage: string | null = null; + + try { + const supervised = superviseSpawn(command, [], { + cwd, + shell: opts.shell !== false, + env: process.env, + }); + activeQualityRuns.set(activeRunKey(projectId, runId), supervised); + + const child = supervised.child; + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout = truncate(stdout + String(chunk), opts.logTruncateKb); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr = truncate(stderr + String(chunk), opts.logTruncateKb); + }); + + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + const timer = setTimeout(() => { + timedOut = true; + supervised.kill("SIGTERM"); + setTimeout(() => { + supervised.kill("SIGKILL"); + }, 2_000); + }, timeoutMs); + + child.on("close", (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }); + child.on("error", (err) => { + clearTimeout(timer); + errorMessage = err instanceof Error ? err.message : String(err); + resolve({ code: null, signal: null }); + }); + }); + + exitCode = result.code; + if (timedOut) { + status = "timed_out"; + errorMessage = errorMessage ?? `Timed out after ${timeoutMs}ms`; + } else if (errorMessage) { + status = "error"; + } else if (exitCode === 0) { + status = "passed"; + } else { + status = "failed"; + } + } catch (err) { + status = "error"; + errorMessage = err instanceof Error ? err.message : String(err); + } + + const finishedAt = new Date().toISOString(); + const durationMs = Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt)); + const current = store.getRun(projectId, runId); + const wasCancelled = current?.status === "cancelled"; + const updated = store.updateRun(projectId, runId, { + status: wasCancelled ? "cancelled" : status, + exitCode, + errorMessage: wasCancelled ? current.errorMessage ?? "Cancelled by operator" : errorMessage, + finishedAt, + durationMs, + stdout, + stderr, + }); + if (!updated) { + throw new Error(`Quality run ${runId} missing after execution`); + } + activeQualityRuns.delete(activeRunKey(projectId, runId)); + return updated; +} + +export function defaultTimeoutMs(verificationCommandTimeoutMs?: number): number { + if (typeof verificationCommandTimeoutMs === "number" && verificationCommandTimeoutMs > 0) { + return Math.min(verificationCommandTimeoutMs, HARD_TIMEOUT_MS); + } + return 300_000; +} diff --git a/plugins/fusion-plugin-quality/src/settings.ts b/plugins/fusion-plugin-quality/src/settings.ts new file mode 100644 index 0000000000..69c9afec0a --- /dev/null +++ b/plugins/fusion-plugin-quality/src/settings.ts @@ -0,0 +1,53 @@ +import type { PluginSettingSchema } from "@fusion/plugin-sdk"; + +/* +FNXC:Quality 2026-07-14-21:45: +Quality plugin settings control retention, log size, and optional root-fallback for task runs. +Defaults keep history bounded and require worktree for task-scoped commands unless operators opt in. +*/ + +export const settingsSchema: Record = { + runRetentionCount: { + type: "number", + label: "Run history retention", + description: "Max finished test runs kept per project. Default: 50.", + defaultValue: 50, + }, + logTruncateKb: { + type: "number", + label: "Log truncate (KB)", + description: "Max stdout/stderr stored per run in kilobytes. Default: 64.", + defaultValue: 64, + }, + allowRootFallback: { + type: "boolean", + label: "Allow project-root fallback for task runs", + description: "When a task has no worktree, allow targeted runs on project root. Default: false (block).", + defaultValue: false, + }, + defaultPreviewScript: { + type: "string", + label: "Default preview script", + description: "Package script used for task preview servers when Dev Server has no selection. Default: dev.", + defaultValue: "dev", + }, +}; + +export function getRunRetentionCount(settings: Record | undefined): number { + const n = settings?.runRetentionCount; + return typeof n === "number" && n > 0 ? Math.floor(n) : 50; +} + +export function getLogTruncateKb(settings: Record | undefined): number { + const n = settings?.logTruncateKb; + return typeof n === "number" && n > 0 ? Math.floor(n) : 64; +} + +export function getAllowRootFallback(settings: Record | undefined): boolean { + return settings?.allowRootFallback === true; +} + +export function getDefaultPreviewScript(settings: Record | undefined): string { + const s = settings?.defaultPreviewScript; + return typeof s === "string" && s.trim() ? s.trim() : "dev"; +} diff --git a/plugins/fusion-plugin-quality/src/store/quality-store.ts b/plugins/fusion-plugin-quality/src/store/quality-store.ts new file mode 100644 index 0000000000..00befe8a9e --- /dev/null +++ b/plugins/fusion-plugin-quality/src/store/quality-store.ts @@ -0,0 +1,364 @@ +import { randomUUID } from "node:crypto"; +import type { Database } from "@fusion/core"; +import type { + CreateTestPlanInput, + CreateTestRunInput, + QualityPresetId, + SuggestedCase, + SuggestedCasesSnapshot, + TestPlan, + TestPlanStatus, + TestRun, + TestRunStatus, +} from "./quality-types.js"; + +type RunRow = { + id: string; + project_id: string; + task_id: string | null; + plan_id: string | null; + source: string; + preset_id: string | null; + command: string; + cwd: string; + cwd_kind: string; + status: string; + exit_code: number | null; + error_message: string | null; + timeout_ms: number; + started_at: string | null; + finished_at: string | null; + duration_ms: number | null; + stdout: string; + stderr: string; + triggered_by: string; + created_at: string; + updated_at: string; +}; + +type PlanRow = { + id: string; + project_id: string; + name: string; + status: string; + steps_json: string; + created_at: string; + updated_at: string; +}; + +function mapRun(row: RunRow): TestRun { + return { + id: row.id, + projectId: row.project_id, + taskId: row.task_id ?? undefined, + planId: row.plan_id ?? undefined, + source: row.source as TestRun["source"], + presetId: (row.preset_id as QualityPresetId | null) ?? undefined, + command: row.command, + cwd: row.cwd, + cwdKind: row.cwd_kind as TestRun["cwdKind"], + status: row.status as TestRunStatus, + exitCode: row.exit_code ?? undefined, + errorMessage: row.error_message ?? undefined, + timeoutMs: row.timeout_ms, + startedAt: row.started_at ?? undefined, + finishedAt: row.finished_at ?? undefined, + durationMs: row.duration_ms ?? undefined, + stdout: row.stdout ?? "", + stderr: row.stderr ?? "", + triggeredBy: row.triggered_by, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function mapPlan(row: PlanRow): TestPlan { + let steps: QualityPresetId[] = []; + try { + const parsed = JSON.parse(row.steps_json) as unknown; + if (Array.isArray(parsed)) { + steps = parsed.filter((s): s is QualityPresetId => typeof s === "string"); + } + } catch { + steps = []; + } + return { + id: row.id, + projectId: row.project_id, + name: row.name, + status: row.status as TestPlanStatus, + steps, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export class QualityStore { + constructor(private readonly db: Database) {} + + createRun(input: CreateTestRunInput): TestRun { + const now = new Date().toISOString(); + const id = `qrun_${randomUUID()}`; + this.db + .prepare( + `INSERT INTO quality_test_runs ( + id, project_id, task_id, plan_id, source, preset_id, command, cwd, cwd_kind, + status, timeout_ms, stdout, stderr, triggered_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, '', '', ?, ?, ?)`, + ) + .run( + id, + input.projectId, + input.taskId ?? null, + input.planId ?? null, + input.source, + input.presetId ?? null, + input.command, + input.cwd, + input.cwdKind, + input.timeoutMs, + input.triggeredBy, + now, + now, + ); + return this.getRun(input.projectId, id)!; + } + + getRun(projectId: string, id: string): TestRun | null { + const row = this.db + .prepare(`SELECT * FROM quality_test_runs WHERE id = ? AND project_id = ?`) + .get(id, projectId) as RunRow | undefined; + return row ? mapRun(row) : null; + } + + listRuns(projectId: string, opts?: { taskId?: string; limit?: number }): TestRun[] { + const limit = opts?.limit && opts.limit > 0 ? Math.min(opts.limit, 200) : 50; + if (opts?.taskId) { + const rows = this.db + .prepare( + `SELECT * FROM quality_test_runs + WHERE project_id = ? AND task_id = ? + ORDER BY created_at DESC, id DESC LIMIT ?`, + ) + .all(projectId, opts.taskId, limit) as RunRow[]; + return rows.map(mapRun); + } + const rows = this.db + .prepare( + `SELECT * FROM quality_test_runs + WHERE project_id = ? + ORDER BY created_at DESC, id DESC LIMIT ?`, + ) + .all(projectId, limit) as RunRow[]; + return rows.map(mapRun); + } + + updateRun( + projectId: string, + id: string, + patch: Partial<{ + status: TestRunStatus; + exitCode: number | null; + errorMessage: string | null; + startedAt: string | null; + finishedAt: string | null; + durationMs: number | null; + stdout: string; + stderr: string; + }>, + ): TestRun | null { + const existing = this.getRun(projectId, id); + if (!existing) return null; + const now = new Date().toISOString(); + this.db + .prepare( + `UPDATE quality_test_runs SET + status = ?, + exit_code = ?, + error_message = ?, + started_at = ?, + finished_at = ?, + duration_ms = ?, + stdout = ?, + stderr = ?, + updated_at = ? + WHERE id = ? AND project_id = ?`, + ) + .run( + patch.status ?? existing.status, + patch.exitCode !== undefined ? patch.exitCode : (existing.exitCode ?? null), + patch.errorMessage !== undefined ? patch.errorMessage : (existing.errorMessage ?? null), + patch.startedAt !== undefined ? patch.startedAt : (existing.startedAt ?? null), + patch.finishedAt !== undefined ? patch.finishedAt : (existing.finishedAt ?? null), + patch.durationMs !== undefined ? patch.durationMs : (existing.durationMs ?? null), + patch.stdout !== undefined ? patch.stdout : existing.stdout, + patch.stderr !== undefined ? patch.stderr : existing.stderr, + now, + id, + projectId, + ); + return this.getRun(projectId, id); + } + + pruneRuns(projectId: string, retention: number): number { + if (retention <= 0) return 0; + const result = this.db + .prepare( + `DELETE FROM quality_test_runs + WHERE project_id = ? + AND status NOT IN ('queued', 'running') + AND id NOT IN ( + SELECT id FROM quality_test_runs + WHERE project_id = ? AND status NOT IN ('queued', 'running') + ORDER BY created_at DESC, id DESC + LIMIT ? + )`, + ) + .run(projectId, projectId, retention); + return Number(result.changes ?? 0); + } + + findActiveRun(projectId: string, taskId?: string): TestRun | null { + if (taskId) { + const row = this.db + .prepare( + `SELECT * FROM quality_test_runs + WHERE project_id = ? AND task_id = ? AND status IN ('queued', 'running') + ORDER BY created_at DESC LIMIT 1`, + ) + .get(projectId, taskId) as RunRow | undefined; + return row ? mapRun(row) : null; + } + const row = this.db + .prepare( + `SELECT * FROM quality_test_runs + WHERE project_id = ? AND task_id IS NULL AND status IN ('queued', 'running') + ORDER BY created_at DESC LIMIT 1`, + ) + .get(projectId) as RunRow | undefined; + return row ? mapRun(row) : null; + } + + createPlan(input: CreateTestPlanInput): TestPlan { + const now = new Date().toISOString(); + const id = `qplan_${randomUUID()}`; + this.db + .prepare( + `INSERT INTO quality_test_plans (id, project_id, name, status, steps_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + id, + input.projectId, + input.name, + input.status ?? "active", + JSON.stringify(input.steps), + now, + now, + ); + return this.getPlan(input.projectId, id)!; + } + + getPlan(projectId: string, id: string): TestPlan | null { + const row = this.db + .prepare(`SELECT * FROM quality_test_plans WHERE id = ? AND project_id = ?`) + .get(id, projectId) as PlanRow | undefined; + return row ? mapPlan(row) : null; + } + + listPlans(projectId: string, opts?: { includeArchived?: boolean }): TestPlan[] { + if (opts?.includeArchived) { + const rows = this.db + .prepare( + `SELECT * FROM quality_test_plans WHERE project_id = ? + ORDER BY updated_at DESC, id DESC`, + ) + .all(projectId) as PlanRow[]; + return rows.map(mapPlan); + } + const rows = this.db + .prepare( + `SELECT * FROM quality_test_plans + WHERE project_id = ? AND status != 'archived' + ORDER BY updated_at DESC, id DESC`, + ) + .all(projectId) as PlanRow[]; + return rows.map(mapPlan); + } + + updatePlan( + projectId: string, + id: string, + patch: Partial<{ name: string; status: TestPlanStatus; steps: QualityPresetId[] }>, + ): TestPlan | null { + const existing = this.getPlan(projectId, id); + if (!existing) return null; + const now = new Date().toISOString(); + this.db + .prepare( + `UPDATE quality_test_plans SET name = ?, status = ?, steps_json = ?, updated_at = ? + WHERE id = ? AND project_id = ?`, + ) + .run( + patch.name ?? existing.name, + patch.status ?? existing.status, + JSON.stringify(patch.steps ?? existing.steps), + now, + id, + projectId, + ); + return this.getPlan(projectId, id); + } + + getSuggestedCases(projectId: string, taskId: string): SuggestedCasesSnapshot | null { + const row = this.db + .prepare( + `SELECT project_id, task_id, cases_json, generated_at, method + FROM quality_suggested_cases WHERE project_id = ? AND task_id = ?`, + ) + .get(projectId, taskId) as + | { + project_id: string; + task_id: string; + cases_json: string; + generated_at: string; + method: string; + } + | undefined; + if (!row) return null; + let cases: SuggestedCase[] = []; + try { + const parsed = JSON.parse(row.cases_json) as unknown; + if (Array.isArray(parsed)) cases = parsed as SuggestedCase[]; + } catch { + cases = []; + } + return { + projectId: row.project_id, + taskId: row.task_id, + cases, + generatedAt: row.generated_at, + method: row.method as SuggestedCasesSnapshot["method"], + }; + } + + saveSuggestedCases(snapshot: SuggestedCasesSnapshot): SuggestedCasesSnapshot { + this.db + .prepare( + `INSERT INTO quality_suggested_cases (project_id, task_id, cases_json, generated_at, method) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(project_id, task_id) DO UPDATE SET + cases_json = excluded.cases_json, + generated_at = excluded.generated_at, + method = excluded.method`, + ) + .run( + snapshot.projectId, + snapshot.taskId, + JSON.stringify(snapshot.cases), + snapshot.generatedAt, + snapshot.method, + ); + return snapshot; + } +} diff --git a/plugins/fusion-plugin-quality/src/store/quality-types.ts b/plugins/fusion-plugin-quality/src/store/quality-types.ts new file mode 100644 index 0000000000..8f6eaddb7d --- /dev/null +++ b/plugins/fusion-plugin-quality/src/store/quality-types.ts @@ -0,0 +1,97 @@ +/* +FNXC:Quality 2026-07-14-21:45: +TestRun/TestPlan domain for the Quality plugin. Status enums match the plan lifecycle; +duration fields support the hub/task report viewer without inventing client-side timing. +*/ + +export type TestRunStatus = + | "queued" + | "running" + | "passed" + | "failed" + | "timed_out" + | "cancelled" + | "error"; + +export type TestRunSource = "hub" | "task-tab" | "workflow" | "agent-qa"; + +export type CwdKind = "project-root" | "worktree"; + +export type QualityPresetId = + | "project-test" + | "test-gate" + | "verify-fast" + | "file-scoped" + | "full-suite"; + +export type TestPlanStatus = "draft" | "active" | "archived"; + +export interface TestRun { + id: string; + projectId: string; + taskId?: string; + planId?: string; + source: TestRunSource; + presetId?: QualityPresetId; + command: string; + cwd: string; + cwdKind: CwdKind; + status: TestRunStatus; + exitCode?: number; + errorMessage?: string; + timeoutMs: number; + startedAt?: string; + finishedAt?: string; + durationMs?: number; + stdout: string; + stderr: string; + triggeredBy: string; + createdAt: string; + updatedAt: string; +} + +export interface TestPlan { + id: string; + projectId: string; + name: string; + status: TestPlanStatus; + /** Ordered allowlisted preset ids only */ + steps: QualityPresetId[]; + createdAt: string; + updatedAt: string; +} + +export interface SuggestedCase { + id: string; + text: string; + done: boolean; + source: "heuristic" | "ai" | "manual"; +} + +export interface SuggestedCasesSnapshot { + projectId: string; + taskId: string; + cases: SuggestedCase[]; + generatedAt: string; + method: "heuristic" | "ai" | "mixed"; +} + +export interface CreateTestRunInput { + projectId: string; + taskId?: string; + planId?: string; + source: TestRunSource; + presetId?: QualityPresetId; + command: string; + cwd: string; + cwdKind: CwdKind; + timeoutMs: number; + triggeredBy: string; +} + +export interface CreateTestPlanInput { + projectId: string; + name: string; + steps: QualityPresetId[]; + status?: TestPlanStatus; +} diff --git a/plugins/fusion-plugin-quality/src/suggestions/heuristic-cases.ts b/plugins/fusion-plugin-quality/src/suggestions/heuristic-cases.ts new file mode 100644 index 0000000000..25e33565a3 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/suggestions/heuristic-cases.ts @@ -0,0 +1,92 @@ +import { randomUUID } from "node:crypto"; +import type { SuggestedCase } from "../store/quality-types.js"; + +/* +FNXC:Quality 2026-07-14-21:45: +Heuristic suggested test cases from PROMPT text and file paths — always available without AI. +Advisory only; never merge-blocking. +*/ + +export interface HeuristicInput { + title?: string; + prompt?: string; + filePaths?: string[]; +} + +function uniqueCases(cases: SuggestedCase[]): SuggestedCase[] { + const seen = new Set(); + const out: SuggestedCase[] = []; + for (const c of cases) { + const key = c.text.trim().toLowerCase(); + if (!key || seen.has(key)) continue; + seen.add(key); + out.push(c); + } + return out; +} + +function caseOf(text: string, source: SuggestedCase["source"] = "heuristic"): SuggestedCase { + return { id: `sc_${randomUUID()}`, text, done: false, source }; +} + +/** Extract acceptance-like bullets from markdown prompt. */ +export function extractPromptBullets(prompt: string): string[] { + const lines = prompt.split(/\r?\n/); + const bullets: string[] = []; + for (const line of lines) { + const m = line.match(/^\s*(?:[-*]|\d+\.)\s+(.+)$/); + if (m?.[1]) bullets.push(m[1].trim()); + const heading = line.match(/^#{1,3}\s+(.+)$/); + if (heading?.[1] && /accept|verif|test|symptom|repro/i.test(heading[1])) { + bullets.push(`Review section: ${heading[1].trim()}`); + } + } + return bullets.slice(0, 12); +} + +export function buildHeuristicSuggestedCases(input: HeuristicInput): SuggestedCase[] { + const cases: SuggestedCase[] = []; + const title = (input.title ?? "").trim(); + const prompt = (input.prompt ?? "").trim(); + const files = (input.filePaths ?? []).map((f) => f.trim()).filter(Boolean); + + if (title) { + cases.push(caseOf(`Manually verify: ${title}`)); + } + if (/bug|fix|regress/i.test(`${title}\n${prompt}`)) { + cases.push(caseOf("Reproduce the original symptom and confirm it no longer occurs")); + cases.push(caseOf("Check related empty/error/loading states on the same surface")); + } + + for (const bullet of extractPromptBullets(prompt)) { + if (bullet.length > 8 && bullet.length < 240) { + cases.push(caseOf(bullet)); + } + } + + const modules = new Set(); + for (const f of files.slice(0, 20)) { + const parts = f.split("/"); + const leaf = parts[parts.length - 1] ?? f; + if (leaf.endsWith(".test.ts") || leaf.endsWith(".test.tsx") || leaf.endsWith(".spec.ts")) { + cases.push(caseOf(`Run and pass tests in ${leaf}`)); + } else { + const area = parts.slice(0, 3).join("/") || f; + modules.add(area); + } + } + for (const area of [...modules].slice(0, 8)) { + cases.push(caseOf(`Exercise changed code under ${area}`)); + } + + if (files.some((f) => /\.(tsx|css|jsx)$/.test(f))) { + cases.push(caseOf("Check desktop and mobile breakpoints for the changed UI")); + } + + if (cases.length === 0) { + cases.push(caseOf("Smoke the happy path described in the task")); + cases.push(caseOf("Confirm no obvious regressions in adjacent flows")); + } + + return uniqueCases(cases).slice(0, 20); +} diff --git a/plugins/fusion-plugin-quality/tsconfig.json b/plugins/fusion-plugin-quality/tsconfig.json new file mode 100644 index 0000000000..f03acb585f --- /dev/null +++ b/plugins/fusion-plugin-quality/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "./src", + "jsx": "react-jsx", + "types": ["react", "node"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/__tests__/**"] +} diff --git a/plugins/fusion-plugin-quality/vitest.config.ts b/plugins/fusion-plugin-quality/vitest.config.ts new file mode 100644 index 0000000000..c3ddd86a55 --- /dev/null +++ b/plugins/fusion-plugin-quality/vitest.config.ts @@ -0,0 +1,42 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; +import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest-workers"; + +const maxWorkers = computeMaxWorkers(); + +export default defineConfig({ + resolve: { + alias: [ + { + find: /^@fusion-plugin-examples\/quality\/dashboard-view$/, + replacement: fileURLToPath(new URL("./src/dashboard-view.tsx", import.meta.url)), + }, + { + find: /^@fusion-plugin-examples\/quality\/qa-tab$/, + replacement: fileURLToPath(new URL("./src/qa-tab.tsx", import.meta.url)), + }, + { + find: /^@fusion-plugin-examples\/quality$/, + replacement: fileURLToPath(new URL("./src/index.ts", import.meta.url)), + }, + { + find: "@fusion/core", + replacement: fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)), + }, + { + find: "@fusion/plugin-sdk", + replacement: fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)), + }, + ], + }, + test: { + include: ["src/**/*.test.{ts,tsx}"], + exclude: ["**/node_modules/**", "**/dist/**"], + environment: "node", + setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))], + globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))], + pool: "threads", + maxWorkers, + minWorkers: 1, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16339a67cd..86c3c414db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,10 +50,10 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: ^0.80.6 - version: 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + version: 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@earendil-works/pi-coding-agent': specifier: ^0.80.6 - version: 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + version: 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) dockerode: specifier: ^4.0.12 version: 4.0.12 @@ -276,6 +276,9 @@ importers: '@fusion-plugin-examples/paperclip-runtime': specifier: workspace:* version: link:../../plugins/fusion-plugin-paperclip-runtime + '@fusion-plugin-examples/quality': + specifier: workspace:* + version: link:../../plugins/fusion-plugin-quality '@fusion-plugin-examples/roadmap': specifier: workspace:* version: link:../../plugins/fusion-plugin-roadmap @@ -502,10 +505,10 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@earendil-works/pi-coding-agent': specifier: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@fusion-plugin-examples/droid-runtime': specifier: workspace:* version: link:../../plugins/fusion-plugin-droid-runtime @@ -1138,6 +1141,40 @@ importers: specifier: ^4.1.0 version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + plugins/fusion-plugin-quality: + dependencies: + '@fusion/core': + specifier: workspace:* + version: link:../../packages/core + '@fusion/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + lucide-react: + specifier: ^0.542.0 + version: 0.542.0(react@19.2.4) + devDependencies: + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/node': + specifier: ^25.5.2 + version: 25.5.2 + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + plugins/fusion-plugin-reports: dependencies: '@fusion/core': @@ -8001,10 +8038,6 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.2.4 - '@anthropic-ai/sdk@0.91.1': - dependencies: - json-schema-to-ts: 3.1.1 - '@anthropic-ai/sdk@0.91.1(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 @@ -8739,20 +8772,6 @@ snapshots: - ws - zod - '@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': - dependencies: - '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - ignore: 7.0.5 - typebox: 1.1.38 - yaml: 2.9.0 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8809,20 +8828,6 @@ snapshots: - ws - zod - '@earendil-works/pi-agent-core@0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': - dependencies: - '@earendil-works/pi-ai': 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - ignore: 7.0.5 - typebox: 1.1.38 - yaml: 2.9.0 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@earendil-works/pi-agent-core@0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-ai': 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8852,30 +8857,10 @@ snapshots: - zod '@earendil-works/pi-ai@0.77.0': - dependencies: - '@anthropic-ai/sdk': 0.91.1 - '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 - '@mistralai/mistralai': 2.2.1 - '@smithy/node-http-handler': 4.7.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - openai: 6.26.0 - partial-json: 0.1.7 - typebox: 1.1.38 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)) + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 @@ -8953,31 +8938,10 @@ snapshots: - zod '@earendil-works/pi-ai@0.80.6': - dependencies: - '@anthropic-ai/sdk': 0.91.1 - '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 - '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) - '@opentelemetry/api': 1.9.0 - '@smithy/node-http-handler': 4.7.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - openai: 6.26.0 - partial-json: 0.1.7 - typebox: 1.1.38 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@earendil-works/pi-ai@0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)) + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) '@opentelemetry/api': 1.9.0 '@smithy/node-http-handler': 4.7.3 @@ -9019,7 +8983,7 @@ snapshots: dependencies: '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) '@opentelemetry/api': 1.9.0 '@smithy/node-http-handler': 4.7.3 @@ -9065,35 +9029,6 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': - dependencies: - '@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - '@earendil-works/pi-tui': 0.77.0 - '@silvia-odwyer/photon-node': 0.3.4 - chalk: 5.6.2 - cross-spawn: 7.0.6 - diff: 8.0.4 - glob: 13.0.6 - highlight.js: 10.7.3 - hosted-git-info: 9.0.3 - ignore: 7.0.5 - jiti: 2.7.0 - minimatch: 10.2.5 - proper-lockfile: 4.1.2 - typebox: 1.1.38 - undici: 8.3.0 - yaml: 2.9.0 - optionalDependencies: - '@mariozechner/clipboard': 0.3.9 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -9212,36 +9147,6 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': - dependencies: - '@earendil-works/pi-agent-core': 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - '@earendil-works/pi-ai': 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - '@earendil-works/pi-tui': 0.80.6 - '@silvia-odwyer/photon-node': 0.3.4 - chalk: 5.6.2 - cross-spawn: 7.0.6 - diff: 8.0.4 - glob: 13.0.6 - highlight.js: 10.7.3 - hosted-git-info: 9.0.3 - ignore: 7.0.5 - jiti: 2.7.0 - minimatch: 10.2.5 - proper-lockfile: 4.1.2 - semver: 7.8.0 - typebox: 1.1.38 - undici: 8.5.0 - yaml: 2.9.0 - optionalDependencies: - '@mariozechner/clipboard': 0.3.9 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@earendil-works/pi-coding-agent@0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-agent-core': 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -9657,30 +9562,6 @@ snapshots: '@exodus/bytes@1.15.0': {} - '@google/genai@1.52.0': - dependencies: - google-auth-library: 10.6.2 - p-retry: 4.6.2 - protobufjs: 7.5.8 - ws: 8.20.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))': - dependencies: - google-auth-library: 10.6.2 - p-retry: 4.6.2 - protobufjs: 7.5.8 - ws: 8.20.0 - optionalDependencies: - '@modelcontextprotocol/sdk': 1.28.0(zod@3.25.76) - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))': dependencies: google-auth-library: 10.6.2 @@ -10209,29 +10090,6 @@ snapshots: - bufferutil - utf-8-validate - '@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)': - dependencies: - '@hono/node-server': 1.19.12(hono@4.12.9) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.2.1 - express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.9 - jose: 6.2.2 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.1(zod@3.25.76) - transitivePeerDependencies: - - supports-color - optional: true - '@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.12(hono@4.12.9) @@ -11010,7 +10868,7 @@ snapshots: obug: 2.1.2 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/expect@4.1.8': dependencies: @@ -14327,8 +14185,6 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@6.26.0: {} - openai@6.26.0(ws@8.20.0)(zod@3.25.76): optionalDependencies: ws: 8.20.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 914c019840..5797698f38 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -45,4 +45,5 @@ packages: - "plugins/fusion-plugin-even-realities-glasses" - "plugins/fusion-plugin-reports" - "plugins/fusion-plugin-compound-engineering" + - "plugins/fusion-plugin-quality" - "plugins/fusion-plugin-linear-import"