Merge pull request #1686 from Runfusion/fix/quarantine-json-forces-gate-mode
Stop quarantine-list edits from forcing pnpm test into gate mode
This commit is contained in:
5
.changeset/fix-quarantine-json-gate-mode.md
Normal file
5
.changeset/fix-quarantine-json-gate-mode.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Stop edits to `scripts/lib/test-quarantine.json` from forcing `pnpm test` into gate mode. The quarantine list is runtime data, not executable test infra; tripping the shared-infra catch-all dropped affected-package coverage, so a dev's real changes went untested whenever they also touched the quarantine list. Quarantine edits now stay in changed mode and run the affected packages.
|
||||
5
.changeset/soft-badgers-measure.md
Normal file
5
.changeset/soft-badgers-measure.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add Command Center Productivity task-duration analytics, dashboard stat cards, and CSV export rows for completed-task active execution time.
|
||||
@@ -272,6 +272,9 @@ The minimal set of merge-blocking PR checks: lint, typecheck, build, a Boot Smok
|
||||
|
||||
Gate membership is an explicit allow-list, never a glob: a test earns its slot with evidence of value and never graduates in by default. A flake inside the gate is *evicted* — its allow-list entry is removed — which deliberately requires no green run from the flaky test itself, so the gate can always be repaired while red.
|
||||
|
||||
### Affected-package test selection
|
||||
How `pnpm test` chooses what to run against the working diff. In *changed mode* it runs the Merge Gate suite plus the workspace packages the diff touched and their dependents; in *gate mode* it runs only the Merge Gate suite. A change to shared test infrastructure routes to gate mode, which *replaces* changed-package coverage rather than adding to it — so a diff that trips the shared-infra signal but only edits data files gets no coverage of its own packages until that file is allow-listed as test-irrelevant.
|
||||
|
||||
### Boot Smoke
|
||||
The gate's "app starts and serves" proof: the CLI answers its help command and a real server boots on a throwaway port, answers its health endpoint, and shuts down cleanly on signal. A pass requires both that the shutdown signal was actually delivered and that the exit was clean — a crash after serving is a failed boot path, not a pass.
|
||||
|
||||
|
||||
@@ -856,7 +856,7 @@ Operator setup + troubleshooting guide: **[Remote Access runbook](./remote-acces
|
||||
Key server capabilities:
|
||||
- REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings
|
||||
- System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry (including app CPU percentage and host memory rendered as numeric values, radial gauges, and trend sparklines in the Command Center System area), task/agent aggregates, and manual vitest process termination
|
||||
- Command Center analytics APIs (`GET /api/command-center/tokens`, `/tools`, `/activity`, `/productivity`, `/team`, `/github`, `/signals`, `/plugin-activations`, `/live`) are project-scoped dashboard routes. `/productivity` reads Lines changed from nullable `task_commit_associations.additions`/`deletions` merge-time diff stats, derives estimated `hoursSaved` from that LOC via the exported `HUMAN_LINES_PER_HOUR` rate, and keeps the unavailable sentinel for both fields when no in-range association has stats. `/signals` aggregates real local `incidents` rows for total/open/resolved counts, MTTR, and source/severity/status breakdowns and returns honest empty/unavailable sentinels instead of synthetic signal volume. `/plugin-activations` aggregates persisted plugin/extension load events for the selected range and returns unavailable when no rows exist instead of treating missing history as zero activations.
|
||||
- Command Center analytics APIs (`GET /api/command-center/tokens`, `/tools`, `/activity`, `/productivity`, `/team`, `/github`, `/signals`, `/plugin-activations`, `/live`) are project-scoped dashboard routes. `/productivity` reads Lines changed from nullable `task_commit_associations.additions`/`deletions` merge-time diff stats, derives estimated `hoursSaved` from that LOC via the exported `HUMAN_LINES_PER_HOUR` rate, and keeps the unavailable sentinel for both fields when no in-range association has stats. Its `taskDuration` payload aggregates done tasks whose `executionCompletedAt` falls in the selected range, using positive `tasks.cumulativeActiveMs` values for completed count, average, median, p90, and total active execution time; missing qualifying durations remain unavailable rather than zero. `/signals` aggregates real local `incidents` rows for total/open/resolved counts, MTTR, and source/severity/status breakdowns and returns honest empty/unavailable sentinels instead of synthetic signal volume. `/plugin-activations` aggregates persisted plugin/extension load events for the selected range and returns unavailable when no rows exist instead of treating missing history as zero activations.
|
||||
- Remote access APIs (`/api/remote/*`) for provider config, activation, tunnel lifecycle, status, token issuance, authenticated URL generation, and QR payload generation
|
||||
- Operational runbook (prereqs/security/troubleshooting): [`docs/remote-access.md`](./remote-access.md)
|
||||
- `/api/remote/tunnel/start`, `/api/remote/tunnel/stop`, and `/api/remote/tunnel/kill-external` cover tunnel lifecycle and external funnel cleanup.
|
||||
@@ -1461,6 +1461,8 @@ Dashboard session-diff route registration (`packages/dashboard/src/routes/regist
|
||||
|
||||
Commit associations also carry optional `additions`/`deletions` shortstat counts captured by merge paths. These nullable fields are the Command Center Productivity LOC source: analytics sum additions + deletions only when at least one in-range row has stats, derive estimated human hours saved as `round((additions + deletions) / HUMAN_LINES_PER_HOUR, 1)`, and preserve the `—` unavailable sentinel for both LOC and hours saved when all matching rows are `NULL` so unknown historical data is never rendered as `0`. The hours-saved field is a conservative estimate, not exact time tracking.
|
||||
|
||||
Command Center Productivity task-duration stats use task rows, not commit rows: done tasks completed in range (`executionCompletedAt`) contribute when `cumulativeActiveMs > 0`. The aggregator computes completed count plus average, median, p90, and total active execution milliseconds; if no qualifying task exists, the duration metrics use the same unavailable `—` contract instead of reporting `0`.
|
||||
|
||||
### Done-task files-changed sources of truth
|
||||
|
||||
Done-task file-count surfaces intentionally distinguish three data sources:
|
||||
|
||||
@@ -669,7 +669,7 @@ Features:
|
||||
- **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage. Per-model and per-provider breakdowns use the task's analytics-only actually-used model snapshot when available, so usage from settings-resolved runs appears under the real runtime model instead of `(unknown)` without changing future model resolution; estimated cost uses the same snapshot-first, legacy-fallback model identity so those resolved runs price normally when the model is in the pricing table. It includes the existing token-usage-over-time chart, an additive recharts multi-series line graph, and a token-share pie backed by the same grouped token analytics; use the granularity control to switch the time-series request between hourly, daily, and weekly buckets. The token total and charts poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users.
|
||||
- **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories. The area keeps the existing category bar and adds a recharts category-share pie from `ToolAnalytics.byCategory`. There is intentionally no tools line chart yet because `ToolAnalytics` does not expose a per-day tool trend; the dashboard does not fabricate one or call a new endpoint.
|
||||
- **Activity** tracks sessions, messages, active nodes, active agents, agent heartbeat runs, and stickiness. Agent-run sheets show total, active, completed, and failed runs for the selected range, and the Agent runs/day sparkline trends runs by `agentRuns.startedAt`. The area keeps the existing live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`), and adds a recharts multi-series line graph for messages, active agents, and agent runs plus an agent-run outcome pie from the existing `agentRuns` split. These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users.
|
||||
- **Productivity** separates outcome counters (commits and pull requests) from volume proxies such as modified files, lines changed, and files by language. It keeps the files-by-language bar and adds a language-share pie from `ProductivityAnalytics.byLanguage`. There is intentionally no productivity line chart because the current productivity response has no per-day throughput or completion time series; no new endpoint is called.
|
||||
- **Productivity** separates outcome counters (commits and pull requests), task-duration stats, and volume proxies such as modified files, lines changed, and files by language. The task-duration block counts done tasks completed in the selected range and shows average, median, p90, and total active execution time from `cumulativeActiveMs`; when no qualifying duration data exists, duration values render the unavailable `—` sentinel rather than `0`. It keeps the files-by-language bar and adds a language-share pie from `ProductivityAnalytics.byLanguage`. There is intentionally no productivity line chart because the current productivity response has no per-day throughput or completion time series; no new endpoint is called.
|
||||
- **Team** shows a per-agent analytics table plus tokens-by-agent and tasks-done-by-agent charts, and adds a real token-share pie from the same per-agent token totals. Metrics come only from the project-scoped `tasks` and `agents` tables: token totals and estimated cost are summed from the `tokenUsage*` columns by `assignedAgentId`, files changed counts parsed `tasks.modifiedFiles` paths, tasks done counts `column = 'done'` moves in the selected range, and in-progress / in-review values reflect current task columns. Agent name, role, and live state come from the `agents` table; deleted-agent task history falls back to the raw agent id instead of crashing. The tab uses `/api/command-center/team`, adds no schema, never calls GitHub, and intentionally leaves per-agent issues filed/fixed to FN-6653. Team has no per-day analytics series today, so it intentionally does not render a line chart or fabricate a trend. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users.
|
||||
- **Ecosystem** shows active model breadth, per-model task activity, and real plugin activations for the selected range. Plugin activation counts come from project-scoped plugin/extension load events via `/api/command-center/plugin-activations`; if no activation rows exist in range, the metric renders unavailable (`—`) rather than fabricating zero. The tab still reuses the tokens analytics endpoint grouped by model, adds a task-share-by-model pie from `TokenAnalytics.groups`, and renders a tokens/tasks trend line when `TokenAnalytics.series` buckets are present; if series buckets are absent, no synthetic trend is shown.
|
||||
- **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, a filed-vs-fixed pie, a filed/fixed recharts trend line, existing daily sparklines, and a by-repository bar breakdown.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: Shared-infra catch-all forces gate mode, silently dropping changed-package coverage
|
||||
date: 2026-06-19
|
||||
category: logic-errors
|
||||
module: scripts/test-changed
|
||||
problem_type: logic_error
|
||||
component: testing_framework
|
||||
symptoms:
|
||||
- "`pnpm test` ran ~700 fixed engine-core + cli-shape tests while the developer's actually-changed packages got zero coverage"
|
||||
- "`node scripts/test-changed.mjs --print-mode` reported `mode=gate reason=shared-infra-changed` on a branch that only touched package source plus a data file"
|
||||
- "Changed packages (@fusion/core, @fusion/dashboard) resolved as affected but never ran"
|
||||
root_cause: logic_error
|
||||
resolution_type: code_fix
|
||||
severity: medium
|
||||
related_components:
|
||||
- development_workflow
|
||||
tags:
|
||||
- test-changed
|
||||
- gate-mode
|
||||
- test-selection
|
||||
- affected-packages
|
||||
- shared-infra
|
||||
- quarantine
|
||||
- silent-coverage-loss
|
||||
---
|
||||
|
||||
# Shared-infra catch-all forces gate mode, silently dropping changed-package coverage
|
||||
|
||||
## Problem
|
||||
|
||||
`pnpm test` (→ `scripts/test-changed.mjs`) routed to **gate mode** whenever the diff touched `scripts/lib/test-quarantine.json` — a runtime data list of quarantined tests, not executable infra. Gate mode runs only the fixed merge-gate slice (`pnpm test:gate` = engine-core + cli ci-shape) and **returns before the affected packages**, so a developer who also edited the quarantine list got zero coverage of their real changes.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- `pnpm test` spent ~22s passing ~700 engine/cli tests unrelated to the change; the changed code never ran.
|
||||
- `--print-mode` showed `mode=gate reason=shared-infra-changed packages=0` on a branch whose only "infra" file was `scripts/lib/test-quarantine.json`.
|
||||
- The affected-package resolver, given the same diff minus the quarantine file, correctly returned `[@fusion/core, @fusion/dashboard]` — proving the coverage was being dropped, not just unselected.
|
||||
|
||||
## What Didn't Work
|
||||
|
||||
- **Assuming the gate suite covers the change** — gate mode runs a *fixed* slice (engine-core + cli ci-workflow shape) regardless of what changed. It is a trust signal, not a coverage signal for arbitrary packages. Reading only the green output hides the gap.
|
||||
- **Blaming the cache** — the content-hash cache was a red herring; the run never reached the affected-package path at all because `decideExecutionPlan` short-circuited to `gate` first.
|
||||
|
||||
## Solution
|
||||
|
||||
The catch-all in `isSharedInfraChange` (`scripts/test-changed.mjs`) treats *any* changed file outside `packages/`, `plugins/`, or `docs/` as shared infra. The quarantine data file hit it. Classify it as test-irrelevant in `isTestIrrelevantRootPath` so the diff stays in changed mode:
|
||||
|
||||
```js
|
||||
// scripts/test-changed.mjs — isTestIrrelevantRootPath
|
||||
// The quarantine list is runtime DATA (which tests are skipped), not
|
||||
// executable test infra. Editing it must not trip the root catch-all below
|
||||
// and force gate mode — gate mode drops affected-package coverage.
|
||||
if (file === "scripts/lib/test-quarantine.json") {
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
After the fix, `--print-mode` reports `mode=changed` and the affected packages run again. Regression tests in `scripts/__tests__/test-changed.test.mjs` assert the quarantine list — alone and alongside package changes — stays in changed mode. Shipped in PR Runfusion/Fusion#1686.
|
||||
|
||||
## Why This Works
|
||||
|
||||
`decideExecutionPlan` checks `isSharedInfraChange(changedFiles)` **before** resolving affected packages, and gate mode is terminal (`runMaybeIsolated("test:gate")` then `return`). So the shared-infra signal doesn't *augment* affected coverage — it **replaces** it. That tradeoff is defensible for a genuine infra change (you can't trust the affected-resolution when the resolver's own inputs moved), but a quarantine-list edit doesn't invalidate affected resolution; it's just data. Marking it test-irrelevant lets the diff fall through to normal affected-package selection (which still runs the gate first, then the affected set), so the developer's real changes get tested.
|
||||
|
||||
## Prevention
|
||||
|
||||
- **Know the replacement semantics**: in `test-changed`, shared-infra/gate mode *replaces* affected-package coverage rather than adding to it. Any changed file not under `packages/`, `plugins/`, or `docs/` hits the catch-all and silently drops changed-code testing until it's explicitly allowlisted in `isTestIrrelevantRootPath`.
|
||||
- **Allowlist new data/config files under `scripts/`**: when adding a runtime data file (JSON lists, fixtures, generated catalogs) that lives outside the package tree, add it to `isTestIrrelevantRootPath` — or it will force every diff that touches it into gate-only mode.
|
||||
- **Verify mode, not just green**: when a test run looks suspiciously fast or generic, run `node scripts/test-changed.mjs --print-mode`. `mode=gate reason=shared-infra-changed packages=0` on a branch with real package changes is the tell that coverage is being dropped.
|
||||
- **Test the coverage invariant, not just the boolean**: the regression test asserts a quarantine edit + package change stays in changed mode — i.e. that the *affected packages would run*, not merely that `isSharedInfraChange` returns false.
|
||||
|
||||
## Related Issues
|
||||
|
||||
- PR Runfusion/Fusion#1686 — the fix this doc documents
|
||||
- [files-changed-inflated-by-origin-first-base-commit](./files-changed-inflated-by-origin-first-base-commit.md) — adjacent test/diff-tooling logic bug in the same `scripts`/engine tooling family
|
||||
@@ -395,6 +395,8 @@ The `tasks.tokenUsage*` columns store cumulative per-task token usage for analyt
|
||||
The nullable `tasks.tokenUsagePerModel` JSON column (migration 125) stores the per-task, per-runtime-model breakdown behind those cumulative totals. Each bucket records provider/model, token counts, and first/last use timestamps. Command Center model/provider analytics expand these buckets so multi-model tasks appear under every model they actually used; task-level totals, cost, time series, node grouping, and agent grouping still read the top-level aggregate so grand `nTasks` is not double-counted. Empty, missing, or malformed per-model JSON falls back to the legacy single-snapshot grouping path.
|
||||
|
||||
The `task_commit_associations.additions` and `task_commit_associations.deletions` columns (migration 123) store nullable merge-time git shortstat counts for the associated commit. Command Center Productivity uses `SUM(additions + deletions)` as the Lines changed source when at least one in-range association has non-null stats, then derives estimated `hoursSaved` as `round(loc / HUMAN_LINES_PER_HOUR, 1)`. `NULL` means stats were unknown or unavailable for that association, not zero; ranges with no non-null stats keep the unavailable `—` sentinel for both LOC and hours saved instead of reporting `0`.
|
||||
|
||||
The `tasks.cumulativeActiveMs` and `tasks.executionCompletedAt` columns are the Command Center Productivity task-duration source. Duration analytics select `column = 'done'` tasks completed in the requested range (`executionCompletedAt`) and include only positive `cumulativeActiveMs` values, then compute completed count, average, median, p90, and total active execution time. Missing, zero, or historical untracked duration values remain unavailable (`—`) rather than being serialized or rendered as `0`.
|
||||
| `config` | Single-row project configuration (`nextId`, settings payload, workflow step counters). |
|
||||
| `workflow_steps` | Workflow step definitions (`prompt`/`script`) with phase, template metadata, and model overrides. |
|
||||
| `activityLog` | Per-project activity/event log with timestamp/type/task indexes. |
|
||||
|
||||
@@ -201,7 +201,7 @@ Legitimate legacy exceptions must be recorded in `scripts/lib/test-timeout-appea
|
||||
|
||||
<!-- FNXC:TestQuarantine 2026-06-19-14:15: FN-6740 audited the same-day quarantine ledger as a coordinated deletion-ratchet batch. The ledger had 14 entries (3 dashboard, 6 core, 5 CLI) and every entry was mirrored in its package Vitest exclude; keep follow-up rescue/delete work scoped by subsystem so ledger/config edits remain lockstep and do not collide. -->
|
||||
|
||||
**2026-06-19 quarantine audit (FN-6740):** the 2026-06-19 ledger batch expires on **2026-07-03**. FN-6740 found no ledger/config half-state and chose no inline rescue/delete. The five CLI files (`extension-goal-tools`, `extension-mission-goal-tools`, `extension-task-tools`, `extension`, `research-extension-tools`) are explicitly deferred to FN-6734's outcome and must not get a duplicate rescue task. Five core files (`activity-analytics`, `db`, `store-create-summarize-deferred-hook`, `vitest-teardown-worker-root-cleanup`, `settings-export`) were rescued by FN-6741 after the loaded `@fusion/core` lane passed with only `store-concurrent-writes` re-quarantined; `settings-export` now closes its `TaskStore` before fixture cleanup. The dashboard files were split by likely root cause: FN-6742 rescued `session-cross-tab` cleanup `ENOTEMPTY` by closing the route/task-store seam before fixture removal; FN-6743 owns the third-repeat QuickEntryBox focus-restoration race after FN-6514/FN-6642; and FN-6744 owns WorkflowNodeEditor duplicate-merge-seam concurrency. Until the remaining dashboard and core follow-ups rescue with root-cause evidence or delete under the ratchet, leave all corresponding ledger entries and package excludes in lockstep.
|
||||
**2026-06-19 quarantine audit (FN-6740):** the 2026-06-19 ledger batch expires on **2026-07-03**. FN-6740 found no ledger/config half-state and chose no inline rescue/delete. The five CLI files (`extension-goal-tools`, `extension-mission-goal-tools`, `extension-task-tools`, `extension`, `research-extension-tools`) are explicitly deferred to FN-6734's outcome and must not get a duplicate rescue task. Five core files (`activity-analytics`, `db`, `store-create-summarize-deferred-hook`, `vitest-teardown-worker-root-cleanup`, `settings-export`) were rescued by FN-6741 after the loaded `@fusion/core` lane passed with only `store-concurrent-writes` re-quarantined; `settings-export` now closes its `TaskStore` before fixture cleanup. The dashboard files were split by likely root cause: FN-6742 rescued `session-cross-tab` cleanup `ENOTEMPTY` by closing the route/task-store seam before fixture removal; FN-6743 owns the third-repeat QuickEntryBox focus-restoration race after FN-6514/FN-6642; and FN-6744 rescued WorkflowNodeEditor duplicate-merge-seam concurrency by making fragment seam conflicts consult the loaded workflow IR before React Flow canvas nodes finish materializing. Until the remaining dashboard and core follow-ups rescue with root-cause evidence or delete under the ratchet, leave all corresponding ledger entries and package excludes in lockstep.
|
||||
|
||||
<!-- FNXC:CoreTests 2026-06-19-14:55: FN-6741 rescued five same-day @fusion/core quarantine entries after proving the broad core lane with only store-concurrent-writes still failing, then removed ledger/config entries in lockstep for the rescued files. Keep this rescue pattern evidence-driven: fix close-order leaks such as TaskStore handles before fixture cleanup, prove the package lane, and do not replace quarantine removal with timeout, retry, or worker-count appeasement.
|
||||
|
||||
@@ -213,6 +213,10 @@ FNXC:CoreTests 2026-06-19-15:05: Merge verification re-observed store-concurrent
|
||||
|
||||
**2026-06-19 dashboard session-cross-tab rescue (FN-6742):** `packages/dashboard/src/__tests__/session-cross-tab.test.ts` was rescued before its 2026-07-03 deletion deadline. The loaded `dashboard-api-quality-backfill` shard reproduced the original `fusion-test-workers-*` `ENOTEMPTY` cleanup failure with the quarantine exclude temporarily removed, while the test's assertions retained value by failing when the expected lock holder was mutated from `tab-a` to `tab-z`. The fix keeps the test unquarantined by disposing the created API router, stopping `AiSessionStore` scheduled cleanup, closing the real `TaskStore`/SQLite handles, hiding route EventEmitter hooks not used by this harness, and draining four bounded check-phase turns before deleting the temp root. The ledger and `packages/dashboard/vitest.config.ts` exclude were updated in lockstep; later loaded runs no longer failed this file, and unrelated dashboard loaded-suite failures are tracked separately rather than weakening this test.
|
||||
|
||||
<!-- FNXC:WorkflowNodeEditorTests 2026-06-19-18:24: FN-6744 proved WorkflowNodeEditor duplicate-merge coverage still catches a real product race: the palette can be used after workflow IR loads but before React Flow nodes exist. Rescue this class by checking seam conflicts against the authoritative loaded IR during initial canvas materialization, then prove desktop and mobile conflict surfaces under the loaded dashboard components-b lane; do not add waits, retries, worker reductions, or timeout appeasement. -->
|
||||
|
||||
**2026-06-19 dashboard WorkflowNodeEditor rescue (FN-6744):** `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` was rescued before its 2026-07-03 deletion deadline. The original duplicate-merge test passed in isolation but was load-sensitive because `handleInsertFragment` derived existing seams only from transient React Flow nodes; a fast palette click could arrive after `activeWorkflow.ir` loaded but before the canvas nodes materialized, allowing an invalid duplicate merge seam instead of showing the conflict alert. The fix keeps the test unquarantined by treating IR merge nodes as the merge seam and by unioning seams from the loaded IR only during initial canvas materialization, preserving post-load canvas-state semantics. Regression coverage now exercises both desktop and mobile fragment insertion surfaces and asserts the conflict affordance appears without growing the rendered graph. The ledger and `packages/dashboard/vitest.config.ts` exclude were removed in lockstep; targeted file runs, repeated `test:quality:app:components-b`, lint, gate, typecheck, and build are the closure evidence. A broader `@fusion/dashboard test` run currently fails unrelated Command Center ProductivityArea mock drift tracked by FN-6754, so do not re-quarantine WorkflowNodeEditor for that lane.
|
||||
|
||||
**2026-06-16 rescue (FN-6514):** `packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx` was rescued before its 2026-06-30 deletion deadline. The file still caught real quick-entry behavior regressions, but it leaked jsdom descriptors for `window.innerWidth`, `window.matchMedia`, `document.visibilityState`, `URL.createObjectURL`, and `URL.revokeObjectURL`; a mobile viewport helper could leave later tests in the same dashboard backfill shard observing `innerWidth=375` and mismatched responsive assertions. The rescue removed the ledger/config quarantine entries in lockstep, captured each original `PropertyDescriptor` at module load, restored those descriptors (or deleted own properties that were originally absent) in `afterEach`, and added a guard test that mutates all rescued globals before asserting they return to their original descriptors. Reusable pattern: any test file that changes jsdom globals with `Object.defineProperty` or spies on replaceable globals must snapshot the original descriptor at the top of the file, restore it in every `afterEach`, and prove the invariant with a guard test; do not use timeout bumps, retries, worker changes, or blanket `vi.restoreAllMocks()` when module mocks depend on stable implementations.
|
||||
|
||||
**Gate eviction:** a flake inside the merge gate cannot block all merges while red — it is evicted by removing its line from the `engine-core` allow-list (no quarantine entry needed unless it should also leave the non-blocking tier).
|
||||
|
||||
@@ -14,6 +14,30 @@ function insertTaskWithFiles(db: Database, id: string, files: string[], updatedA
|
||||
).run(id, updatedAt, updatedAt, JSON.stringify(files));
|
||||
}
|
||||
|
||||
function insertCompletedTask(
|
||||
db: Database,
|
||||
id: string,
|
||||
opts: {
|
||||
cumulativeActiveMs?: number | null;
|
||||
executionCompletedAt: string | null;
|
||||
column?: string;
|
||||
},
|
||||
): void {
|
||||
const createdAt = opts.executionCompletedAt ?? "2026-03-01T00:00:00.000Z";
|
||||
db.prepare(
|
||||
`INSERT INTO tasks
|
||||
(id, description, "column", createdAt, updatedAt, cumulativeActiveMs, executionCompletedAt)
|
||||
VALUES (?, 'desc', ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
id,
|
||||
opts.column ?? "done",
|
||||
createdAt,
|
||||
createdAt,
|
||||
opts.cumulativeActiveMs ?? null,
|
||||
opts.executionCompletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
function insertCommit(
|
||||
db: Database,
|
||||
id: string,
|
||||
@@ -129,20 +153,99 @@ describe("productivity-analytics", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("computes completed-task duration stats for done tasks completed in range", () => {
|
||||
insertCompletedTask(db, "d1", { cumulativeActiveMs: 1_000, executionCompletedAt: "2026-03-01T00:00:00.000Z" });
|
||||
insertCompletedTask(db, "d2", { cumulativeActiveMs: 2_000, executionCompletedAt: "2026-03-02T00:00:00.000Z" });
|
||||
insertCompletedTask(db, "d3", { cumulativeActiveMs: 3_000, executionCompletedAt: "2026-03-03T00:00:00.000Z" });
|
||||
insertCompletedTask(db, "d4", { cumulativeActiveMs: 4_000, executionCompletedAt: "2026-03-04T00:00:00.000Z" });
|
||||
|
||||
const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" });
|
||||
expect(result.taskDuration).toEqual({
|
||||
completedTasks: 4,
|
||||
averageMs: 2_500,
|
||||
medianMs: 2_500,
|
||||
p90Ms: 4_000,
|
||||
totalMs: 10_000,
|
||||
unavailable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes completed-task durations outside the executionCompletedAt range", () => {
|
||||
insertCompletedTask(db, "before", { cumulativeActiveMs: 9_000, executionCompletedAt: "2026-02-28T23:59:59.999Z" });
|
||||
insertCompletedTask(db, "inside", { cumulativeActiveMs: 2_000, executionCompletedAt: "2026-03-01T00:00:00.000Z" });
|
||||
insertCompletedTask(db, "after", { cumulativeActiveMs: 8_000, executionCompletedAt: "2026-04-01T00:00:00.000Z" });
|
||||
|
||||
const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T23:59:59.999Z" });
|
||||
expect(result.taskDuration).toEqual({
|
||||
completedTasks: 1,
|
||||
averageMs: 2_000,
|
||||
medianMs: 2_000,
|
||||
p90Ms: 2_000,
|
||||
totalMs: 2_000,
|
||||
unavailable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes non-done tasks and null or zero cumulativeActiveMs durations", () => {
|
||||
insertCompletedTask(db, "todo", { cumulativeActiveMs: 1_000, executionCompletedAt: "2026-03-01T00:00:00.000Z", column: "todo" });
|
||||
insertCompletedTask(db, "null-duration", { cumulativeActiveMs: null, executionCompletedAt: "2026-03-02T00:00:00.000Z" });
|
||||
insertCompletedTask(db, "zero-duration", { cumulativeActiveMs: 0, executionCompletedAt: "2026-03-03T00:00:00.000Z" });
|
||||
insertCompletedTask(db, "valid", { cumulativeActiveMs: 5_000, executionCompletedAt: "2026-03-04T00:00:00.000Z" });
|
||||
|
||||
const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" });
|
||||
expect(result.taskDuration).toEqual({
|
||||
completedTasks: 1,
|
||||
averageMs: 5_000,
|
||||
medianMs: 5_000,
|
||||
p90Ms: 5_000,
|
||||
totalMs: 5_000,
|
||||
unavailable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports task duration as unavailable, never zero, when no qualifying durations exist", () => {
|
||||
insertCompletedTask(db, "zero-duration", { cumulativeActiveMs: 0, executionCompletedAt: "2026-03-01T00:00:00.000Z" });
|
||||
insertCompletedTask(db, "todo", { cumulativeActiveMs: 1_000, executionCompletedAt: "2026-03-02T00:00:00.000Z", column: "todo" });
|
||||
|
||||
const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" });
|
||||
expect(result.taskDuration).toEqual({
|
||||
completedTasks: 0,
|
||||
averageMs: null,
|
||||
medianMs: null,
|
||||
p90Ms: null,
|
||||
totalMs: null,
|
||||
unavailable: true,
|
||||
});
|
||||
expect(result.taskDuration.averageMs).not.toBe(0);
|
||||
expect(result.taskDuration.medianMs).not.toBe(0);
|
||||
expect(result.taskDuration.p90Ms).not.toBe(0);
|
||||
expect(result.taskDuration.totalMs).not.toBe(0);
|
||||
});
|
||||
|
||||
it("empty range returns zeroed structures, not nulls", () => {
|
||||
insertTaskWithFiles(db, "t1", ["src/a.ts"], "2026-03-01T00:00:00.000Z");
|
||||
insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z");
|
||||
insertPr(db, "pr1", Date.parse("2026-03-01T00:00:00.000Z"));
|
||||
insertCompletedTask(db, "d1", { cumulativeActiveMs: 1_000, executionCompletedAt: "2026-03-01T00:00:00.000Z" });
|
||||
|
||||
const result = aggregateProductivityAnalytics(db, { from: "2027-01-01T00:00:00.000Z", to: "2027-12-31T00:00:00.000Z" });
|
||||
expect(result.modifiedFiles).toBe(0);
|
||||
expect(result.byLanguage).toEqual([]);
|
||||
expect(result.commits).toBe(0);
|
||||
expect(result.pullRequests).toBe(0);
|
||||
// LOC and derived hours are unavailable regardless of range.
|
||||
// LOC, derived hours, and task duration are unavailable regardless of range.
|
||||
expect(result.loc).toEqual({ value: null, unavailable: true });
|
||||
expect(result.hoursSaved).toEqual({ value: null, unavailable: true });
|
||||
expect(result.hoursSaved.value).not.toBe(0);
|
||||
expect(result.taskDuration).toEqual({
|
||||
completedTasks: 0,
|
||||
averageMs: null,
|
||||
medianMs: null,
|
||||
p90Ms: null,
|
||||
totalMs: null,
|
||||
unavailable: true,
|
||||
});
|
||||
expect(result.taskDuration.totalMs).not.toBe(0);
|
||||
});
|
||||
|
||||
it("includes a boundary task exactly at `from`", () => {
|
||||
|
||||
@@ -17,7 +17,8 @@ import type { Database } from "./db.js";
|
||||
*
|
||||
* Inclusivity: `from`/`to` bounds are inclusive. Tasks are filtered by
|
||||
* `updatedAt` (the last time the task — and therefore its modifiedFiles — was
|
||||
* touched); commit associations by `authoredAt`; PRs by `createdAt`.
|
||||
* touched); completed-task durations by `executionCompletedAt`; commit
|
||||
* associations by `authoredAt`; PRs by `createdAt`.
|
||||
*/
|
||||
|
||||
/*
|
||||
@@ -59,6 +60,19 @@ export interface HoursSavedSummary {
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:CommandCenterProductivity 2026-06-19-12:00:
|
||||
* Task-duration productivity stats are derived from `tasks.cumulativeActiveMs` for done tasks completed in the selected range. Missing qualifying durations are unavailable, not zero, so old or untracked tasks do not read as instant work.
|
||||
*/
|
||||
export interface TaskDurationSummary {
|
||||
completedTasks: number;
|
||||
averageMs: number | null;
|
||||
medianMs: number | null;
|
||||
p90Ms: number | null;
|
||||
totalMs: number | null;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
export interface ProductivityAnalytics {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
@@ -74,6 +88,8 @@ export interface ProductivityAnalytics {
|
||||
loc: LocSummary;
|
||||
/** Estimated human-hours equivalent derived from `loc` when LOC is available. */
|
||||
hoursSaved: HoursSavedSummary;
|
||||
/** Active execution duration for done tasks completed in range. */
|
||||
taskDuration: TaskDurationSummary;
|
||||
}
|
||||
|
||||
interface CountRow {
|
||||
@@ -91,6 +107,10 @@ interface ModifiedFilesRow {
|
||||
modifiedFiles: string | null;
|
||||
}
|
||||
|
||||
interface TaskDurationRow {
|
||||
cumulativeActiveMs: number;
|
||||
}
|
||||
|
||||
/** Extract a coarse language key from a file path (its lowercased extension). */
|
||||
function languageOf(path: string): string {
|
||||
const base = path.split("/").pop() ?? path;
|
||||
@@ -99,10 +119,26 @@ function languageOf(path: string): string {
|
||||
return base.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function median(sortedValues: readonly number[]): number | null {
|
||||
if (sortedValues.length === 0) return null;
|
||||
const middle = Math.floor(sortedValues.length / 2);
|
||||
if (sortedValues.length % 2 === 1) return sortedValues[middle] ?? null;
|
||||
return ((sortedValues[middle - 1] ?? 0) + (sortedValues[middle] ?? 0)) / 2;
|
||||
}
|
||||
|
||||
function nearestRankPercentile(sortedValues: readonly number[], percentile: number): number | null {
|
||||
if (sortedValues.length === 0) return null;
|
||||
const index = Math.min(
|
||||
sortedValues.length - 1,
|
||||
Math.max(0, Math.ceil(percentile * sortedValues.length) - 1),
|
||||
);
|
||||
return sortedValues[index] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate productivity metrics over a date range. Empty range yields zeroed
|
||||
* structures (not nulls); LOC remains the unavailable sentinel unless at least
|
||||
* one in-range commit association carries diff stats.
|
||||
* structures (not nulls); LOC and task duration remain unavailable sentinels
|
||||
* unless at least one in-range row carries real source data.
|
||||
*/
|
||||
export function aggregateProductivityAnalytics(
|
||||
db: Database,
|
||||
@@ -181,6 +217,46 @@ export function aggregateProductivityAnalytics(
|
||||
? { value: null, unavailable: true }
|
||||
: { value: Math.round((loc.value / HUMAN_LINES_PER_HOUR) * 10) / 10, unavailable: false };
|
||||
|
||||
const durationClauses: string[] = [
|
||||
`"column" = 'done'`,
|
||||
"executionCompletedAt IS NOT NULL",
|
||||
"cumulativeActiveMs IS NOT NULL",
|
||||
"cumulativeActiveMs > 0",
|
||||
];
|
||||
const durationParams: string[] = [];
|
||||
if (query.from !== undefined) {
|
||||
durationClauses.push("executionCompletedAt >= ?");
|
||||
durationParams.push(query.from);
|
||||
}
|
||||
if (query.to !== undefined) {
|
||||
durationClauses.push("executionCompletedAt <= ?");
|
||||
durationParams.push(query.to);
|
||||
}
|
||||
const durationRows = db
|
||||
.prepare(
|
||||
`SELECT cumulativeActiveMs FROM tasks WHERE ${durationClauses.join(" AND ")} ORDER BY cumulativeActiveMs ASC`,
|
||||
)
|
||||
.all(...durationParams) as TaskDurationRow[];
|
||||
const durations = durationRows.map((row) => row.cumulativeActiveMs);
|
||||
const totalDurationMs = durations.reduce((sum, durationMs) => sum + durationMs, 0);
|
||||
const taskDuration: TaskDurationSummary = durations.length > 0
|
||||
? {
|
||||
completedTasks: durations.length,
|
||||
averageMs: totalDurationMs / durations.length,
|
||||
medianMs: median(durations),
|
||||
p90Ms: nearestRankPercentile(durations, 0.9),
|
||||
totalMs: totalDurationMs,
|
||||
unavailable: false,
|
||||
}
|
||||
: {
|
||||
completedTasks: 0,
|
||||
averageMs: null,
|
||||
medianMs: null,
|
||||
p90Ms: null,
|
||||
totalMs: null,
|
||||
unavailable: true,
|
||||
};
|
||||
|
||||
// Pull requests. `pull_requests.createdAt` is an INTEGER epoch-ms column, so
|
||||
// convert the ISO bounds to epoch ms for comparison.
|
||||
const prClauses: string[] = [];
|
||||
@@ -209,5 +285,6 @@ export function aggregateProductivityAnalytics(
|
||||
pullRequests,
|
||||
loc,
|
||||
hoursSaved,
|
||||
taskDuration,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
return "";
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [postSubmitFocusRequest, setPostSubmitFocusRequest] = useState(0);
|
||||
// isExpanded controls textarea height styling (auto-resize)
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
// isDisclosureExpanded controls visibility of the controls panel (Deps, Models, etc.)
|
||||
@@ -106,7 +107,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const touchButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const justResetRef = useRef(false);
|
||||
const justSubmittedRef = useRef(false);
|
||||
const postSubmitFocusTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const handledPostSubmitFocusRequestRef = useRef(0);
|
||||
const previousProjectIdRef = useRef(projectId);
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const pendingImagesRef = useRef<PendingImage[]>([]);
|
||||
@@ -321,21 +323,46 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
}
|
||||
}, [description, isExpanded, autoResize]);
|
||||
|
||||
// Restore focus after an in-component submission completes (when textarea is re-enabled).
|
||||
const requestFocusAfterSuccessfulSubmit = useCallback(() => {
|
||||
setPostSubmitFocusRequest((request) => request + 1);
|
||||
}, []);
|
||||
|
||||
/*
|
||||
FNXC:QuickEntryFocus 2026-06-19-16:50:
|
||||
Desktop users should keep typing after Enter, Save, or duplicate-confirmed task creation, while mobile users must not receive an automatic focus that opens the soft keyboard.
|
||||
Drive the post-submit focus from a resolved-submit state request instead of a ref-gated effect so React state ordering cannot skip the restoration when the form clears under broad jsdom load.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!justSubmittedRef.current || isSubmitting || description !== "" || !textareaRef.current) {
|
||||
if (
|
||||
postSubmitFocusRequest === 0 ||
|
||||
handledPostSubmitFocusRequestRef.current === postSubmitFocusRequest ||
|
||||
isSubmitting ||
|
||||
description !== "" ||
|
||||
!textareaRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
justSubmittedRef.current = false;
|
||||
// Use setTimeout to ensure focus happens after React re-enables the textarea.
|
||||
const focusTimeout = setTimeout(() => {
|
||||
handledPostSubmitFocusRequestRef.current = postSubmitFocusRequest;
|
||||
|
||||
if (postSubmitFocusTimeoutRef.current) {
|
||||
clearTimeout(postSubmitFocusTimeoutRef.current);
|
||||
}
|
||||
|
||||
postSubmitFocusTimeoutRef.current = setTimeout(() => {
|
||||
postSubmitFocusTimeoutRef.current = null;
|
||||
if (typeof window !== "undefined" && window.innerWidth > MOBILE_BREAKPOINT_PX) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, 0);
|
||||
return () => clearTimeout(focusTimeout);
|
||||
}, [isSubmitting, description]);
|
||||
|
||||
return () => {
|
||||
if (postSubmitFocusTimeoutRef.current) {
|
||||
clearTimeout(postSubmitFocusTimeoutRef.current);
|
||||
postSubmitFocusTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [description, isSubmitting, postSubmitFocusRequest]);
|
||||
|
||||
// Clear dep search when dropdown closes
|
||||
useEffect(() => {
|
||||
@@ -542,7 +569,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
}
|
||||
}
|
||||
resetForm();
|
||||
justSubmittedRef.current = true;
|
||||
requestFocusAfterSuccessfulSubmit();
|
||||
} catch (err) {
|
||||
setDescription(originalDescription);
|
||||
addToast(getErrorMessage(err) || t("tasks.createFailed", "Failed to create task"), "error");
|
||||
@@ -574,6 +601,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
projectId,
|
||||
addToast,
|
||||
resetForm,
|
||||
requestFocusAfterSuccessfulSubmit,
|
||||
]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
|
||||
@@ -762,6 +762,7 @@ function InnerEditor({
|
||||
});
|
||||
const [templateFilter, setTemplateFilter] = useState("");
|
||||
const [templateConflict, setTemplateConflict] = useState<string | null>(null);
|
||||
const canvasNodesMaterializedRef = useRef(false);
|
||||
|
||||
// U12: the columns/fields authoring panels live in the left sidebar (below the
|
||||
// workflow list) as collapsible disclosure sections. Each section's collapsed
|
||||
@@ -1144,6 +1145,7 @@ function InnerEditor({
|
||||
|
||||
// Load the active workflow graph into the canvas.
|
||||
useEffect(() => {
|
||||
canvasNodesMaterializedRef.current = false;
|
||||
if (!activeWorkflow) {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
@@ -1198,6 +1200,10 @@ function InnerEditor({
|
||||
}
|
||||
}, [activeWorkflow, setNodes, setEdges, setViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
if (nodes.length > 0) canvasNodesMaterializedRef.current = true;
|
||||
}, [nodes]);
|
||||
|
||||
// `?panel=settings` deep link (U6/U9 redirect stubs): once the active workflow
|
||||
// has loaded, scroll the settings panel into view. Runs once per editor open.
|
||||
const didScrollToSettings = useRef(false);
|
||||
@@ -1391,7 +1397,8 @@ function InnerEditor({
|
||||
const handleInsertFragment = useCallback(
|
||||
(fragment: WorkflowDefinition) => {
|
||||
if (isBuiltin) return false;
|
||||
const conflicts = fragmentSeamConflicts(fragment.ir, nodes);
|
||||
const loadedIrFallback = canvasNodesMaterializedRef.current ? undefined : activeWorkflow?.ir;
|
||||
const conflicts = fragmentSeamConflicts(fragment.ir, nodes, loadedIrFallback);
|
||||
if (conflicts.length > 0) {
|
||||
setTemplateConflict(conflicts.join(", "));
|
||||
return false;
|
||||
@@ -1409,7 +1416,7 @@ function InnerEditor({
|
||||
setSelectedNodeId(result.insertedNodeIds[0] ?? null);
|
||||
return true;
|
||||
},
|
||||
[isBuiltin, nodes, edges, setNodes, setEdges],
|
||||
[isBuiltin, nodes, edges, activeWorkflow, setNodes, setEdges],
|
||||
);
|
||||
|
||||
// Auto-layout: one-click left-to-right tidy (U5, R8). Recomputes positions
|
||||
|
||||
@@ -277,6 +277,10 @@ async function flushPendingTimers() {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForSubmitSuccessToClear(textarea: HTMLTextAreaElement) {
|
||||
await waitFor(() => expect(textarea.value).toBe(""));
|
||||
}
|
||||
|
||||
function openPriorityMenu() {
|
||||
fireEvent.click(screen.getByTestId("quick-entry-priority-button"));
|
||||
}
|
||||
@@ -456,6 +460,7 @@ describe("QuickEntryBox", () => {
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
|
||||
await waitForSubmitSuccessToClear(textarea);
|
||||
await flushPendingTimers();
|
||||
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||
@@ -473,6 +478,7 @@ describe("QuickEntryBox", () => {
|
||||
clickSave();
|
||||
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
|
||||
await waitForSubmitSuccessToClear(textarea);
|
||||
await flushPendingTimers();
|
||||
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||
@@ -498,7 +504,7 @@ describe("QuickEntryBox", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create anyway" }));
|
||||
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(textarea.value).toBe(""));
|
||||
await waitForSubmitSuccessToClear(textarea);
|
||||
await flushPendingTimers();
|
||||
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||
@@ -519,12 +525,33 @@ describe("QuickEntryBox", () => {
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
|
||||
await waitForSubmitSuccessToClear(textarea);
|
||||
await flushPendingTimers();
|
||||
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).not.toBe(textarea);
|
||||
});
|
||||
|
||||
it("preserves the draft without auto-focus when submission fails", async () => {
|
||||
mockDesktopViewport();
|
||||
const addToast = vi.fn();
|
||||
const onCreate = vi.fn().mockRejectedValue(new Error("create failed"));
|
||||
renderQuickEntryBox({ addToast, onCreate });
|
||||
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||
const focusSpy = vi.spyOn(textarea, "focus");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Failed task" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(textarea.value).toBe("Failed task"));
|
||||
await flushPendingTimers();
|
||||
|
||||
expect(addToast).toHaveBeenCalledWith("create failed", "error");
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).not.toBe(textarea);
|
||||
});
|
||||
|
||||
it("does not auto-focus after Escape clears a non-empty draft", async () => {
|
||||
mockDesktopViewport();
|
||||
renderQuickEntryBox({});
|
||||
|
||||
@@ -2655,26 +2655,42 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("clicking a fragment with a duplicate merge seam surfaces the inline conflict; no insertion", async () => {
|
||||
it("clicking a fragment with a duplicate merge seam surfaces the desktop inline conflict; no insertion", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def(), mergeFragment()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-palette-templates");
|
||||
/*
|
||||
* FNXC:WorkflowNodeEditor 2026-06-19-09:02:
|
||||
* The duplicate-merge-seam guard must prove the fragment click is the only attempted canvas mutation.
|
||||
* Wait for the base workflow node before taking the mutation baseline so cold ReactFlow rendering cannot masquerade as insertion.
|
||||
* FNXC:WorkflowNodeEditor 2026-06-19-18:10:
|
||||
* The duplicate-merge-seam guard must not wait for React Flow nodes before protecting insertion.
|
||||
* Click as soon as the palette is usable, then prove the loaded merge prompt remains the only prompt node after the conflict surfaces.
|
||||
*/
|
||||
await screen.findByTestId("wf-node-gate");
|
||||
|
||||
const beforeNodes = document.querySelectorAll('[data-testid^="wf-node-"]').length;
|
||||
fireEvent.click(screen.getByTestId("wf-tpl-fragment-WF-FRAG-MERGE"));
|
||||
|
||||
const conflict = await screen.findByTestId("wf-tpl-conflict");
|
||||
expect(conflict).toHaveAttribute("role", "alert");
|
||||
expect(conflict).toHaveTextContent(/merge/);
|
||||
// No node added.
|
||||
expect(document.querySelectorAll('[data-testid^="wf-node-"]').length).toBe(beforeNodes);
|
||||
await screen.findByTestId("wf-node-gate");
|
||||
await waitFor(() => expect(document.querySelectorAll('.wf-node[data-testid^="wf-node-"]')).toHaveLength(4));
|
||||
});
|
||||
|
||||
it("clicking a duplicate merge fragment surfaces the mobile inline conflict; no insertion", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def(), mergeFragment()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "QA" }));
|
||||
const graph = await screen.findByTestId("wf-mobile-shell");
|
||||
const beforeNodes = within(graph).queryAllByTestId(/^mobile-wf-node-/).length;
|
||||
|
||||
fireEvent.click(screen.getByTestId("wf-mobile-tab-add"));
|
||||
fireEvent.click(await screen.findByTestId("wf-mobile-tpl-fragment-WF-FRAG-MERGE"));
|
||||
|
||||
const conflict = await screen.findByTestId("wf-mobile-tpl-conflict");
|
||||
expect(conflict).toHaveAttribute("role", "alert");
|
||||
expect(conflict).toHaveTextContent(/merge/);
|
||||
fireEvent.click(screen.getByTestId("wf-mobile-tab-graph"));
|
||||
await waitFor(() => expect(screen.getAllByTestId(/^mobile-wf-node-/)).toHaveLength(beforeNodes));
|
||||
});
|
||||
|
||||
it("clicking a clean fragment inserts its non-start/end nodes", async () => {
|
||||
|
||||
@@ -117,6 +117,14 @@ function populatedProductivityFixture() {
|
||||
commits: 2,
|
||||
pullRequests: 1,
|
||||
loc: { value: 42, unavailable: false },
|
||||
taskDuration: {
|
||||
completedTasks: 2,
|
||||
averageMs: 1_800_000,
|
||||
medianMs: 1_800_000,
|
||||
p90Ms: 2_400_000,
|
||||
totalMs: 3_600_000,
|
||||
unavailable: false,
|
||||
},
|
||||
byLanguage: [{ language: "TypeScript", count: 6 }],
|
||||
};
|
||||
}
|
||||
@@ -127,6 +135,14 @@ function emptyProductivityFixture() {
|
||||
commits: 0,
|
||||
pullRequests: 0,
|
||||
loc: { value: null, unavailable: true },
|
||||
taskDuration: {
|
||||
completedTasks: 0,
|
||||
averageMs: null,
|
||||
medianMs: null,
|
||||
p90Ms: null,
|
||||
totalMs: null,
|
||||
unavailable: true,
|
||||
},
|
||||
byLanguage: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,8 +14,11 @@ vi.mock("../../../api/legacy", () => ({
|
||||
vi.mock("../../../api", () => ({
|
||||
fetchSystemStats: () => Promise.resolve(systemStatsFixture()),
|
||||
fetchGlobalSettings: () => Promise.resolve({ vitestAutoKillEnabled: true, vitestKillThresholdPct: 90 }),
|
||||
fetchConfig: vi.fn().mockResolvedValue({ maxConcurrent: 2, rootDir: "/" }),
|
||||
fetchSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxTriageConcurrent: 1, maxWorktrees: 5 }),
|
||||
killVitestProcesses: () => Promise.resolve({ killed: 0, pids: [] }),
|
||||
updateGlobalSettings: () => Promise.resolve({}),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
function emptyTokenFixture() {
|
||||
@@ -102,6 +105,14 @@ function populatedProductivityFixture() {
|
||||
commits: 2,
|
||||
pullRequests: 1,
|
||||
loc: { value: 42, unavailable: false },
|
||||
taskDuration: {
|
||||
completedTasks: 2,
|
||||
averageMs: 1_800_000,
|
||||
medianMs: 1_800_000,
|
||||
p90Ms: 2_400_000,
|
||||
totalMs: 3_600_000,
|
||||
unavailable: false,
|
||||
},
|
||||
byLanguage: [{ language: "TypeScript", count: 6 }],
|
||||
};
|
||||
}
|
||||
@@ -112,6 +123,14 @@ function emptyProductivityFixture() {
|
||||
commits: 0,
|
||||
pullRequests: 0,
|
||||
loc: { value: null, unavailable: true },
|
||||
taskDuration: {
|
||||
completedTasks: 0,
|
||||
averageMs: null,
|
||||
medianMs: null,
|
||||
p90Ms: null,
|
||||
totalMs: null,
|
||||
unavailable: true,
|
||||
},
|
||||
byLanguage: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Bar } from "../charts/Bar";
|
||||
import { PieChart } from "../charts/recharts";
|
||||
import { AreaShell } from "./AreaShell";
|
||||
import { useAnalyticsArea } from "./useAnalyticsArea";
|
||||
import { formatCount } from "./areaShared";
|
||||
import { formatCount, formatDurationMs } from "./areaShared";
|
||||
|
||||
/*
|
||||
FNXC:CommandCenterCharts 2026-06-18-23:40:
|
||||
@@ -28,6 +28,9 @@ ProductivityAnalytics exposes a categorical language distribution but no per-day
|
||||
*
|
||||
* FNXC:CommandCenterProductivity 2026-06-19-12:00:
|
||||
* Human hours saved is a derived estimate from LOC. It must render the unavailable "—" sentinel, never 0, when LOC is unavailable and stay visibly labeled as an estimate rather than exact accounting.
|
||||
*
|
||||
* FNXC:CommandCenter 2026-06-19-12:10:
|
||||
* The task-duration block shows active execution time from completed tasks and must use the same unavailable "—" contract as LOC so absent duration history is never displayed as 0.
|
||||
*/
|
||||
export function ProductivityArea({ range }: { range: DateRange }) {
|
||||
const { t } = useTranslation("app");
|
||||
@@ -55,12 +58,36 @@ export function ProductivityArea({ range }: { range: DateRange }) {
|
||||
[data?.byLanguage],
|
||||
);
|
||||
|
||||
const taskDuration = data?.taskDuration ?? {
|
||||
completedTasks: 0,
|
||||
averageMs: null,
|
||||
medianMs: null,
|
||||
p90Ms: null,
|
||||
totalMs: null,
|
||||
unavailable: true,
|
||||
};
|
||||
const isEmpty =
|
||||
!data ||
|
||||
(data.modifiedFiles === 0 && data.commits === 0 && data.pullRequests === 0);
|
||||
(data.modifiedFiles === 0 &&
|
||||
data.commits === 0 &&
|
||||
data.pullRequests === 0 &&
|
||||
taskDuration.completedTasks === 0);
|
||||
|
||||
const locUnavailable = !data || data.loc.unavailable || data.loc.value === null;
|
||||
const hoursSavedUnavailable = !data || data.hoursSaved.unavailable || data.hoursSaved.value === null;
|
||||
const durationUnavailable = !data || taskDuration.unavailable;
|
||||
const durationTitle = t(
|
||||
"commandCenter.productivity.durationUnavailable",
|
||||
"Task duration is unavailable until completed tasks have active execution time recorded",
|
||||
);
|
||||
const renderDurationValue = (value: number | null, testId: string) =>
|
||||
durationUnavailable || value === null ? (
|
||||
<span className="cc-unavailable" title={durationTitle} data-testid={testId}>
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
formatDurationMs(value)
|
||||
);
|
||||
|
||||
return (
|
||||
<AreaShell testId="productivity" isLoading={isLoading} error={error} isEmpty={isEmpty}>
|
||||
@@ -129,6 +156,40 @@ export function ProductivityArea({ range }: { range: DateRange }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cc-area-section">
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.productivity.durationTitle", "Task duration")}</h3>
|
||||
<div className="cc-stat-grid">
|
||||
<div className="card cc-stat-card" data-testid="cc-productivity-duration-completed">
|
||||
<div className="cc-stat-label">{t("commandCenter.productivity.completedTasks", "Completed tasks")}</div>
|
||||
<div className="cc-stat-value">{formatCount(taskDuration.completedTasks)}</div>
|
||||
</div>
|
||||
<div className="card cc-stat-card" data-testid="cc-productivity-duration-avg">
|
||||
<div className="cc-stat-label">{t("commandCenter.productivity.averageDuration", "Average")}</div>
|
||||
<div className="cc-stat-value">
|
||||
{renderDurationValue(taskDuration.averageMs, "cc-productivity-duration-avg-unavailable")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card cc-stat-card" data-testid="cc-productivity-duration-median">
|
||||
<div className="cc-stat-label">{t("commandCenter.productivity.medianDuration", "Median")}</div>
|
||||
<div className="cc-stat-value">
|
||||
{renderDurationValue(taskDuration.medianMs, "cc-productivity-duration-median-unavailable")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card cc-stat-card" data-testid="cc-productivity-duration-p90">
|
||||
<div className="cc-stat-label">{t("commandCenter.productivity.p90Duration", "P90")}</div>
|
||||
<div className="cc-stat-value">
|
||||
{renderDurationValue(taskDuration.p90Ms, "cc-productivity-duration-p90-unavailable")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card cc-stat-card" data-testid="cc-productivity-duration-total">
|
||||
<div className="cc-stat-label">{t("commandCenter.productivity.totalDuration", "Total active")}</div>
|
||||
<div className="cc-stat-value">
|
||||
{renderDurationValue(taskDuration.totalMs, "cc-productivity-duration-total-unavailable")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cc-area-section">
|
||||
<h3 className="cc-area-section-title">
|
||||
{t("commandCenter.productivity.byLanguage", "Files by language")}
|
||||
|
||||
@@ -777,7 +777,7 @@ describe("ToolsArea", () => {
|
||||
});
|
||||
|
||||
describe("ProductivityArea", () => {
|
||||
it("renders unavailable LOC and hours saved as dash sentinels, never 0 and keeps chart geometry finite", async () => {
|
||||
it("renders unavailable LOC and hours saved as dash sentinels, duration stats, and finite chart geometry", async () => {
|
||||
apiMock.mockResolvedValue({
|
||||
from: "2026-06-08",
|
||||
to: null,
|
||||
@@ -787,6 +787,14 @@ describe("ProductivityArea", () => {
|
||||
pullRequests: 2,
|
||||
loc: { value: null, unavailable: true },
|
||||
hoursSaved: { value: null, unavailable: true },
|
||||
taskDuration: {
|
||||
completedTasks: 3,
|
||||
averageMs: 5_400_000,
|
||||
medianMs: 3_600_000,
|
||||
p90Ms: 7_200_000,
|
||||
totalMs: 16_200_000,
|
||||
unavailable: false,
|
||||
},
|
||||
});
|
||||
render(<ProductivityArea range={range7d} />);
|
||||
await screen.findByTestId("cc-area-productivity");
|
||||
@@ -799,6 +807,11 @@ describe("ProductivityArea", () => {
|
||||
expect(screen.getByTestId("cc-productivity-hours-saved").textContent).not.toContain("0");
|
||||
// The commits outcome counter still shows a real number.
|
||||
expect(screen.getByTestId("cc-productivity-commits").textContent).toContain("4");
|
||||
expect(screen.getByTestId("cc-productivity-duration-completed").textContent).toContain("3");
|
||||
expect(screen.getByTestId("cc-productivity-duration-avg").textContent).toContain("1h 30m");
|
||||
expect(screen.getByTestId("cc-productivity-duration-median").textContent).toContain("1h");
|
||||
expect(screen.getByTestId("cc-productivity-duration-p90").textContent).toContain("2h");
|
||||
expect(screen.getByTestId("cc-productivity-duration-total").textContent).toContain("4h 30m");
|
||||
expect(screen.getByRole("list", { name: "Files by language" })).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-productivity-pie")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "Language share" })).toBeTruthy();
|
||||
@@ -815,11 +828,20 @@ describe("ProductivityArea", () => {
|
||||
pullRequests: 0,
|
||||
loc: { value: null, unavailable: true },
|
||||
hoursSaved: { value: null, unavailable: true },
|
||||
taskDuration: {
|
||||
completedTasks: 0,
|
||||
averageMs: null,
|
||||
medianMs: null,
|
||||
p90Ms: null,
|
||||
totalMs: null,
|
||||
unavailable: true,
|
||||
},
|
||||
});
|
||||
const { unmount } = render(<ProductivityArea range={range7d} />);
|
||||
await screen.findByTestId("cc-area-productivity-empty");
|
||||
expect(screen.queryByRole("list", { name: "Files by language" })).toBeNull();
|
||||
expect(screen.queryByTestId("cc-productivity-pie")).toBeNull();
|
||||
expect(screen.queryByTestId("cc-productivity-duration-avg")).toBeNull();
|
||||
unmount();
|
||||
|
||||
apiMock.mockImplementationOnce(() => new Promise(() => undefined));
|
||||
@@ -832,6 +854,39 @@ describe("ProductivityArea", () => {
|
||||
await screen.findByTestId("cc-area-productivity-error");
|
||||
expect(screen.getByTestId("cc-area-productivity-error").textContent).toContain("productivity failed");
|
||||
expect(screen.queryByTestId("cc-productivity-pie")).toBeNull();
|
||||
expect(screen.queryByTestId("cc-productivity-duration-avg")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders unavailable task duration as dash sentinels, never zero", async () => {
|
||||
apiMock.mockResolvedValue({
|
||||
from: "2026-06-08",
|
||||
to: null,
|
||||
modifiedFiles: 1,
|
||||
byLanguage: [{ language: "ts", count: 1 }],
|
||||
commits: 0,
|
||||
pullRequests: 0,
|
||||
loc: { value: null, unavailable: true },
|
||||
hoursSaved: { value: null, unavailable: true },
|
||||
taskDuration: {
|
||||
completedTasks: 0,
|
||||
averageMs: null,
|
||||
medianMs: null,
|
||||
p90Ms: null,
|
||||
totalMs: null,
|
||||
unavailable: true,
|
||||
},
|
||||
});
|
||||
|
||||
render(<ProductivityArea range={range7d} />);
|
||||
await screen.findByTestId("cc-area-productivity");
|
||||
|
||||
const avg = screen.getByTestId("cc-productivity-duration-avg-unavailable");
|
||||
expect(avg.textContent).toBe("—");
|
||||
expect(avg.getAttribute("title")).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-productivity-duration-median-unavailable").textContent).toBe("—");
|
||||
expect(screen.getByTestId("cc-productivity-duration-p90-unavailable").textContent).toBe("—");
|
||||
expect(screen.getByTestId("cc-productivity-duration-total-unavailable").textContent).toBe("—");
|
||||
expect(screen.getByTestId("cc-productivity-duration-avg").textContent).not.toContain("0");
|
||||
});
|
||||
|
||||
it("keeps the productivity pie safe for single-item and non-finite language data", async () => {
|
||||
@@ -844,6 +899,14 @@ describe("ProductivityArea", () => {
|
||||
pullRequests: 0,
|
||||
loc: { value: null, unavailable: true },
|
||||
hoursSaved: { value: null, unavailable: true },
|
||||
taskDuration: {
|
||||
completedTasks: 0,
|
||||
averageMs: null,
|
||||
medianMs: null,
|
||||
p90Ms: null,
|
||||
totalMs: null,
|
||||
unavailable: true,
|
||||
},
|
||||
});
|
||||
render(<ProductivityArea range={range7d} />);
|
||||
|
||||
|
||||
@@ -29,6 +29,18 @@ export function formatCount(n: number): string {
|
||||
return Number.isFinite(n) ? Math.round(n).toLocaleString() : "0";
|
||||
}
|
||||
|
||||
/** Format milliseconds as compact active execution duration text. */
|
||||
export function formatDurationMs(ms: number | null): string {
|
||||
if (ms === null || !Number.isFinite(ms)) return "";
|
||||
const totalSeconds = Math.max(0, Math.round(ms / 1_000));
|
||||
const hours = Math.floor(totalSeconds / 3_600);
|
||||
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
||||
if (minutes > 0) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/** Format a USD cost result, returning the unavailable sentinel "—" when unknown. */
|
||||
export function formatCost(usd: number | null, unavailable: boolean): string {
|
||||
if (unavailable || usd === null || !Number.isFinite(usd)) {
|
||||
|
||||
@@ -1008,6 +1008,7 @@ function flowNodeSeam(node: FlowNode<WorkflowFlowNodeData>): string | undefined
|
||||
|
||||
/** Read the seam marker an IR node carries via its config.seam. */
|
||||
function irNodeSeam(node: WorkflowIrNode): string | undefined {
|
||||
if ((node.kind as string) === "merge") return "merge";
|
||||
const seam = node.config?.seam;
|
||||
return typeof seam === "string" ? seam : undefined;
|
||||
}
|
||||
@@ -1018,12 +1019,21 @@ function irNodeSeam(node: WorkflowIrNode): string | undefined {
|
||||
* empty result means the fragment is safe to insert. Other seam values
|
||||
* (planning, step-execute, …) are not pre-validated here (only the tracked
|
||||
* execute/review/merge seams are single-instance on the canvas).
|
||||
*
|
||||
* FNXC:WorkflowNodeEditor 2026-06-19-18:09:
|
||||
* Fragment insertion guards must not depend only on React Flow nodes, because the palette can render before the loaded workflow graph fully materializes under components-b shard load.
|
||||
* Union the transient canvas nodes with the authoritative active workflow IR so duplicate seam conflicts surface on desktop and mobile even during cold ReactFlow render timing.
|
||||
*/
|
||||
export function fragmentSeamConflicts(
|
||||
fragmentIr: WorkflowIr,
|
||||
nodes: FlowNode<WorkflowFlowNodeData>[],
|
||||
existingIr?: WorkflowIr,
|
||||
): string[] {
|
||||
const canvasSeams = new Set<string>();
|
||||
for (const node of existingIr?.nodes ?? []) {
|
||||
const seam = irNodeSeam(node);
|
||||
if (seam && SEAM_NAMES.has(seam)) canvasSeams.add(seam);
|
||||
}
|
||||
for (const n of nodes) {
|
||||
const seam = flowNodeSeam(n);
|
||||
if (seam && SEAM_NAMES.has(seam)) canvasSeams.add(seam);
|
||||
|
||||
@@ -103,6 +103,14 @@ describe("productivityAnalyticsToTable", () => {
|
||||
pullRequests: 1,
|
||||
loc: hoursSaved.unavailable ? { value: null, unavailable: true } : { value: 15, unavailable: false },
|
||||
hoursSaved,
|
||||
taskDuration: {
|
||||
completedTasks: 0,
|
||||
averageMs: null,
|
||||
medianMs: null,
|
||||
p90Ms: null,
|
||||
totalMs: null,
|
||||
unavailable: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,14 @@ function seedDb(db: Database, opts: { taskId: string; model: string; tokens: num
|
||||
});
|
||||
}
|
||||
|
||||
function seedCompletedTaskDuration(db: Database, opts: { id: string; cumulativeActiveMs: number; completedAt: string }): void {
|
||||
db.prepare(
|
||||
`INSERT INTO tasks
|
||||
(id, description, "column", cumulativeActiveMs, executionCompletedAt, createdAt, updatedAt)
|
||||
VALUES (?, 'desc', 'done', ?, ?, ?, ?)`,
|
||||
).run(opts.id, opts.cumulativeActiveMs, opts.completedAt, opts.completedAt, opts.completedAt);
|
||||
}
|
||||
|
||||
function seedAgentRun(db: Database, opts: { id: string; agentId: string; startedAt: string; status: string }): void {
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO agents (id, name, role, state, createdAt, updatedAt)
|
||||
@@ -295,6 +303,7 @@ describe("register-command-center-routes", () => {
|
||||
it("returns the tools / activity / productivity aggregator shapes", async () => {
|
||||
const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z";
|
||||
seedAgentRun(dbA, { id: "run-a1", agentId: "agent-route", startedAt: "2026-03-02T00:00:00.000Z", status: "active" });
|
||||
seedCompletedTaskDuration(dbA, { id: "FN-D1", cumulativeActiveMs: 120_000, completedAt: "2026-03-03T00:00:00.000Z" });
|
||||
const tools = await request(app, "GET", `/api/command-center/tools?${range}&projectId=proj-a`);
|
||||
expect(tools.status).toBe(200);
|
||||
expect(tools.body).toHaveProperty("autonomyRatio");
|
||||
@@ -312,6 +321,11 @@ describe("register-command-center-routes", () => {
|
||||
expect(prod.body).toHaveProperty("loc");
|
||||
expect(prod.body).toHaveProperty("hoursSaved");
|
||||
expect(prod.body).toHaveProperty("byLanguage");
|
||||
expect(prod.body).toHaveProperty("taskDuration");
|
||||
expect((prod.body as { taskDuration: { completedTasks: number; totalMs: number } }).taskDuration).toMatchObject({
|
||||
completedTasks: 1,
|
||||
totalMs: 120_000,
|
||||
});
|
||||
|
||||
seedGithubIssueMetrics(dbA, { prefix: "FN-A", repo: "acme/alpha", filed: 2, fixed: 1 });
|
||||
const github = await request(app, "GET", `/api/command-center/github?${range}&projectId=proj-a`);
|
||||
@@ -539,6 +553,7 @@ describe("register-command-center-routes", () => {
|
||||
|
||||
it("?format=csv works for tools / activity / productivity endpoints", async () => {
|
||||
const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z";
|
||||
seedCompletedTaskDuration(dbA, { id: "FN-DCSV", cumulativeActiveMs: 120_000, completedAt: "2026-03-03T00:00:00.000Z" });
|
||||
for (const [path, filename] of [
|
||||
["tools", "command-center-tools.csv"],
|
||||
["activity", "command-center-activity.csv"],
|
||||
@@ -556,6 +571,13 @@ describe("register-command-center-routes", () => {
|
||||
`attachment; filename="${filename}"`,
|
||||
);
|
||||
expect((res.body as string).split("\r\n")[0].length).toBeGreaterThan(0);
|
||||
if (path === "productivity") {
|
||||
expect(res.body as string).toContain("completedTasks,1");
|
||||
expect(res.body as string).toContain("avgDurationMs,120000");
|
||||
expect(res.body as string).toContain("medianDurationMs,120000");
|
||||
expect(res.body as string).toContain("p90DurationMs,120000");
|
||||
expect(res.body as string).toContain("totalDurationMs,120000");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -174,6 +174,11 @@ export function productivityAnalyticsToTable(
|
||||
rows.push(["pullRequests", result.pullRequests]);
|
||||
rows.push(["loc", result.loc.value ?? ""]);
|
||||
rows.push(["hoursSaved", result.hoursSaved.value ?? ""]);
|
||||
rows.push(["completedTasks", result.taskDuration.completedTasks]);
|
||||
rows.push(["avgDurationMs", result.taskDuration.averageMs ?? ""]);
|
||||
rows.push(["medianDurationMs", result.taskDuration.medianMs ?? ""]);
|
||||
rows.push(["p90DurationMs", result.taskDuration.p90Ms ?? ""]);
|
||||
rows.push(["totalDurationMs", result.taskDuration.totalMs ?? ""]);
|
||||
return { header, rows };
|
||||
}
|
||||
|
||||
|
||||
@@ -279,14 +279,19 @@ FNXC:DashboardTestQuarantine 2026-06-19-08:17:
|
||||
FN-6726 workspace verification observed the WorkflowNodeEditor duplicate-merge-seam template conflict test fail only in the broad components-b shard, then pass on targeted rerun.
|
||||
Quarantine the concurrency-sensitive workflow editor file under the deletion ratchet instead of changing unrelated template insertion behavior or appeasing the test.
|
||||
|
||||
FNXC:DashboardTestQuarantine 2026-06-19-18:12:
|
||||
FN-6744 rescued WorkflowNodeEditor before the 2026-07-03 deletion deadline by deriving duplicate-fragment seam conflicts from the active workflow IR plus React Flow nodes.
|
||||
Keep it out of this exclude list so desktop and mobile template guards continue proving duplicate merge seams surface an inline alert with no insertion under components-b shard load.
|
||||
|
||||
FNXC:DashboardTestQuarantine 2026-06-19-15:40:
|
||||
FN-6742 rescued session-cross-tab before the 2026-07-03 deletion ratchet by reproducing ENOTEMPTY in dashboard-api-quality-backfill and fixing the test-owned route/close-callback teardown seam.
|
||||
Keep it out of this exclude list so loaded API shards keep exercising cross-tab locking, beacon release, stale-lock expiry, SSE summaries, and stale-session cleanup.
|
||||
|
||||
FNXC:DashboardTestQuarantine 2026-06-19-16:50:
|
||||
FN-6743 rescued QuickEntryBox's third quarantine cycle by replacing the ref-gated post-submit focus effect with a resolved-submit focus trigger and broadening desktop/mobile submit coverage.
|
||||
Keep QuickEntryBox out of this exclude list so the dashboard app lanes exercise Enter, Save, duplicate-confirmed creation, mobile non-focus, and failure-preserves-draft focus invariants.
|
||||
*/
|
||||
const quarantinedDashboardTests: string[] = [
|
||||
"app/components/__tests__/QuickEntryBox.test.tsx",
|
||||
"app/components/__tests__/WorkflowNodeEditor.test.tsx",
|
||||
];
|
||||
const quarantinedDashboardTests: string[] = [];
|
||||
|
||||
const qualityApiTests = [
|
||||
// Critical HTTP/server behavior: auth, task/project/settings mutation,
|
||||
|
||||
@@ -148,6 +148,26 @@ test("isSharedInfraChange: returns false for .fusion artifacts", () => {
|
||||
assert.equal(isSharedInfraChange([".fusion/tasks/FN-5157/PROMPT.md"]), false);
|
||||
});
|
||||
|
||||
test("isSharedInfraChange: returns false for the test-quarantine data list", () => {
|
||||
// FN: editing scripts/lib/test-quarantine.json (a runtime data list of
|
||||
// quarantined tests, not executable infra) previously tripped the root
|
||||
// catch-all and forced gate mode, which DROPS affected-package coverage.
|
||||
assert.equal(isSharedInfraChange(["scripts/lib/test-quarantine.json"]), false);
|
||||
});
|
||||
|
||||
test("isSharedInfraChange: quarantine edit plus package change stays changed-only", () => {
|
||||
// A quarantine-list edit alongside real package work must keep the diff in
|
||||
// changed mode so the changed packages actually get tested.
|
||||
assert.equal(
|
||||
isSharedInfraChange([
|
||||
"scripts/lib/test-quarantine.json",
|
||||
"packages/core/src/productivity-analytics.ts",
|
||||
"packages/dashboard/app/components/QuickEntryBox.tsx",
|
||||
]),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("isSharedInfraChange: still returns true for root config edges", () => {
|
||||
for (const file of ["tsconfig.json", ".npmrc", "Dockerfile"]) {
|
||||
assert.equal(isSharedInfraChange([file]), true, `${file} should still force the full suite`);
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
{
|
||||
"$comment": "Flaky-test quarantine ledger (deletion ratchet — see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date — the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config exclude is the mechanism, and the sweep is policy executed by whoever touches the suite.",
|
||||
"entries": [
|
||||
{
|
||||
"file": "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx",
|
||||
"reason": "FN-6697 local workspace `pnpm test` observed the post-submission focus restoration test fail in the broad dashboard app backfill shard, while a targeted rerun of QuickEntryBox with MailboxModal passed the QuickEntryBox assertions; quarantine the focus-timing flake instead of appeasing it while the terminal shortcut fix remains scoped.",
|
||||
"quarantinedAt": "2026-06-19"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx",
|
||||
"reason": "FN-6726 local workspace `pnpm test` observed the duplicate-merge-seam template conflict assertion fail only in the broad dashboard components-b shard, while a targeted rerun of that exact test passed; quarantine the workflow editor concurrency flake instead of appeasing unrelated template insertion behavior while the Command Center token containment fix remains scoped.",
|
||||
"quarantinedAt": "2026-06-19"
|
||||
},
|
||||
{
|
||||
"file": "packages/core/src/__tests__/store-concurrent-writes.test.ts",
|
||||
"reason": "FN-6741 merge verification observed store-concurrent-writes fail again only under the broad @fusion/core lane with SQLite BEGIN IMMEDIATE lock exhaustion after the other same-day core quarantine entries stayed loaded; re-quarantine this suite-load lock flake instead of widening SQLite lock-recovery timing, adding retries, or weakening concurrent-write assertions.",
|
||||
|
||||
@@ -604,6 +604,14 @@ function isTestIrrelevantRootPath(file) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The quarantine list is runtime DATA (which tests are skipped), not
|
||||
// executable test infra. Editing it must not trip the root catch-all below
|
||||
// and force gate mode — gate mode drops affected-package coverage, so a
|
||||
// dev's real changes would go untested just because they touched the list.
|
||||
if (file === "scripts/lib/test-quarantine.json") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ["README", "CHANGELOG.md", "LICENSE", "LICENSE.md"].includes(file);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user