feat: add test-free verify:fast (typecheck + build + boot-smoke) (#1777)
## What this adds A **test-free verification command** — `pnpm verify:fast` (`scripts/verify-fast.mjs`) — that gives deterministic, flake-free signal without running the test suite. It is fully **additive**: `pnpm test`, the merge gate (`test:gate`), and CI are untouched. `docs/testing.md` observes the broad test gate "caught no recalled real bugs while consuming ~70% of shipping time in flake triage." `verify:fast` is the opt-in path for non-test verification, suitable as a project `testCommand`/verification command. ## What verify:fast runs 1. **typecheck — scoped to the changed packages** (each package's `typecheck` script, or `pnpm --filter <pkg> exec tsc --noEmit -p .` when none exists). 2. **build — scoped to the changed packages** (`pnpm --filter <pkg> build`, only for packages that declare a build script). 3. **boot smoke once** (`scripts/boot-smoke.mjs`: CLI `--help` + a real `fn serve` answering `GET /api/health`), after builds so it runs against fresh artifacts. Change-detection **reuses `scripts/test-changed.mjs`** (`getBaseBranch` / `detectComparisonBase` / `changedFilesSince` / `resolveAffectedPackages` / workspace resolution — newly `export`ed) instead of reinventing git-diff, so it scopes to exactly the packages a changed-only test run would. With no affected package (root/docs-only diff) it runs the boot smoke only. Each step is bounded by the existing `runWithWatchdog` (class `changed`) so a hung tsc/build/serve fails fast; it streams progress and exits nonzero on the first failing step. `@fusion/desktop` and `@fusion/mobile` are skipped, mirroring the root `build`/`typecheck` exclusions. ## Measured wall-time On this branch's diff (which resolves to the heaviest package, `@fusion/dashboard`), end-to-end: ``` [verify:fast] plan: typecheck:@fusion/dashboard -> build:@fusion/dashboard -> boot-smoke [verify:fast] OK typecheck @fusion/dashboard (~44s) [verify:fast] OK build @fusion/dashboard (26.2s) [verify:fast] OK boot smoke (CLI --help + real serve /api/health) (19.6s) [verify:fast] PASS — 3 step(s) green in 90.3s (no tests run). ``` **~90s total**, deterministic and flake-free. By contrast a typical **scoped test run for the same package** is far heavier and flake-prone: `docs/testing.md` notes a dashboard task "otherwise re-ran all 822 dashboard test files (~5-8 min)", and `pnpm test` additionally runs the merge-gate suite first. verify:fast trades that test-suite cost (and its flake-triage tax) for a typecheck+build+boot signal in ~1.5 min. ## Doc additions - `AGENTS.md` + `docs/testing.md` testing-commands lists now include `pnpm verify:fast`, described as the recommended **test-free verification** (typecheck + build + boot-smoke), suitable as a project `testCommand`/verification command; the full suite stays available and runs non-blocking. ## Tests / verification - New `scripts/__tests__/verify-fast.test.mjs` (11 tests) pins the pure planning / arg-construction logic — scoped typecheck/build selection, build-script gating, desktop/mobile exclusion, boot-smoke-only fallback, and reuse of `resolveAffectedPackages`. It never spawns real tsc/build/vitest. - `pnpm verify:fast` runs end-to-end and exits 0 (output above). - Lint clean on all new/changed files; `agents-md-invariants`, `check-test-inventory`, `verify-fast`, and `test-changed` script tests all green (132 tests). No changeset (scripts + docs + CI-tooling, behavior-additive; `@runfusion/fusion` runtime unaffected). 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- stage-review-badge-begin --> --- <a href="https://stagereview.app/Runfusion/Fusion/pull/1777"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://stagereview.app/assets/gh-open-in-stage-dark.svg"> <img src="https://stagereview.app/assets/gh-open-in-stage-light.svg" alt="Open in Stage"> </picture> </a> <!-- stage-review-badge-end -->
This commit is contained in:
7
.changeset/fn-6975-mission-modal-stream.md
Normal file
7
.changeset/fn-6975-mission-modal-stream.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
<!-- FNXC:MissionInterviewDocs 2026-06-25-15:55: FN-6975 made the Plan Mission with AI workspace movable/resizable on desktop while preserving mobile's fixed full-screen flow, and stream failures now surface one recoverable retry state instead of leaving the modal spinning. -->
|
||||
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
<!-- FNXC:TestInfrastructure 2026-06-25-00:00: verify:fast is the opt-in test-free verification path. 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-smoke gives deterministic, flake-free signal without running tests. It changes no default — pnpm test, the merge gate, and CI are untouched; the full suite stays available and runs non-blocking. -->
|
||||
`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=<n>` only for targeted package-level investigation.
|
||||
|
||||
<!-- FNXC:CustomWorkflowReliability 2026-06-19-00:00: FN-6694 adds an executable custom-workflow reliability release-check lane for QA signoff, but it must stay out of the merge gate so reliability evidence does not inflate every PR's wall-time. -->
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, Array<(event: MessageEvent) => 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<Parameters<typeof connectMissionInterviewStream>[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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<MissionPlanSummary | null>(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 (
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
onMouseDown={(e) => {
|
||||
overlayMouseDownOnSelfRef.current = e.target === e.currentTarget;
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && overlayMouseDownOnSelfRef.current) {
|
||||
handleClose();
|
||||
}
|
||||
overlayMouseDownOnSelfRef.current = false;
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
<FloatingWindow
|
||||
windowKey="mission-interview"
|
||||
title={t("missions.planTitle", "Plan Mission with AI")}
|
||||
onClose={handleClose}
|
||||
hideHeader
|
||||
dragHandleSelector=".mission-interview-modal__drag-handle"
|
||||
className="floating-window--mission-interview"
|
||||
defaultSize={{ width: 760, height: 680 }}
|
||||
minSize={{ width: 560, height: 420 }}
|
||||
persistGeometryKey="floating-window:mission-interview"
|
||||
>
|
||||
<div className="modal modal-lg planning-modal">
|
||||
<div className="modal-header">
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<div className="modal modal-lg planning-modal mission-interview-modal">
|
||||
<div className="modal-header mission-interview-modal__drag-handle">
|
||||
<div className="detail-title-row">
|
||||
<Target size={20} className="icon-triage" />
|
||||
<h3>{t("missions.planTitle", "Plan Mission with AI")}</h3>
|
||||
@@ -1112,7 +1111,7 @@ export function MissionInterviewModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<React.ComponentProps<typeof MissionInterviewModal>> = {}) {
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
155
scripts/__tests__/verify-fast.test.mjs
Normal file
155
scripts/__tests__/verify-fast.test.mjs
Normal file
@@ -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"]);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
265
scripts/verify-fast.mjs
Normal file
265
scripts/verify-fast.mjs
Normal file
@@ -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 <pkg> exec tsc --noEmit -p .` when none exists).
|
||||
2. build — scoped to the changed packages (`pnpm --filter <pkg> 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<string, { dir?: string, hasTypecheck?: boolean, hasBuild?: boolean }>} [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 <pkg> 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<string, string>} packageDirByName pkg name → repo-relative dir
|
||||
* @param {string} [root]
|
||||
* @returns {Map<string, { dir: string, hasTypecheck: boolean, hasBuild: boolean }>}
|
||||
*/
|
||||
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<string,string>, 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user