diff --git a/.changeset/fn-6975-mission-modal-stream.md b/.changeset/fn-6975-mission-modal-stream.md new file mode 100644 index 0000000000..43225f5d9a --- /dev/null +++ b/.changeset/fn-6975-mission-modal-stream.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make Plan Mission with AI desktop modal movable and recover cleanly from stream failures. +category: fix +dev: Dashboard mission interview now uses floating desktop geometry and normalizes terminal SSE errors into one retry state. diff --git a/AGENTS.md b/AGENTS.md index 0c1e5f26d6..4c5742d1cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,7 @@ The merge gate is thin and trusted: CI blocks PRs on exactly Lint, Typecheck, Bu pnpm test # gate suite + changed-only affected tests (bounded; never full-suite) pnpm test:gate # the merge gate: curated engine-core suite + CI-shape test pnpm smoke:boot # boot smoke: CLI --help + real serve /api/health +pnpm verify:fast # TEST-FREE verification: typecheck + build (scoped to changed packages) + boot smoke; recommended non-test verification/testCommand. Additive — changes no default pnpm test:velocity # weekly report-only test velocity baseline; use -- --measure --write-report to refresh pnpm test:full # full workspace suite — explicit opt-in only pnpm lint @@ -114,6 +115,8 @@ pnpm build pnpm verify:workspace # deep opt-in verification (lint -> test:full -> build); NOT the merge gate ``` +`pnpm verify:fast` is the recommended **test-free verification** path: typecheck + build scoped to the changed packages (it reuses `pnpm test`'s changed-package resolution) plus the boot smoke once, with **no test run**. It is deterministic and flake-free, suitable as a project `testCommand`/verification command when you want non-test verification; the full suite stays available and runs non-blocking. It is additive and does not change `pnpm test`, the gate, or CI. See `docs/testing.md`. + ### Standing Rule: Flaky Tests Are Quarantined on Sight (Deletion Ratchet) - A test observed failing without a corresponding real bug in the change is QUARANTINED ON SIGHT: add an entry to `scripts/lib/test-quarantine.json` (`file`, `reason` with a link to the failing run, `quarantinedAt`) AND a matching one-line `exclude` in that package's vitest config, in the same commit. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index ab518cd4c7..2e4c156d68 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -724,6 +724,13 @@ Workflow behavior: - Feature triage and slice **Triage all features** create new tasks on the selected workflow. - If no workflow is selected, or workflow columns are unavailable, mission-created tasks continue to use the project default workflow. + + +Plan Mission with AI modal behavior: +- On desktop, the modal opens as a floating workspace that can be dragged by its title bar and resized from the window edges/corners. +- On mobile, the mission interview keeps the fixed full-screen/sheet-style layout so touch users retain the original focused flow. +- If the mission interview stream reports a terminal failure, the modal closes the failed stream, shows one normalized error, and offers retry without duplicating late error/complete events. + ## Roadmaps View Roadmaps view manages roadmap hierarchies (roadmaps, milestones, features) and planning handoff exports. diff --git a/docs/testing.md b/docs/testing.md index 217a3d4a5c..7c6f74770d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -32,12 +32,16 @@ Use the narrowest command that exercises the behavior you changed, then broaden pnpm test # gate suite + changed-only affected tests (bounded; never full-suite) pnpm test:gate # the merge gate: curated engine-core suite + CI-shape test pnpm smoke:boot # boot smoke: CLI --help + real serve /api/health +pnpm verify:fast # TEST-FREE verification: typecheck + build (scoped to changed packages) + boot smoke pnpm test:full # full workspace suite — explicit opt-in only pnpm lint # lint all packages pnpm build # build workspace packages (excludes desktop/mobile) pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build (NOT the merge gate) ``` + +`pnpm verify:fast` (`scripts/verify-fast.mjs`) is the recommended **test-free verification** command: it runs **typecheck + build scoped to the changed packages** (reusing the same git-diff / changed-package resolution as `pnpm test`) followed by the existing **boot smoke** once — and runs **no test suite**. It gives deterministic, flake-free signal in seconds, so it is a sound project `testCommand`/verification command when you want non-test verification. With no affected package (root/docs-only diff) it runs the boot smoke only. Each step is bounded by the shared `runWithWatchdog` (class `changed`) so a hang fails fast, and it exits nonzero on the first failing step. This is purely additive: it does not change `pnpm test`, the merge gate, or CI, and the full suite stays available (`pnpm test:full`, non-blocking on push to main). + `pnpm test:full` runs each package's default test script with capped worker fanout (`FUSION_TEST_TOTAL_WORKERS=4 FUSION_TEST_CONCURRENCY=2 pnpm -r --workspace-concurrency=2 test`). Do not casually raise worker counts; dashboard/jsdom and integration-heavy packages destabilize when oversubscribed. Use `VITEST_MAX_WORKERS=` only for targeted package-level investigation. diff --git a/package.json b/package.json index 229b5eb2b1..f0b7d6fcbb 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "build:exe": "pnpm build && pnpm --filter @runfusion/fusion build:exe", "build:exe:all": "pnpm build && pnpm --filter @runfusion/fusion build:exe:all", "test": "node scripts/test-changed.mjs", + "verify:fast": "node scripts/verify-fast.mjs", "test:scripts": "node scripts/run-script-tests.mjs", "test:workflow-release-check": "node scripts/workflow-reliability-release-check.mjs", "fn:cache-stats": "node scripts/cache-stats.mjs", diff --git a/packages/dashboard/app/api/__tests__/mission-interview-stream.test.ts b/packages/dashboard/app/api/__tests__/mission-interview-stream.test.ts new file mode 100644 index 0000000000..427b2b76b5 --- /dev/null +++ b/packages/dashboard/app/api/__tests__/mission-interview-stream.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { connectMissionInterviewStream } from "../legacy"; + +class MockEventSource { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSED = 2; + static instances: MockEventSource[] = []; + + url: string; + readyState = MockEventSource.OPEN; + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + close = vi.fn(() => { + this.readyState = MockEventSource.CLOSED; + }); + + private listeners = new Map void>>(); + + constructor(url: string) { + this.url = url; + MockEventSource.instances.push(this); + } + + addEventListener(eventName: string, listener: EventListener) { + const listeners = this.listeners.get(eventName) ?? []; + listeners.push(listener as (event: MessageEvent) => void); + this.listeners.set(eventName, listeners); + } + + dispatch(eventName: string, data = "", lastEventId = "") { + const event = { data, lastEventId } as MessageEvent; + for (const listener of this.listeners.get(eventName) ?? []) { + listener(event); + } + } +} + +describe("connectMissionInterviewStream", () => { + beforeEach(() => { + MockEventSource.instances = []; + vi.stubGlobal("EventSource", MockEventSource); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + function connect(handlers: Partial[2]> = {}, options?: { maxReconnectAttempts?: number }) { + const onError = vi.fn(); + const onComplete = vi.fn(); + const connection = connectMissionInterviewStream("mission-session-1", undefined, { onError, onComplete, ...handlers }, options); + const source = MockEventSource.instances[0]; + return { connection, source, onError, onComplete }; + } + + it.each([ + ["JSON message", JSON.stringify({ message: "The model rejected the prompt." }), "The model rejected the prompt."], + ["JSON error fallback", JSON.stringify({ error: "Provider is unavailable." }), "Provider is unavailable."], + ["JSON string", JSON.stringify("Please try again later."), "Please try again later."], + ["non-JSON text", "Temporary outage", "Temporary outage"], + ["empty data", "", "The mission interview stream was interrupted. Please retry the session."], + ["generic stream error", "Stream error", "The mission interview stream was interrupted. Please retry the session."], + ["JSON primitive", JSON.stringify(500), "The mission interview stream was interrupted. Please retry the session."], + ])("normalizes terminal error payloads: %s", (_name, data, expected) => { + const { source, onError } = connect(); + + source.dispatch("error", data); + + expect(onError).toHaveBeenCalledWith(expected); + expect(source.close).toHaveBeenCalledTimes(1); + }); + + it("dedupes late terminal events and closes the stale EventSource once", () => { + const { source, onError, onComplete } = connect(); + + source.dispatch("error", JSON.stringify({ message: "First failure" }), "1"); + source.dispatch("error", JSON.stringify({ message: "Second failure" }), "2"); + source.dispatch("complete", "", "3"); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith("First failure"); + expect(onComplete).not.toHaveBeenCalled(); + expect(source.close).toHaveBeenCalledTimes(1); + }); + + it("reports fatal reconnect exhaustion through the same recoverable error path", () => { + const { source, onError } = connect({}, { maxReconnectAttempts: 0 }); + + source.readyState = MockEventSource.CLOSED; + source.onerror?.(); + + expect(onError).toHaveBeenCalledWith("Connection lost"); + expect(source.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 1bf3ac85a8..0c98e0ef6d 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -3975,6 +3975,9 @@ function createResilientEventSource( } source.close(); + if (eventSource === source) { + eventSource = null; + } if (reconnectAttempts >= maxReconnectAttempts) { options.onFatalError?.("Connection lost"); @@ -8415,6 +8418,31 @@ export function createMissionFromInterview( }); } +const MISSION_INTERVIEW_STREAM_ERROR_MESSAGE = "The mission interview stream was interrupted. Please retry the session."; + +function normalizeMissionInterviewStreamError(data: string | undefined): string { + const raw = data?.trim() ?? ""; + if (!raw) return MISSION_INTERVIEW_STREAM_ERROR_MESSAGE; + + const normalizeMessage = (value: unknown): string => { + if (typeof value !== "string") return MISSION_INTERVIEW_STREAM_ERROR_MESSAGE; + const message = value.trim(); + if (!message || message === "Stream error") return MISSION_INTERVIEW_STREAM_ERROR_MESSAGE; + return message; + }; + + try { + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const message = (parsed as { message?: unknown; error?: unknown }).message ?? (parsed as { error?: unknown }).error; + return normalizeMessage(message); + } + return normalizeMessage(parsed); + } catch { + return normalizeMessage(raw); + } +} + /** Connect to mission interview SSE stream and handle events */ export function connectMissionInterviewStream( sessionId: string, @@ -8432,12 +8460,32 @@ export function connectMissionInterviewStream( const url = buildApiUrl(withProjectId(`/missions/interview/${encodeURIComponent(sessionId)}/stream`, projectId)); let keepAlive: { stop: () => void } | null = null; let connection: { close: () => void; isConnected: () => boolean } | null = null; + let terminalEventHandled = false; const stopKeepAlive = () => { keepAlive?.stop(); keepAlive = null; }; + const closeTerminalConnection = () => { + stopKeepAlive(); + connection?.close(); + }; + + const notifyTerminalError = (message: string) => { + if (terminalEventHandled) return; + terminalEventHandled = true; + closeTerminalConnection(); + handlers.onError?.(message); + }; + + const notifyTerminalComplete = () => { + if (terminalEventHandled) return; + terminalEventHandled = true; + closeTerminalConnection(); + handlers.onComplete?.(); + }; + const resilient = createResilientEventSource( url, { @@ -8471,17 +8519,14 @@ export function connectMissionInterviewStream( } }, error: (event) => { - try { - const parsed = JSON.parse(event.data); - handlers.onError?.(parsed.message || parsed); - } catch { - handlers.onError?.(event.data || "Stream error"); - } - connection?.close(); + /* + FNXC:MissionInterviewStream 2026-06-24-00:00: + Mission interview stream failures are terminal for the current EventSource. Normalize malformed/empty/generic payloads, close keepalive + SSE once, and ignore duplicate late error/complete events so the modal can show one recoverable Retry state instead of a stale spinner or raw stream failure. + */ + notifyTerminalError(normalizeMissionInterviewStreamError(event.data)); }, complete: () => { - handlers.onComplete?.(); - connection?.close(); + notifyTerminalComplete(); }, }, }, @@ -8489,8 +8534,7 @@ export function connectMissionInterviewStream( maxReconnectAttempts: options?.maxReconnectAttempts, onConnectionStateChange: handlers.onConnectionStateChange, onFatalError: (message) => { - stopKeepAlive(); - handlers.onError?.(message); + notifyTerminalError(normalizeMissionInterviewStreamError(message)); }, }, ); diff --git a/packages/dashboard/app/components/MissionInterviewModal.css b/packages/dashboard/app/components/MissionInterviewModal.css index b55747f6e2..84c4c690e6 100644 --- a/packages/dashboard/app/components/MissionInterviewModal.css +++ b/packages/dashboard/app/components/MissionInterviewModal.css @@ -1,3 +1,54 @@ +/* +FNXC:MissionInterviewModal 2026-06-24-00:00: +Desktop mission planning is a floating workspace hosted by FloatingWindow. The embedded `.modal` must fill the floating panel instead of applying the fixed planning modal size, while mobile keeps the full-screen sheet contract and hides FloatingWindow resize shells. +*/ +.floating-window--mission-interview .floating-window__body { + overflow: hidden; +} + +.floating-window--mission-interview .mission-interview-modal { + width: 100%; + height: 100%; + max-width: none; + max-height: none; + border: 0; + border-radius: inherit; + box-shadow: none; +} + +.floating-window--mission-interview .mission-interview-modal__drag-handle { + cursor: grab; + user-select: none; + touch-action: none; +} + +.floating-window--mission-interview .mission-interview-modal__drag-handle:active { + cursor: grabbing; +} + +@media (max-width: 768px) { + .floating-window--mission-interview { + inset: 0 !important; + width: 100vw !important; + height: 100dvh !important; + min-width: 0 !important; + min-height: 0 !important; + max-width: 100vw !important; + max-height: 100dvh !important; + border: 0; + border-radius: 0; + box-shadow: none; + } + + .floating-window--mission-interview .floating-window__resize-handle { + display: none; + } + + .floating-window--mission-interview .mission-interview-modal { + border-radius: 0; + } +} + .roadmap-list { display: flex; flex-direction: column; diff --git a/packages/dashboard/app/components/MissionInterviewModal.tsx b/packages/dashboard/app/components/MissionInterviewModal.tsx index 056051c70d..43111a6e60 100644 --- a/packages/dashboard/app/components/MissionInterviewModal.tsx +++ b/packages/dashboard/app/components/MissionInterviewModal.tsx @@ -47,6 +47,7 @@ import { } from "lucide-react"; import { ConversationHistory } from "./ConversationHistory"; import { CustomModelDropdown } from "./CustomModelDropdown"; +import { FloatingWindow } from "./FloatingWindow"; import { useSessionLock } from "../hooks/useSessionLock"; import { useAiSessionSync } from "../hooks/useAiSessionSync"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; @@ -125,7 +126,6 @@ export function MissionInterviewModal({ const [editedSummary, setEditedSummary] = useState(null); const [_hasProgress, setHasProgress] = useState(false); const hasAutoStartedRef = useRef(false); - const overlayMouseDownOnSelfRef = useRef(false); const [streamingOutput, setStreamingOutput] = useState(""); const [showThinking, setShowThinking] = useState(true); const [isReconnecting, setIsReconnecting] = useState(false); @@ -631,7 +631,6 @@ export function MissionInterviewModal({ streamConnectionRef.current?.close(); streamConnectionRef.current = null; - overlayMouseDownOnSelfRef.current = false; setIsReconnecting(false); setIsRetrying(false); setIsCreating(false); @@ -641,7 +640,6 @@ export function MissionInterviewModal({ const handleSendToBackground = useCallback(() => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; - overlayMouseDownOnSelfRef.current = false; setIsReconnecting(false); setIsRetrying(false); setIsCreating(false); @@ -838,22 +836,23 @@ export function MissionInterviewModal({ if (!isOpen) return null; return ( -
{ - overlayMouseDownOnSelfRef.current = e.target === e.currentTarget; - }} - onClick={(e) => { - if (e.target === e.currentTarget && overlayMouseDownOnSelfRef.current) { - handleClose(); - } - overlayMouseDownOnSelfRef.current = false; - }} - role="dialog" - aria-modal="true" + -
-
+ {/* + FNXC:MissionInterviewModal 2026-06-24-00:00: + The Plan Mission with AI workspace must be draggable and resizable on desktop by delegating geometry to FloatingWindow, while mobile keeps the existing full-screen/sheet-like mission interview flow. Keep one embedded mission header so close/send-to-background/session-lock controls do not duplicate FloatingWindow chrome. + */} +
+

{t("missions.planTitle", "Plan Mission with AI")}

@@ -1112,7 +1111,7 @@ export function MissionInterviewModal({ )}
-
+ ); } diff --git a/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx b/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx index 265e16c49a..4e70988514 100644 --- a/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx @@ -1,8 +1,11 @@ import type React from "react"; +import { readFileSync } from "node:fs"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { MissionInterviewModal } from "../MissionInterviewModal"; +const missionInterviewCss = readFileSync("app/components/MissionInterviewModal.css", "utf8"); + const mockStartMissionInterview = vi.fn(); const mockRespondToMissionInterview = vi.fn(); const mockRetryMissionInterviewSession = vi.fn(); @@ -133,6 +136,12 @@ describe("MissionInterviewModal", () => { mockReleaseSessionLock.mockResolvedValue(undefined); mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null }); mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }); + localStorage.removeItem("floating-window:mission-interview"); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); }); function renderModal(props: Partial> = {}) { @@ -151,6 +160,79 @@ describe("MissionInterviewModal", () => { }; } + function setViewport(width: number, height: number) { + Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: height }); + } + + function stubPointerCapture(element: HTMLElement) { + Object.defineProperty(element, "setPointerCapture", { configurable: true, value: vi.fn() }); + Object.defineProperty(element, "releasePointerCapture", { configurable: true, value: vi.fn() }); + } + + it("renders mission interview inside a floating desktop workspace", () => { + setViewport(1200, 900); + + renderModal(); + + const panel = screen.getByTestId("floating-window-mission-interview"); + expect(panel).toHaveClass("floating-window--mission-interview"); + expect(panel).toHaveClass("floating-window--headerless"); + expect(panel.style.width).toBe("760px"); + expect(panel.style.height).toBe("680px"); + expect(screen.queryByTestId("floating-window-drag-handle-mission-interview")).toBeNull(); + expect(screen.getByText("Plan Mission with AI").closest(".mission-interview-modal__drag-handle")).toBeTruthy(); + expect(screen.getAllByRole("button", { name: "Close" })).toHaveLength(1); + }); + + it("drags and resizes the desktop mission floating window while clamping geometry", async () => { + setViewport(1200, 1000); + + renderModal(); + + const panel = screen.getByTestId("floating-window-mission-interview"); + const header = screen.getByText("Plan Mission with AI").closest(".mission-interview-modal__drag-handle") as HTMLElement; + stubPointerCapture(panel); + + const initialLeft = Number.parseFloat(panel.style.left); + const initialTop = Number.parseFloat(panel.style.top); + + act(() => { + fireEvent.pointerDown(header, { pointerId: 7, clientX: 120, clientY: 80 }); + fireEvent.pointerMove(panel, { pointerId: 7, clientX: 220, clientY: 140 }); + fireEvent.pointerUp(panel, { pointerId: 7, clientX: 220, clientY: 140 }); + }); + + await waitFor(() => { + expect(Number.parseFloat(panel.style.left)).toBeGreaterThan(initialLeft); + expect(Number.parseFloat(panel.style.top)).toBeGreaterThan(initialTop); + }); + + const resizeHandle = screen.getByTestId("floating-window-resize-se") as HTMLElement; + stubPointerCapture(resizeHandle); + + act(() => { + fireEvent.pointerDown(resizeHandle, { pointerId: 8, clientX: 700, clientY: 600 }); + fireEvent.pointerMove(resizeHandle, { pointerId: 8, clientX: 3000, clientY: 3000 }); + fireEvent.pointerUp(resizeHandle, { pointerId: 8, clientX: 3000, clientY: 3000 }); + }); + + expect(Number.parseFloat(panel.style.width)).toBeLessThanOrEqual(1200); + expect(Number.parseFloat(panel.style.height)).toBeLessThanOrEqual(1000); + expect(Number.parseFloat(panel.style.width)).toBeGreaterThanOrEqual(560); + expect(Number.parseFloat(panel.style.height)).toBeGreaterThanOrEqual(420); + }); + + it("keeps mobile mission planning full-screen and hides resize handles by CSS contract", () => { + const mobileBlock = missionInterviewCss.match(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.floating-window--mission-interview \.mission-interview-modal\s*\{[\s\S]*?\n\}/)?.[0]; + + expect(mobileBlock).toContain(".floating-window--mission-interview"); + expect(mobileBlock).toContain("width: 100vw !important;"); + expect(mobileBlock).toContain("height: 100dvh !important;"); + expect(mobileBlock).toContain(".floating-window--mission-interview .floating-window__resize-handle"); + expect(mobileBlock).toContain("display: none;"); + }); + it("shows lock overlay and allows take-control", async () => { window.sessionStorage.setItem("fusion-tab-id", "tab-self"); mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" }); @@ -378,6 +460,30 @@ describe("MissionInterviewModal", () => { expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); }); + it("renders normalized generic stream failures as a recoverable retry state", async () => { + mockFetchAiSession.mockRejectedValueOnce(new Error("refresh failed")); + + renderModal(); + + fireEvent.change(screen.getByLabelText("What do you want to build?"), { + target: { value: "Build a mission planning workflow" }, + }); + fireEvent.click(screen.getByText("Start Interview")); + + await waitFor(() => { + expect(streamHandlers).toBeDefined(); + }); + + await act(async () => { + streamHandlers.onError?.("The mission interview stream was interrupted. Please retry the session."); + }); + + expect(await screen.findByText("The mission interview stream was interrupted. Please retry the session.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument(); + expect(screen.queryByText("AI is thinking...")).not.toBeInTheDocument(); + }); + it("shows persisted mission interview errors after stream recovery refreshes the session", async () => { mockFetchAiSession.mockResolvedValueOnce( buildMissionSession({ @@ -582,14 +688,14 @@ describe("MissionInterviewModal", () => { expect(mockCancelMissionInterview).not.toHaveBeenCalled(); }); - it("closes from the backdrop after overlay mousedown", () => { + it("does not render a blocking backdrop click target around the floating workspace", () => { const { onClose } = renderModal(); const overlay = screen.getByRole("dialog"); fireEvent.mouseDown(overlay); fireEvent.click(overlay); - expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); expect(mockCancelMissionInterview).not.toHaveBeenCalled(); }); diff --git a/scripts/__tests__/verify-fast.test.mjs b/scripts/__tests__/verify-fast.test.mjs new file mode 100644 index 0000000000..91dbbcd3b9 --- /dev/null +++ b/scripts/__tests__/verify-fast.test.mjs @@ -0,0 +1,155 @@ +/** + * Unit tests for scripts/verify-fast.mjs + * + * Runner: node --test scripts/__tests__/verify-fast.test.mjs + * + * These exercise the PURE planning / arg-construction logic only. They never + * spawn real tsc / build / vitest — the test-free verification command's value + * is its deterministic plan, so that is what we pin. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildTypecheckStep, + buildBuildStep, + buildBootSmokeStep, + buildVerifyPlan, + VERIFY_EXCLUDED_PACKAGES, +} from "../verify-fast.mjs"; + +import { resolveAffectedPackages } from "../test-changed.mjs"; + +const SMOKE = "/repo/scripts/boot-smoke.mjs"; +const NODE = "/usr/bin/node"; + +function stepIds(plan) { + return plan.steps.map((s) => s.id); +} +function stepByKind(plan, kind) { + return plan.steps.filter((s) => s.kind === kind); +} + +// --------------------------------------------------------------------------- +// buildTypecheckStep +// --------------------------------------------------------------------------- + +test("buildTypecheckStep: uses the package's typecheck script when present", () => { + const step = buildTypecheckStep("@fusion/engine", { hasTypecheck: true }); + assert.equal(step.command, "pnpm"); + assert.deepEqual(step.args, ["--filter", "@fusion/engine", "typecheck"]); + assert.equal(step.klass, "changed"); +}); + +test("buildTypecheckStep: falls back to scoped tsc --noEmit when no typecheck script", () => { + const step = buildTypecheckStep("@fusion/widget", { hasTypecheck: false }); + assert.deepEqual(step.args, ["--filter", "@fusion/widget", "exec", "tsc", "--noEmit", "-p", "."]); +}); + +test("buildTypecheckStep: defaults to the tsc fallback when meta omitted", () => { + const step = buildTypecheckStep("@fusion/widget"); + assert.deepEqual(step.args, ["--filter", "@fusion/widget", "exec", "tsc", "--noEmit", "-p", "."]); +}); + +// --------------------------------------------------------------------------- +// buildBuildStep / buildBootSmokeStep +// --------------------------------------------------------------------------- + +test("buildBuildStep: scoped pnpm build for the package", () => { + const step = buildBuildStep("@fusion/cli"); + assert.deepEqual(step.args, ["--filter", "@fusion/cli", "build"]); + assert.equal(step.kind, "build"); +}); + +test("buildBootSmokeStep: runs the boot-smoke script via node", () => { + const step = buildBootSmokeStep(SMOKE, NODE); + assert.equal(step.command, NODE); + assert.deepEqual(step.args, [SMOKE]); + assert.equal(step.kind, "boot-smoke"); +}); + +// --------------------------------------------------------------------------- +// buildVerifyPlan +// --------------------------------------------------------------------------- + +test("buildVerifyPlan: no packages -> boot smoke only", () => { + const plan = buildVerifyPlan({ packages: [], bootSmokeScriptPath: SMOKE, nodeBin: NODE }); + assert.deepEqual(stepIds(plan), ["boot-smoke"]); + assert.deepEqual(plan.eligiblePackages, []); +}); + +test("buildVerifyPlan: typecheck for all eligible, then builds, then boot smoke (ordered)", () => { + const packageMeta = new Map([ + ["@fusion/engine", { hasTypecheck: true, hasBuild: true }], + ["@fusion/core", { hasTypecheck: true, hasBuild: true }], + ]); + const plan = buildVerifyPlan({ packages: ["@fusion/engine", "@fusion/core"], packageMeta, bootSmokeScriptPath: SMOKE, nodeBin: NODE }); + assert.deepEqual(stepIds(plan), [ + "typecheck:@fusion/engine", + "typecheck:@fusion/core", + "build:@fusion/engine", + "build:@fusion/core", + "boot-smoke", + ]); +}); + +test("buildVerifyPlan: a package without a build script gets a typecheck step but no build step", () => { + const packageMeta = new Map([ + ["@fusion/engine", { hasTypecheck: true, hasBuild: true }], + ["@fusion/test-only", { hasTypecheck: false, hasBuild: false }], + ]); + const plan = buildVerifyPlan({ packages: ["@fusion/engine", "@fusion/test-only"], packageMeta, bootSmokeScriptPath: SMOKE, nodeBin: NODE }); + assert.deepEqual(stepIds(plan), [ + "typecheck:@fusion/engine", + "typecheck:@fusion/test-only", + "build:@fusion/engine", + "boot-smoke", + ]); + // The test-only package's typecheck uses the tsc fallback (no typecheck script). + const tc = stepByKind(plan, "typecheck").find((s) => s.pkg === "@fusion/test-only"); + assert.deepEqual(tc.args, ["--filter", "@fusion/test-only", "exec", "tsc", "--noEmit", "-p", "."]); +}); + +test("buildVerifyPlan: desktop/mobile are excluded from scoped steps but boot smoke still runs", () => { + const packageMeta = new Map([ + ["@fusion/engine", { hasTypecheck: true, hasBuild: true }], + ["@fusion/desktop", { hasTypecheck: true, hasBuild: true }], + ["@fusion/mobile", { hasTypecheck: true, hasBuild: true }], + ]); + const plan = buildVerifyPlan({ + packages: ["@fusion/engine", "@fusion/desktop", "@fusion/mobile"], + packageMeta, + bootSmokeScriptPath: SMOKE, + nodeBin: NODE, + }); + assert.deepEqual(plan.eligiblePackages, ["@fusion/engine"]); + assert.deepEqual(plan.excludedPackages.sort(), ["@fusion/desktop", "@fusion/mobile"]); + assert.deepEqual(stepIds(plan), ["typecheck:@fusion/engine", "build:@fusion/engine", "boot-smoke"]); +}); + +test("VERIFY_EXCLUDED_PACKAGES mirrors the root build/typecheck exclusions", () => { + assert.ok(VERIFY_EXCLUDED_PACKAGES.has("@fusion/desktop")); + assert.ok(VERIFY_EXCLUDED_PACKAGES.has("@fusion/mobile")); +}); + +// --------------------------------------------------------------------------- +// Integration: reuse test-changed's resolveAffectedPackages to scope the plan +// --------------------------------------------------------------------------- + +test("buildVerifyPlan: scopes to exactly the packages resolveAffectedPackages selects", () => { + // packageNameByDir as test-changed builds it (dir -> name, with a bare alias). + const packageNameByDir = new Map([ + ["packages/engine", "@fusion/engine"], + ["engine", "@fusion/engine"], + ["packages/dashboard", "@fusion/dashboard"], + ["dashboard", "@fusion/dashboard"], + ]); + const changedFiles = ["packages/engine/src/merger.ts", "docs/testing.md"]; + const affected = resolveAffectedPackages(changedFiles, packageNameByDir); + assert.deepEqual(affected, ["@fusion/engine"]); // docs/ change does not add a package + + const packageMeta = new Map([["@fusion/engine", { hasTypecheck: true, hasBuild: true }]]); + const plan = buildVerifyPlan({ packages: affected, packageMeta, bootSmokeScriptPath: SMOKE, nodeBin: NODE }); + assert.deepEqual(stepIds(plan), ["typecheck:@fusion/engine", "build:@fusion/engine", "boot-smoke"]); +}); diff --git a/scripts/check-file-line-count.mjs b/scripts/check-file-line-count.mjs index 314483b0f6..8cb0eb7692 100644 --- a/scripts/check-file-line-count.mjs +++ b/scripts/check-file-line-count.mjs @@ -22,6 +22,9 @@ FN-6871 corrects the stale premise that line-count drift blocks `pnpm test`: FN- FNXC:CI 2026-06-21-23:53: FN-6917 re-confirms the `pnpm test`-blocking premise is stale because FN-5048 left this guard opt-in under `check:line-count` only. Twenty files were re-ratcheted after organic feature/test growth; `TerminalModal.tsx` was grandfathered after crossing the hard cap as a long-existing file, with focused split follow-up FN-6918. Wholesale god-file shrink/refactor remains the long-term direction and stays deferred to dedicated follow-ups. + +FNXC:CI 2026-06-25-00:00: +FN-7013 re-confirms the `pnpm test`-blocking premise is stale: FN-5048 removed this guard from pretest and left it opt-in under `check:line-count` only. Sixty-one current violations were re-ratcheted after organic feature/test growth and eight stale baseline entries were tightened or pruned. `AgentLogViewer.test.tsx` and `merger-ai.ts` were temporarily grandfathered after crossing the hard cap as long-existing files, with focused split follow-ups FN-7028 and FN-7029. Wholesale god-file shrink/refactor remains the long-term direction and stays deferred to dedicated follow-ups. */ // Repo-wide guard: hand-written source files may not exceed a hard line-count // cap (MAX_LINES). This stops the next god-file from being born while leaving diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json index 992b32b3f4..cf2f03d1f6 100644 --- a/scripts/line-count-baseline.json +++ b/scripts/line-count-baseline.json @@ -1,105 +1,104 @@ { "packages/cli/src/__tests__/extension.test.ts": 4186, - "packages/cli/src/bin.ts": 2068, - "packages/cli/src/commands/__tests__/dashboard.test.ts": 3366, + "packages/cli/src/bin.ts": 2070, + "packages/cli/src/commands/__tests__/dashboard.test.ts": 3376, "packages/cli/src/commands/__tests__/serve.test.ts": 2100, - "packages/cli/src/commands/__tests__/task.test.ts": 3424, - "packages/cli/src/commands/dashboard-tui/app.tsx": 4665, - "packages/cli/src/commands/dashboard.ts": 2960, + "packages/cli/src/commands/__tests__/task.test.ts": 3439, + "packages/cli/src/commands/dashboard-tui/app.tsx": 4681, + "packages/cli/src/commands/dashboard.ts": 3000, "packages/cli/src/extension.ts": 4704, "packages/core/src/__tests__/agent-store.test.ts": 2997, "packages/core/src/__tests__/central-core.test.ts": 3263, "packages/core/src/__tests__/db.test.ts": 3606, - "packages/core/src/__tests__/mission-store.test.ts": 4405, + "packages/core/src/__tests__/mission-store.test.ts": 4519, "packages/core/src/__tests__/plugin-loader.test.ts": 2783, - "packages/core/src/__tests__/store-settings.test.ts": 2202, + "packages/core/src/__tests__/store-settings.test.ts": 2249, "packages/core/src/agent-store.ts": 2946, "packages/core/src/central-core.ts": 3854, - "packages/core/src/db.ts": 5874, - "packages/core/src/mission-store.ts": 4382, - "packages/core/src/store.ts": 16939, - "packages/core/src/types.ts": 7269, - "packages/dashboard/app/api/legacy.ts": 10742, + "packages/core/src/db.ts": 5888, + "packages/core/src/mission-store.ts": 4390, + "packages/core/src/store.ts": 17358, + "packages/core/src/types.ts": 7415, + "packages/dashboard/app/api/legacy.ts": 10821, "packages/dashboard/app/components/AgentDetailView.tsx": 5400, - "packages/dashboard/app/components/AgentsView.tsx": 2109, - "packages/dashboard/app/components/ChatView.tsx": 4074, - "packages/dashboard/app/components/GitManagerModal.tsx": 3249, - "packages/dashboard/app/components/ListView.tsx": 2486, - "packages/dashboard/app/components/MissionManager.tsx": 4990, + "packages/dashboard/app/components/AgentsView.tsx": 2147, + "packages/dashboard/app/components/ChatView.tsx": 4075, + "packages/dashboard/app/components/GitManagerModal.tsx": 3387, + "packages/dashboard/app/components/ListView.tsx": 2464, + "packages/dashboard/app/components/MissionManager.tsx": 5042, "packages/dashboard/app/components/ModelOnboardingModal.tsx": 3212, - "packages/dashboard/app/components/PlanningModeModal.tsx": 3454, + "packages/dashboard/app/components/PlanningModeModal.tsx": 3531, "packages/dashboard/app/components/QuickEntryBox.tsx": 2229, - "packages/dashboard/app/components/SettingsModal.tsx": 3336, - "packages/dashboard/app/components/TaskCard.tsx": 2528, - "packages/dashboard/app/components/TaskDetailModal.tsx": 4653, + "packages/dashboard/app/components/SettingsModal.tsx": 3505, + "packages/dashboard/app/components/TaskCard.tsx": 2544, + "packages/dashboard/app/components/TaskDetailModal.tsx": 4636, "packages/dashboard/app/components/TerminalModal.tsx": 2313, - "packages/dashboard/app/components/WorkflowNodeEditor.tsx": 4777, - "packages/dashboard/app/components/__tests__/AgentsView.test.tsx": 2767, - "packages/dashboard/app/components/__tests__/App.test.tsx": 4262, - "packages/dashboard/app/components/__tests__/ChatView.test.tsx": 5766, - "packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx": 3428, - "packages/dashboard/app/components/__tests__/ListView.test.tsx": 4334, - "packages/dashboard/app/components/__tests__/MailboxView.test.tsx": 2072, + "packages/dashboard/app/components/WorkflowNodeEditor.tsx": 4868, + "packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx": 2010, + "packages/dashboard/app/components/__tests__/AgentsView.test.tsx": 2817, + "packages/dashboard/app/components/__tests__/App.test.tsx": 4437, + "packages/dashboard/app/components/__tests__/ChatView.test.tsx": 5822, + "packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx": 3530, + "packages/dashboard/app/components/__tests__/ListView.test.tsx": 4349, + "packages/dashboard/app/components/__tests__/MailboxView.test.tsx": 2202, "packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx": 4679, - "packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx": 2771, - "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 4593, - "packages/dashboard/app/components/__tests__/SettingsModal.test.tsx": 5501, + "packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx": 3002, + "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx": 4707, "packages/dashboard/app/components/__tests__/TaskCard.test.tsx": 5121, - "packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx": 2405, + "packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx": 2558, "packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx": 2917, - "packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx": 2382, - "packages/dashboard/app/components/__tests__/TerminalModal.test.tsx": 5694, + "packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx": 2476, + "packages/dashboard/app/components/__tests__/TerminalModal.test.tsx": 5707, "packages/dashboard/app/components/__tests__/UsageIndicator.test.tsx": 2905, - "packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx": 3414, + "packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx": 3724, "packages/dashboard/app/hooks/__tests__/useChat.test.ts": 4097, "packages/dashboard/app/hooks/__tests__/useTasks.test.ts": 2582, "packages/dashboard/src/__tests__/chat-manager.test.ts": 2636, "packages/dashboard/src/__tests__/file-service.test.ts": 2123, - "packages/dashboard/src/__tests__/github.test.ts": 2326, + "packages/dashboard/src/__tests__/github.test.ts": 2319, "packages/dashboard/src/__tests__/plugin-routes.test.ts": 2069, "packages/dashboard/src/__tests__/routes-agents.test.ts": 4921, "packages/dashboard/src/__tests__/routes-auth.test.ts": 3980, "packages/dashboard/src/__tests__/routes-automation.test.ts": 2393, "packages/dashboard/src/__tests__/routes-github.test.ts": 2855, "packages/dashboard/src/__tests__/routes-nodes-sync.test.ts": 2479, - "packages/dashboard/src/__tests__/routes-planning.test.ts": 4346, - "packages/dashboard/src/__tests__/routes-settings.test.ts": 3122, - "packages/dashboard/src/__tests__/routes-tasks-ops.test.ts": 4158, + "packages/dashboard/src/__tests__/routes-planning.test.ts": 4468, + "packages/dashboard/src/__tests__/routes-settings.test.ts": 3259, + "packages/dashboard/src/__tests__/routes-tasks-ops.test.ts": 4247, "packages/dashboard/src/__tests__/routes-tasks.test.ts": 2696, - "packages/dashboard/src/__tests__/server.test.ts": 3048, + "packages/dashboard/src/__tests__/server.test.ts": 3177, "packages/dashboard/src/__tests__/usage.test.ts": 4328, "packages/dashboard/src/chat.ts": 2197, - "packages/dashboard/src/github.ts": 4575, - "packages/dashboard/src/mission-routes.ts": 3948, - "packages/dashboard/src/planning.ts": 2700, - "packages/dashboard/src/routes.ts": 5296, - "packages/dashboard/src/routes/register-git-github.ts": 5792, - "packages/dashboard/src/routes/register-settings-memory-routes.ts": 2421, - "packages/dashboard/src/routes/register-task-workflow-routes.ts": 3861, - "packages/dashboard/src/server.ts": 2378, - "packages/engine/src/__tests__/executor-pause.test.ts": 2974, - "packages/engine/src/__tests__/executor-prompt.test.ts": 2572, - "packages/engine/src/__tests__/executor-recovery.test.ts": 3600, + "packages/dashboard/src/github.ts": 4587, + "packages/dashboard/src/mission-routes.ts": 3968, + "packages/dashboard/src/planning.ts": 2866, + "packages/dashboard/src/routes.ts": 5319, + "packages/dashboard/src/routes/register-git-github.ts": 5879, + "packages/dashboard/src/routes/register-settings-memory-routes.ts": 2382, + "packages/dashboard/src/routes/register-task-workflow-routes.ts": 3902, + "packages/dashboard/src/server.ts": 2467, + "packages/engine/src/__tests__/executor-pause.test.ts": 3019, + "packages/engine/src/__tests__/executor-prompt.test.ts": 2573, "packages/engine/src/__tests__/executor-step-session.test.ts": 3779, "packages/engine/src/__tests__/executor-worktree.test.ts": 2536, - "packages/engine/src/__tests__/heartbeat-executor.test.ts": 4094, - "packages/engine/src/__tests__/merger-merge-lifecycle.test.ts": 3253, + "packages/engine/src/__tests__/heartbeat-executor.test.ts": 4162, + "packages/engine/src/__tests__/merger-merge-lifecycle.test.ts": 3408, "packages/engine/src/__tests__/merger-verification.test.ts": 3163, "packages/engine/src/__tests__/mission-execution-loop.test.ts": 2463, - "packages/engine/src/__tests__/pi-create-fn-agent.test.ts": 2233, - "packages/engine/src/__tests__/project-engine.test.ts": 2851, - "packages/engine/src/__tests__/scheduler.test.ts": 5412, - "packages/engine/src/__tests__/self-healing.test.ts": 9641, + "packages/engine/src/__tests__/pi-create-fn-agent.test.ts": 2300, + "packages/engine/src/__tests__/project-engine.test.ts": 3238, + "packages/engine/src/__tests__/self-healing.test.ts": 9739, "packages/engine/src/__tests__/step-session-executor.test.ts": 2911, - "packages/engine/src/__tests__/triage.test.ts": 4534, - "packages/engine/src/agent-heartbeat.ts": 4557, - "packages/engine/src/agent-tools.ts": 3870, - "packages/engine/src/executor.ts": 16071, - "packages/engine/src/merger.ts": 12663, - "packages/engine/src/pi.ts": 2435, - "packages/engine/src/project-engine.ts": 3663, - "packages/engine/src/scheduler.ts": 2638, - "packages/engine/src/self-healing.ts": 10316, - "packages/engine/src/triage.ts": 2793, + "packages/engine/src/__tests__/triage.test.ts": 4673, + "packages/engine/src/agent-heartbeat.ts": 4660, + "packages/engine/src/agent-tools.ts": 3986, + "packages/engine/src/executor.ts": 16743, + "packages/engine/src/merger-ai.ts": 2050, + "packages/engine/src/merger.ts": 12886, + "packages/engine/src/pi.ts": 2507, + "packages/engine/src/project-engine.ts": 4030, + "packages/engine/src/scheduler.ts": 3186, + "packages/engine/src/self-healing.ts": 11091, + "packages/engine/src/triage.ts": 2787, "plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx": 2583 } diff --git a/scripts/test-changed.mjs b/scripts/test-changed.mjs index 7edb162dfe..d66038fc2b 100644 --- a/scripts/test-changed.mjs +++ b/scripts/test-changed.mjs @@ -407,7 +407,13 @@ function gitOutput(gitArgs) { return result.stdout.trim(); } -function getBaseBranch() { +// FNXC:TestInfrastructure 2026-06-25-00:00: +// Exported so the test-free verification path (scripts/verify-fast.mjs) reuses +// the SAME base-branch / comparison-base / changed-file / workspace-resolution +// logic instead of reinventing git-diff. verify:fast runs typecheck + build + +// boot-smoke (no test suite) and must scope to exactly the packages a +// changed-only run would scope to. +export function getBaseBranch() { const changesetConfigPath = path.join(rootDir, ".changeset", "config.json"); const changesetConfig = JSON.parse(readFileSync(changesetConfigPath, "utf8")); return changesetConfig.baseBranch || "main"; @@ -497,7 +503,7 @@ export function listWorkspacePackageInfos({ projectRoot = rootDir } = {}) { .sort((a, b) => a.dir.localeCompare(b.dir)); } -function listWorkspacePackages(workspacePackages = listWorkspacePackageInfos()) { +export function listWorkspacePackages(workspacePackages = listWorkspacePackageInfos()) { const packageNameByDir = new Map(); for (const workspacePackage of workspacePackages) { packageNameByDir.set(workspacePackage.dir, workspacePackage.name); @@ -665,7 +671,7 @@ export function isSharedInfraChange(changedFiles) { }); } -function detectComparisonBase(baseBranch) { +export function detectComparisonBase(baseBranch) { const candidates = [ `origin/${baseBranch}`, `refs/remotes/origin/${baseBranch}`, @@ -682,7 +688,7 @@ function detectComparisonBase(baseBranch) { return null; } -function changedFilesSince(baseSha) { +export function changedFilesSince(baseSha) { const diff = gitOutput(["diff", "--name-only", `${baseSha}...HEAD`]); if (diff === null) { return null; diff --git a/scripts/verify-fast.mjs b/scripts/verify-fast.mjs new file mode 100644 index 0000000000..f12de223cb --- /dev/null +++ b/scripts/verify-fast.mjs @@ -0,0 +1,265 @@ +#!/usr/bin/env node +/* +FNXC:TestInfrastructure 2026-06-25-00:00: +verify:fast is the opt-in, TEST-FREE verification command. It gives deterministic, +flake-free signal in seconds without running the test suite, by doing exactly: + 1. typecheck — scoped to the changed packages (their `typecheck` script, or + `pnpm --filter exec tsc --noEmit -p .` when none exists). + 2. build — scoped to the changed packages (`pnpm --filter build`). + 3. boot smoke — once (scripts/boot-smoke.mjs: CLI --help + real serve /api/health). + +Rationale: docs/testing.md observes the broad test gate "caught no recalled real +bugs while consuming ~70% of shipping time in flake triage." typecheck+build+boot +is fast and never flakes, so it is a sound project `testCommand`/verification +command when you want non-test verification. This command changes NO default — +`pnpm test`, the merge gate, and CI are untouched. The full suite stays available +(`pnpm test:full`) and runs non-blocking on push to main. + +Change-detection REUSES scripts/test-changed.mjs (getBaseBranch / +detectComparisonBase / changedFilesSince / resolveAffectedPackages / workspace +resolution) so verify:fast scopes to exactly the packages a changed-only test run +would, instead of reinventing git-diff. Each step is bounded by the existing +`runWithWatchdog` (class "changed") so a hung tsc/build/serve fails fast instead +of blocking forever, and we exit nonzero on the first failing step. +*/ + +import path from "node:path"; +import { readFileSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { + getBaseBranch, + detectComparisonBase, + changedFilesSince, + listWorkspacePackageInfos, + listWorkspacePackages, + buildPackageDirByName, + resolveAffectedPackages, +} from "./test-changed.mjs"; +import { deriveBudgetMs, runWithWatchdog } from "./lib/run-vitest-watchdog.mjs"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, ".."); +const bootSmokeScriptPath = path.join(scriptDir, "boot-smoke.mjs"); + +/* +FNXC:TestInfrastructure 2026-06-25-00:00: +@fusion/desktop and @fusion/mobile are excluded from the root `build`/`typecheck` +scripts (heavy native/electron + RN toolchains), so verify:fast mirrors that +policy and skips them with a note rather than failing on an unbuildable filter. +*/ +export const VERIFY_EXCLUDED_PACKAGES = new Set(["@fusion/desktop", "@fusion/mobile"]); + +/** + * Build the scoped typecheck step for a package. Prefers the package's own + * `typecheck` script (e.g. dashboard runs two tsc passes); falls back to a plain + * project tsc --noEmit when the package declares no typecheck script. + * + * @param {string} pkg workspace package name (e.g. "@fusion/engine") + * @param {{ hasTypecheck?: boolean }} [meta] + * @returns {{ id: string, kind: string, pkg: string, label: string, command: string, args: string[], klass: string }} + */ +export function buildTypecheckStep(pkg, meta = {}) { + const args = meta.hasTypecheck + ? ["--filter", pkg, "typecheck"] + : ["--filter", pkg, "exec", "tsc", "--noEmit", "-p", "."]; + return { id: `typecheck:${pkg}`, kind: "typecheck", pkg, label: `typecheck ${pkg}`, command: "pnpm", args, klass: "changed" }; +} + +/** + * Build the scoped build step for a package. + * + * @param {string} pkg + * @returns {{ id: string, kind: string, pkg: string, label: string, command: string, args: string[], klass: string }} + */ +export function buildBuildStep(pkg) { + return { id: `build:${pkg}`, kind: "build", pkg, label: `build ${pkg}`, command: "pnpm", args: ["--filter", pkg, "build"], klass: "changed" }; +} + +/** + * Build the single boot-smoke step (always last, after any builds, so it runs + * against freshly built artifacts). + * + * @param {string} smokeScriptPath + * @param {string} [nodeBin] + */ +export function buildBootSmokeStep(smokeScriptPath, nodeBin = process.execPath) { + return { + id: "boot-smoke", + kind: "boot-smoke", + pkg: null, + label: "boot smoke (CLI --help + real serve /api/health)", + command: nodeBin, + args: [smokeScriptPath], + klass: "changed", + }; +} + +/** + * Pure planner: turn the affected package set into an ordered step list. + * typecheck (all eligible) → build (eligible with a build script) → boot smoke. + * With no eligible packages this is just the boot-smoke step, satisfying the + * "no packages changed ⇒ boot smoke only" contract. + * + * @param {object} opts + * @param {string[]} [opts.packages] affected package names + * @param {Map} [opts.packageMeta] + * @param {string} opts.bootSmokeScriptPath + * @param {string} [opts.nodeBin] + * @returns {{ eligiblePackages: string[], excludedPackages: string[], steps: object[] }} + */ +export function buildVerifyPlan({ packages = [], packageMeta = new Map(), bootSmokeScriptPath: smokeScriptPath, nodeBin = process.execPath } = {}) { + const eligiblePackages = packages.filter((pkg) => !VERIFY_EXCLUDED_PACKAGES.has(pkg)); + const excludedPackages = packages.filter((pkg) => VERIFY_EXCLUDED_PACKAGES.has(pkg)); + + const steps = []; + for (const pkg of eligiblePackages) { + steps.push(buildTypecheckStep(pkg, packageMeta.get(pkg) ?? {})); + } + for (const pkg of eligiblePackages) { + const meta = packageMeta.get(pkg) ?? {}; + // Only build packages that declare a build script; pure test/config packages + // have nothing to emit and a `pnpm --filter build` would error. + if (meta.hasBuild !== false) steps.push(buildBuildStep(pkg)); + } + steps.push(buildBootSmokeStep(smokeScriptPath, nodeBin)); + return { eligiblePackages, excludedPackages, steps }; +} + +/** + * Read each affected package's package.json to learn which scripts it declares. + * + * @param {string[]} packages + * @param {Map} packageDirByName pkg name → repo-relative dir + * @param {string} [root] + * @returns {Map} + */ +export function readPackageMeta(packages, packageDirByName, root = repoRoot) { + const meta = new Map(); + for (const pkg of packages) { + const dir = packageDirByName.get(pkg); + let scripts = {}; + if (dir) { + try { + const pkgJson = JSON.parse(readFileSync(path.join(root, dir, "package.json"), "utf8")); + scripts = pkgJson.scripts ?? {}; + } catch { + // Missing/unreadable package.json: fall back to tsc default + attempt build. + } + } + meta.set(pkg, { + dir: dir ?? null, + hasTypecheck: typeof scripts.typecheck === "string", + hasBuild: typeof scripts.build === "string", + }); + } + return meta; +} + +/** + * Resolve the affected package set for the current working tree, reusing + * test-changed's git-diff + workspace resolution. Returns both the package list + * and a human note describing why the set is what it is (no base, no changes, + * unmappable path, etc.) so the CLI can explain a boot-smoke-only run. + * + * @returns {{ packages: string[], packageDirByName: Map, note: string }} + */ +export function resolveAffectedForVerify() { + const baseBranch = getBaseBranch(); + const comparisonBase = detectComparisonBase(baseBranch); + const workspacePackages = listWorkspacePackageInfos(); + const packageNameByDir = listWorkspacePackages(workspacePackages); + const packageDirByName = buildPackageDirByName(workspacePackages); + + if (!comparisonBase) { + return { packages: [], packageDirByName, note: `could not resolve merge-base with ${baseBranch}; running boot smoke only` }; + } + const changedFiles = changedFilesSince(comparisonBase); + if (changedFiles === null) { + return { packages: [], packageDirByName, note: "failed to read git diff; running boot smoke only" }; + } + if (changedFiles.length === 0) { + return { packages: [], packageDirByName, note: "no changes detected against base; running boot smoke only" }; + } + const affected = resolveAffectedPackages(changedFiles, packageNameByDir); + if (affected === null) { + return { packages: [], packageDirByName, note: "changed file did not map to a workspace package; running boot smoke only" }; + } + if (affected.length === 0) { + return { packages: [], packageDirByName, note: "no affected workspace package (root/docs-only changes); running boot smoke only" }; + } + return { packages: affected, packageDirByName, note: `affected packages: ${affected.join(", ")}` }; +} + +/** + * Run one step under the wall-clock watchdog (class "changed"). Streams the + * child's output (stdio inherit) and throws with an `.exitCode` on the first + * failure/timeout/signal so the caller exits nonzero immediately. + */ +export async function runStep(step, { spawnFn = spawn, log = console.log, errLog = console.error } = {}) { + const budgetMs = deriveBudgetMs({ klass: step.klass ?? "changed" }); + log(`\n[verify:fast] -> ${step.label}`); + log(`[verify:fast] ${step.command} ${step.args.join(" ")} (budget ${Math.round(budgetMs / 1000)}s)`); + const startedAt = Date.now(); + const { code, signal, timedOut } = await runWithWatchdog({ + command: step.command, + args: step.args, + env: process.env, + cwd: repoRoot, + budgetMs, + label: step.label, + log: errLog, + spawn: spawnFn, + }); + const elapsedS = ((Date.now() - startedAt) / 1000).toFixed(1); + if (timedOut || signal || code !== 0) { + const reason = timedOut ? `watchdog timeout (${budgetMs}ms)` : signal ? `signal ${signal}` : `exit code ${code}`; + const error = new Error(`[verify:fast] FAILED: ${step.label} (${reason}) after ${elapsedS}s`); + error.exitCode = timedOut ? 124 : signal ? 1 : code ?? 1; + throw error; + } + log(`[verify:fast] OK ${step.label} (${elapsedS}s)`); +} + +export async function main() { + const overallStart = Date.now(); + console.log("[verify:fast] test-free verification: typecheck + build (scoped to changed packages) + boot smoke."); + + const { packages, packageDirByName, note } = resolveAffectedForVerify(); + console.log(`[verify:fast] ${note}`); + + const packageMeta = readPackageMeta(packages, packageDirByName); + const { eligiblePackages, excludedPackages, steps } = buildVerifyPlan({ + packages, + packageMeta, + bootSmokeScriptPath, + }); + + if (excludedPackages.length > 0) { + console.log(`[verify:fast] skipping excluded packages (also excluded from root build/typecheck): ${excludedPackages.join(", ")}`); + } + if (eligiblePackages.length === 0) { + console.log("[verify:fast] no scoped packages to verify; running boot smoke only."); + } else { + console.log(`[verify:fast] scoped to: ${eligiblePackages.join(", ")}`); + } + console.log(`[verify:fast] plan: ${steps.map((s) => s.id).join(" -> ")}`); + + for (const step of steps) { + await runStep(step); + } + + const elapsedS = ((Date.now() - overallStart) / 1000).toFixed(1); + console.log(`\n[verify:fast] PASS — ${steps.length} step(s) green in ${elapsedS}s (no tests run).`); +} + +const currentFilePath = fileURLToPath(import.meta.url); +if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) { + main().catch((error) => { + if (error?.message) console.error(error.message); + if (error?.exitCode) process.exit(error.exitCode); + console.error(error); + process.exit(1); + }); +}