fix: resolve all full-suite failures + add structural mock-completeness gate check (round 10) (#2040)
## Summary
Fixes ALL failing shards from the latest full-suite run (29225946428)
AND adds a structural gate check to prevent the recurring mock-export
drift pattern that has caused every full-suite failure across rounds
1–9.
## What broke (run 29225946428, commit 504b0f8b0)
| Shard | Root cause | Tests fixed |
|---|---|---|
| **3 (CLI)** | `workflowValidateParams` (FN-7911) missing from
`@fusion/engine` mock | 8 files |
| **3 (CLI)** | `skill-sync.test.ts` — `fn_workflow_validate` missing
from engine-tools.md | 1 file |
| **4 (dashboard)** | 6 chat default settings keys missing from
description allowlist | 1 file |
| **1+2 (engine)** | `additionalSkillPaths` missing from
`buildSessionSkillContext` mocks (FN-1510/1511) | 10 tests |
| **1+2 (engine)** | heartbeat FN-7878 changed paused→error for generic
run failures | 1 test |
| **1+2 (engine)** | executor `updateTask` exact-match →
`objectContaining` (new fields) | 2 tests |
| **1+2 (engine)** | `connectMcpSessionTools` mock missing for pi.test
MCP forwarding | 1 test |
## Structural fix — `scripts/check-mock-completeness.mjs` (the "fix for
good")
**New gate check** added to `pnpm test:gate`. Statically validates every
hardcoded `vi.mock("@fusion/dashboard")` and `vi.mock("@fusion/engine")`
factory covers all named imports the source file uses. Runs in <0.2s, no
module evaluation.
**How it works:**
1. Extracts named exports from each barrel
(`packages/dashboard/src/index.ts`, `packages/engine/src/index.ts`)
2. For each test file with a hardcoded `vi.mock` factory (no
`importOriginal`/`importActual` spread):
- Resolves source files the test covers (static + dynamic imports,
convention mapping)
- Extracts what those source files named-import from the barrel
- Resolves spread helpers (e.g. `...workflowAuthoringEngineMock`) by
reading the helper's exported keys
- Reports any barrel exports that are named-imported by source but
absent from the mock
**Why this fixes the recurring pattern:** Every round 1–9 failure was a
new barrel export imported by source but missing from a test mock. This
check catches it at gate time, before merge — not after the full-suite
fails on main.
Also completed all 15 latent mock gaps the guard found on first run (9
dashboard + 6 engine), including expanding the centralized
`workflowAuthoringEngineMock` helper with all `extension.ts` named
imports.
## Verification
- Gate (with new check): exit 0 ✅
- CLI: 355/355 passed ✅
- Engine (6 fixed files): 250/250 passed ✅
- i18n + settings: verified ✅
- Mock completeness guard: ✅ (0 issues)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Documented a new non-destructive workflow validation tool that
performs a dry-run and returns typed validation errors.
* **Tests**
* Updated and strengthened CLI, dashboard, extension, and engine tests
with more accurate mock exports and more resilient assertions.
* Adjusted expectations for session/heartbeat and retry-related
behaviors.
* **Chores**
* Added an automated mock-completeness gate and integrated it into the
test quality gate to keep mocks aligned with available platform exports.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -19,7 +19,8 @@
|
||||
"check:line-count": "node scripts/check-file-line-count.mjs",
|
||||
"check:changesets": "node scripts/check-changeset-format.mjs",
|
||||
"check:quarantine-ledger": "node scripts/check-quarantine-ledger.mjs",
|
||||
"test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-cli-dashboard-mock-completeness.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @runfusion/fusion test:ci-shape",
|
||||
"check:mock-completeness": "node scripts/check-mock-completeness.mjs",
|
||||
"test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @runfusion/fusion test:ci-shape",
|
||||
"smoke:boot": "node scripts/boot-smoke.mjs",
|
||||
"local": "node scripts/start-local.mjs",
|
||||
"dev": "node scripts/dev-with-memory.mjs",
|
||||
|
||||
@@ -27,6 +27,7 @@ This reference documents tools injected by the engine at runtime for specific ag
|
||||
| `fn_workflow_settings` | executor | Read/write a workflow's per-`(workflow, project)` setting **values** (`get` returns `{stored, effective, orphaned}`; `set` writes `values` and returns `{stored, effective, orphaned}`, with `null` clearing an override — including any stored value for an orphaned key). Validated against the named workflow's declared settings; built-in **values** are writable though built-in **declarations** are not; invalid values return a typed rejection list and persist nothing | `action` (`get` \| `set`), `workflow_id` (string), `values?` (object keyed by setting id) |
|
||||
| `fn_workflow_list` | executor, chat, planning | List the project's custom workflows (read-only built-ins plus user definitions) | none |
|
||||
| `fn_workflow_get` | executor, chat, planning | Fetch one workflow definition by id — name, description, builtin flag, and the full IR (nodes/edges/columns/artifacts/fields) as JSON | `workflow_id` (string) |
|
||||
| `fn_workflow_validate` | executor, chat, planning | Dry-run validate a Fusion workflow IR without creating or mutating any workflow; runs the same server-side IR, trait, code-node, and column-agent validation as create/update and returns typed errors | `workflow_id?` (string), `ir?` (object) |
|
||||
| `fn_workflow_select` | executor, chat, planning | Assign a custom workflow to a task (defaults to the current task) | `workflow_id` (string), `task_id?` (string) |
|
||||
| `fn_workflow_create` | executor, chat, planning | Create a custom workflow definition from a graph IR (validated server-side). v2 IR supports step-inversion constructs: `parse-steps`, `foreach` (mode/isolation/concurrency/maxReworkCycles), `step-execute`, `step-review`, `code` nodes, `rework` edges, plus `artifacts` and custom `fields` declarations | `name` (string), `description?` (string), `ir` (object), `layout?` (object) |
|
||||
| `fn_workflow_update` | executor, chat, planning | Update a custom workflow definition's name/description/ir/layout (built-ins cannot be edited; same step-inversion IR constructs as create; editing `fields` orphans rather than destroys existing task values) | `workflow_id` (string), `name?` (string), `description?` (string), `ir?` (object), `layout?` (object), `rehome_to?` (string) |
|
||||
|
||||
@@ -53,7 +53,7 @@ vi.mock("@fusion/core", () => ({
|
||||
|
||||
vi.mock("@fusion/dashboard", () => ({
|
||||
registerGithubTrackingHook: vi.fn(),
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-cli-dashboard-mock-completeness.mjs gate).
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-mock-completeness.mjs gate).
|
||||
GitLabClient: vi.fn(),
|
||||
resolveGitlabAuth: vi.fn(() => ({})),
|
||||
buildGitLabTaskProvenance: vi.fn(() => ({})),
|
||||
@@ -69,6 +69,7 @@ vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => ({})),
|
||||
workflowListParams: {},
|
||||
workflowGetParams: {},
|
||||
workflowValidateParams: {}, // FNXC:Round10 FN-7911 added this export to @fusion/engine barrel
|
||||
workflowSelectParams: {},
|
||||
workflowCreateParams: {},
|
||||
workflowUpdateParams: {},
|
||||
|
||||
@@ -11,7 +11,7 @@ const assertNoSecretPlaintextMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@fusion/dashboard", () => ({
|
||||
registerGithubTrackingHook: vi.fn(),
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-cli-dashboard-mock-completeness.mjs gate).
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-mock-completeness.mjs gate).
|
||||
GitLabClient: vi.fn(),
|
||||
resolveGitlabAuth: vi.fn(() => ({})),
|
||||
buildGitLabTaskProvenance: vi.fn(() => ({})),
|
||||
@@ -27,6 +27,7 @@ vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => ({})),
|
||||
workflowListParams: {},
|
||||
workflowGetParams: {},
|
||||
workflowValidateParams: {}, // FNXC:Round10 FN-7911 added this export to @fusion/engine barrel
|
||||
workflowSelectParams: {},
|
||||
workflowCreateParams: {},
|
||||
workflowUpdateParams: {},
|
||||
|
||||
@@ -19,7 +19,7 @@ const registerGithubTrackingHookMock = vi.hoisted(() => vi.fn(() => {
|
||||
|
||||
vi.mock("@fusion/dashboard", () => ({
|
||||
registerGithubTrackingHook: registerGithubTrackingHookMock,
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-cli-dashboard-mock-completeness.mjs gate).
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-mock-completeness.mjs gate).
|
||||
GitLabClient: vi.fn(),
|
||||
resolveGitlabAuth: vi.fn(() => ({})),
|
||||
buildGitLabTaskProvenance: vi.fn(() => ({})),
|
||||
@@ -43,6 +43,7 @@ vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => ({})),
|
||||
workflowListParams: {},
|
||||
workflowGetParams: {},
|
||||
workflowValidateParams: {}, // FNXC:Round10 FN-7911 added this export to @fusion/engine barrel
|
||||
workflowSelectParams: {},
|
||||
workflowCreateParams: {},
|
||||
workflowUpdateParams: {},
|
||||
|
||||
@@ -40,12 +40,32 @@ vi.mock("@fusion/engine", () => ({
|
||||
assertNoSecretPlaintext: vi.fn(),
|
||||
emitGoalRetrievalAudit: vi.fn(),
|
||||
createWorkflowAuthoringTools: vi.fn(() => ({})),
|
||||
// FNXC:TestInfrastructure 2026-07-13-10:25: Complete the engine mock for extension.ts named imports (experiment finalize, workflow params, etc.).
|
||||
defaultGitOps: {},
|
||||
ExperimentFinalizeBranchExistsError: class MockError extends Error {},
|
||||
ExperimentFinalizeCherryPickConflictError: class MockError extends Error {},
|
||||
ExperimentFinalizeMergeBaseError: class MockError extends Error {},
|
||||
ExperimentFinalizeNoKeptRunsError: class MockError extends Error {},
|
||||
ExperimentFinalizePlanError: class MockError extends Error {},
|
||||
ExperimentFinalizeService: vi.fn(),
|
||||
ExperimentFinalizeStateError: class MockError extends Error {},
|
||||
isInReviewMissingWorktreeSessionStartFailure: vi.fn(),
|
||||
workflowListParams: {},
|
||||
workflowGetParams: {},
|
||||
workflowSelectParams: {},
|
||||
workflowCreateParams: {},
|
||||
workflowUpdateParams: {},
|
||||
workflowDeleteParams: {},
|
||||
workflowValidateParams: {},
|
||||
workflowSettingsParams: {},
|
||||
traitListParams: {},
|
||||
workflowListParams: {},
|
||||
workflowGetParams: {},
|
||||
workflowValidateParams: {}, // FNXC:Round10 FN-7911 added this export to @fusion/engine barrel
|
||||
workflowSelectParams: {},
|
||||
workflowCreateParams: {},
|
||||
workflowUpdateParams: {},
|
||||
workflowDeleteParams: {},
|
||||
workflowSettingsParams: {},
|
||||
traitListParams: {},
|
||||
}));
|
||||
|
||||
@@ -5,7 +5,7 @@ const fetchWebContentMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@fusion/dashboard", () => ({
|
||||
registerGithubTrackingHook: vi.fn(),
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-cli-dashboard-mock-completeness.mjs gate).
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-mock-completeness.mjs gate).
|
||||
GitLabClient: vi.fn(),
|
||||
resolveGitlabAuth: vi.fn(() => ({})),
|
||||
buildGitLabTaskProvenance: vi.fn(() => ({})),
|
||||
@@ -21,6 +21,7 @@ vi.mock("@fusion/engine", () => ({
|
||||
createWorkflowAuthoringTools: vi.fn(() => ({})),
|
||||
workflowListParams: {},
|
||||
workflowGetParams: {},
|
||||
workflowValidateParams: {}, // FNXC:Round10 FN-7911 added this export to @fusion/engine barrel
|
||||
workflowSelectParams: {},
|
||||
workflowCreateParams: {},
|
||||
workflowUpdateParams: {},
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
/*
|
||||
FNXC:SkillSync 2026-07-01-20:05:
|
||||
`src/extension.ts` registers the workflow-authoring + trait tools at module load, pulling their TypeBox param schemas and `createWorkflowAuthoringTools` from `@fusion/engine`. Any test that replaces `@fusion/engine` with a bare `vi.mock` factory must therefore still expose these named exports, or importing `../extension.js` throws `No "workflowListParams" export is defined on the "@fusion/engine" mock`. Centralize the passthrough stubs here so a future engine workflow-tool addition only updates one place instead of drifting each extension test's factory. The param schemas are used only as `.parameters` passthroughs and `createWorkflowAuthoringTools` is only invoked in the exec path, so empty schemas / an empty tool list keep the module-load contract without exercising engine behavior.
|
||||
`src/extension.ts` registers the workflow-authoring + trait tools at module load, pulling their TypeBox param schemas and `createWorkflowAuthoringTools` from `@fusion/engine`. Any test that replaces `@fusion/engine` with a bare `vi.mock` factory must therefore still expose these named exports, or importing `../extension.js` throws `No "workflowListParams" export is defined on the "@fusion/engine" mock`. Centralize the passthrough stubs here so a future engine workflow-tool addition only updates one place instead of drifting each extension test's factory.
|
||||
|
||||
FNXC:TestInfrastructure 2026-07-13-10:20:
|
||||
extension.ts also named-imports defaultGitOps, ExperimentFinalize* error classes/service, assertNoSecretPlaintext, emitGoalRetrievalAudit, and isInReviewMissingWorktreeSessionStartFailure from @fusion/engine. All of these trigger the vitest "No export defined" error if absent from a hardcoded mock. Add them here so the centralized helper covers the full module-load contract.
|
||||
*/
|
||||
|
||||
/** Stub TypeBox param schema — only ever forwarded as a tool `.parameters` value. */
|
||||
@@ -13,7 +18,20 @@ export const workflowAuthoringEngineMock = {
|
||||
workflowCreateParams: emptyParams,
|
||||
workflowUpdateParams: emptyParams,
|
||||
workflowDeleteParams: emptyParams,
|
||||
workflowValidateParams: emptyParams,
|
||||
workflowSettingsParams: emptyParams,
|
||||
traitListParams: emptyParams,
|
||||
createWorkflowAuthoringTools: () => [] as unknown[],
|
||||
// Remaining named imports from extension.ts — stubs satisfy module-load only.
|
||||
defaultGitOps: {},
|
||||
ExperimentFinalizeBranchExistsError: class MockError extends Error {},
|
||||
ExperimentFinalizeCherryPickConflictError: class MockError extends Error {},
|
||||
ExperimentFinalizeMergeBaseError: class MockError extends Error {},
|
||||
ExperimentFinalizeNoKeptRunsError: class MockError extends Error {},
|
||||
ExperimentFinalizePlanError: class MockError extends Error {},
|
||||
ExperimentFinalizeService: vi.fn(),
|
||||
ExperimentFinalizeStateError: class MockError extends Error {},
|
||||
assertNoSecretPlaintext: vi.fn(),
|
||||
emitGoalRetrievalAudit: vi.fn(),
|
||||
isInReviewMissingWorktreeSessionStartFailure: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ vi.mock("../project-context.js", () => ({
|
||||
|
||||
vi.mock("@fusion/dashboard", () => ({
|
||||
registerGithubTrackingHook: vi.fn(),
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-cli-dashboard-mock-completeness.mjs gate).
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-mock-completeness.mjs gate).
|
||||
GitLabClient: vi.fn(),
|
||||
resolveGitlabAuth: vi.fn(() => ({})),
|
||||
buildGitLabTaskProvenance: vi.fn(() => ({})),
|
||||
@@ -54,6 +54,8 @@ vi.mock("@fusion/engine", () => ({
|
||||
createFnAgent: vi.fn(),
|
||||
runAiMerge: vi.fn(),
|
||||
landWorkspaceTask: vi.fn(),
|
||||
// FNXC:TestInfrastructure 2026-07-13-10:25: extension.ts named-imports this from @fusion/engine.
|
||||
isInReviewMissingWorktreeSessionStartFailure: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/dashboard/planning", () => ({
|
||||
|
||||
@@ -563,6 +563,8 @@ vi.mock("@fusion/core", async (importOriginal) => {
|
||||
});
|
||||
|
||||
vi.mock("@fusion/dashboard", () => ({
|
||||
// FNXC:TestInfrastructure 2026-07-13-10:25: Source files named-import these from @fusion/dashboard barrel; mock must surface them.
|
||||
registerGithubTrackingHook: vi.fn(),
|
||||
// FNXC:CliTests 2026-07-13-08:10: @fusion/dashboard barrel re-exports cli-package-version helpers; mock must surface them for startup model sync.
|
||||
isUnresolvedCliPackageVersion: vi.fn(() => false),
|
||||
resolveCliPackageVersionInfo: vi.fn(() => ({ version: "0.0.0-test", isUnresolved: false })),
|
||||
@@ -576,7 +578,7 @@ resolveCliPackageVersionInfo: vi.fn(() => ({ version: "0.0.0-test", isUnresolved
|
||||
getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"),
|
||||
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
|
||||
refreshAllCustomProviderModels: mocks.refreshAllCustomProviderModels,
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-cli-dashboard-mock-completeness.mjs gate).
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-mock-completeness.mjs gate).
|
||||
registerGithubTrackingHook: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -396,6 +396,12 @@ const mockListen = vi.fn((port: number) => {
|
||||
});
|
||||
|
||||
vi.mock("@fusion/dashboard", () => ({
|
||||
// FNXC:TestInfrastructure 2026-07-13-10:25: Source files named-import these from @fusion/dashboard barrel; mock must surface them.
|
||||
registerGithubTrackingHook: vi.fn(),
|
||||
AttachTicketStore: vi.fn(),
|
||||
CliInputAttributionLog: vi.fn(),
|
||||
CliConfirmAdvanceRegistry: vi.fn(),
|
||||
CliRelaunchRegistry: vi.fn(),
|
||||
// FNXC:CliTests 2026-07-13-08:10: @fusion/dashboard barrel re-exports cli-package-version helpers; mock must surface them for startup model sync.
|
||||
isUnresolvedCliPackageVersion: vi.fn(() => false),
|
||||
resolveCliPackageVersionInfo: vi.fn(() => ({ version: "0.0.0-test", isUnresolved: false })),
|
||||
@@ -425,7 +431,7 @@ resolveCliPackageVersionInfo: vi.fn(() => ({ version: "0.0.0-test", isUnresolved
|
||||
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
|
||||
refreshAllCustomProviderModels: mockRefreshAllCustomProviderModels,
|
||||
stopAllDevServers: vi.fn().mockResolvedValue(undefined),
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-cli-dashboard-mock-completeness.mjs gate).
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-mock-completeness.mjs gate).
|
||||
AttachTicketStore: vi.fn(),
|
||||
CliInputAttributionLog: vi.fn(),
|
||||
CliConfirmAdvanceRegistry: vi.fn(),
|
||||
|
||||
@@ -623,6 +623,8 @@ vi.mock("@fusion/core", async (importOriginal) => {
|
||||
});
|
||||
|
||||
vi.mock("@fusion/dashboard", () => ({
|
||||
// FNXC:TestInfrastructure 2026-07-13-10:25: Source files named-import these from @fusion/dashboard barrel; mock must surface them.
|
||||
registerGithubTrackingHook: vi.fn(),
|
||||
// FNXC:CliTests 2026-07-13-08:10: @fusion/dashboard barrel re-exports cli-package-version helpers; mock must surface them for startup model sync.
|
||||
isUnresolvedCliPackageVersion: vi.fn(() => false),
|
||||
resolveCliPackageVersionInfo: vi.fn(() => ({ version: "0.0.0-test", isUnresolved: false })),
|
||||
@@ -636,7 +638,7 @@ resolveCliPackageVersionInfo: vi.fn(() => ({ version: "0.0.0-test", isUnresolved
|
||||
getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"),
|
||||
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
|
||||
refreshAllCustomProviderModels: mocks.refreshAllCustomProviderModels,
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-cli-dashboard-mock-completeness.mjs gate).
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-mock-completeness.mjs gate).
|
||||
registerGithubTrackingHook: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ vi.mock("@fusion/dashboard", () => ({
|
||||
}),
|
||||
generatePrMetadata: vi.fn(),
|
||||
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-cli-dashboard-mock-completeness.mjs gate).
|
||||
// FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-mock-completeness.mjs gate).
|
||||
registerGithubTrackingHook: vi.fn(),
|
||||
GitLabClient: vi.fn(),
|
||||
resolveGitlabAuth: vi.fn(() => ({})),
|
||||
|
||||
@@ -489,6 +489,14 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
|
||||
prerebaseDivergenceThreshold: "internal pre-rebase tuning constant, no UI field",
|
||||
maxSpawnedAgentsPerParent: "internal spawn-limit constant, no UI field",
|
||||
maxSpawnedAgentsGlobal: "internal spawn-limit constant, no UI field",
|
||||
// FNXC:Round10 2026-07-13: FN-7907/FN-7908 added chat default model/agent/session settings.
|
||||
// These are configured via the chat New Session defaults picker, not plain description fields.
|
||||
chatNewSessionMode: "chat new-session default mode, configured via the chat defaults picker, not a plain description field",
|
||||
chatDefaultKind: "chat default agent kind, configured via the chat defaults picker, not a plain description field",
|
||||
chatDefaultAgentId: "chat default agent id, configured via the chat defaults picker, not a plain description field",
|
||||
chatDefaultModelProvider: "chat default model provider, configured via the chat defaults picker, not a plain description field",
|
||||
chatDefaultModelId: "chat default model id, configured via the chat defaults picker, not a plain description field",
|
||||
chatDefaultThinkingLevel: "chat default thinking level, configured via the chat defaults picker, not a plain description field",
|
||||
};
|
||||
|
||||
describe("FN-7505 settings default-value description guard", () => {
|
||||
|
||||
@@ -430,7 +430,8 @@ describe("Workflow Steps Execution", () => {
|
||||
await executor.execute(baseTask as any);
|
||||
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-5436-D", { workflowStepRetries: undefined, taskDoneRetryCount: null });
|
||||
// FNXC:ExecutorRetry 2026-07-13: Use objectContaining because production now passes additional fields (executeRequeueLoopCount, executeRequeueLoopSignature, branch, worktree, sessionFile) in the same updateTask call.
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-5436-D", expect.objectContaining({ workflowStepRetries: undefined, taskDoneRetryCount: null }));
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-5436-D", {
|
||||
status: "failed",
|
||||
error: "executor-exit-while-review-pending",
|
||||
|
||||
@@ -742,8 +742,9 @@ describe("Budget Governance", () => {
|
||||
});
|
||||
|
||||
expect(store.getBudgetStatus).not.toHaveBeenCalled();
|
||||
// FNXC:HeartbeatTests 2026-07-12-FN7835: FN-7835/FN-7859 park non-recoverable run failures as "paused" (pauseReason: error-unrecoverable) instead of bare "error". Budget governance still does not engage on failure (assertion below: never paused with budget-exhausted reason).
|
||||
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "paused");
|
||||
// FNXC:HeartbeatTests 2026-07-13: FN-7878 changed generic run failures (status=failed without unrecoverable auth/model/billing signal) from "paused" (FN-7835/FN-7859) to bare "error" so the bounded retry budget can run. Budget governance still does not engage on failure (assertion below: never paused with budget-exhausted reason). See agent-heartbeat.ts:1829-1831 else-branch.
|
||||
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "error");
|
||||
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "paused");
|
||||
expect(store.updateAgent).not.toHaveBeenCalledWith("agent-001", { pauseReason: "budget-exhausted" });
|
||||
});
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ const setupFailingTheirsStrategy = setupFailingFallbackStrategy;
|
||||
|
||||
|
||||
describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)", () => {
|
||||
// Mock session-skill-context to control skill selection behavior
|
||||
// FNXC:SessionSkillContext 2026-07-13: buildSessionSkillContext mockResolvedValue objects MUST include additionalSkillPaths: [] — production code (merger.ts:1991/3187/3607/12094) reads skillContext.additionalSkillPaths.length unconditionally when skillContext is truthy; omitting the field crashes with TypeError before createFnAgent is reached.
|
||||
vi.mock("../session-skill-context.js", () => ({
|
||||
buildSessionSkillContext: vi.fn(),
|
||||
}));
|
||||
@@ -279,15 +279,11 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
|
||||
|
||||
it("passes skillSelection to createFnAgent when agentStore is provided", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: ["fusion"],
|
||||
sessionPurpose: "merger",
|
||||
},
|
||||
resolvedSkillNames: ["fusion"],
|
||||
skillSource: "role-fallback",
|
||||
});
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({ skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: ["fusion"],
|
||||
sessionPurpose: "merger",
|
||||
}, resolvedSkillNames: ["fusion"], skillSource: "role-fallback", additionalSkillPaths: [] });
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
@@ -327,15 +323,11 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
|
||||
|
||||
it("uses assigned agent skills when available", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: ["custom-skill", "another-skill"],
|
||||
sessionPurpose: "merger",
|
||||
},
|
||||
resolvedSkillNames: ["custom-skill", "another-skill"],
|
||||
skillSource: "assigned-agent",
|
||||
});
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({ skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: ["custom-skill", "another-skill"],
|
||||
sessionPurpose: "merger",
|
||||
}, resolvedSkillNames: ["custom-skill", "another-skill"], skillSource: "assigned-agent", additionalSkillPaths: [] });
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
@@ -372,11 +364,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
|
||||
|
||||
it("does not pass skillSelection when buildSessionSkillContext returns undefined context", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: undefined,
|
||||
resolvedSkillNames: [],
|
||||
skillSource: "none",
|
||||
});
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({ skillSelectionContext: undefined, resolvedSkillNames: [], skillSource: "none", additionalSkillPaths: [] });
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
@@ -480,15 +468,11 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
|
||||
it("records resolved skill names in skill context result", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
const resolvedNames = ["skill-a", "skill-b"];
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: resolvedNames,
|
||||
sessionPurpose: "merger",
|
||||
},
|
||||
resolvedSkillNames: resolvedNames,
|
||||
skillSource: "assigned-agent",
|
||||
});
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({ skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: resolvedNames,
|
||||
sessionPurpose: "merger",
|
||||
}, resolvedSkillNames: resolvedNames, skillSource: "assigned-agent", additionalSkillPaths: [] });
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
@@ -524,15 +508,11 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
|
||||
|
||||
it("uses sessionPurpose='merger' in skill selection context", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: ["fusion"],
|
||||
sessionPurpose: "merger",
|
||||
},
|
||||
resolvedSkillNames: ["fusion"],
|
||||
skillSource: "role-fallback",
|
||||
});
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({ skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: ["fusion"],
|
||||
sessionPurpose: "merger",
|
||||
}, resolvedSkillNames: ["fusion"], skillSource: "role-fallback", additionalSkillPaths: [] });
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
@@ -569,7 +549,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
|
||||
|
||||
|
||||
describe("aiMergeTask — skill selection non-fatal diagnostics (FN-1510/FN-1511)", () => {
|
||||
// Mock session-skill-context to control skill selection behavior
|
||||
// FNXC:SessionSkillContext 2026-07-13: buildSessionSkillContext mockResolvedValue objects MUST include additionalSkillPaths: [] — same contract as the resolver-contract block above; production code reads skillContext.additionalSkillPaths.length unconditionally.
|
||||
vi.mock("../session-skill-context.js", () => ({
|
||||
buildSessionSkillContext: vi.fn(),
|
||||
}));
|
||||
@@ -587,15 +567,11 @@ describe("aiMergeTask — skill selection non-fatal diagnostics (FN-1510/FN-1511
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
// Simulate diagnostics being logged - the resolver would produce these
|
||||
// when requested skills are not found or filtered
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: ["nonexistent-skill"],
|
||||
sessionPurpose: "merger",
|
||||
},
|
||||
resolvedSkillNames: [],
|
||||
skillSource: "none",
|
||||
});
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({ skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: ["nonexistent-skill"],
|
||||
sessionPurpose: "merger",
|
||||
}, resolvedSkillNames: [], skillSource: "none", additionalSkillPaths: [] });
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
@@ -630,15 +606,11 @@ describe("aiMergeTask — skill selection non-fatal diagnostics (FN-1510/FN-1511
|
||||
|
||||
it("records skill source in context result for debugging", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: ["custom-skill"],
|
||||
sessionPurpose: "merger",
|
||||
},
|
||||
resolvedSkillNames: ["custom-skill"],
|
||||
skillSource: "assigned-agent",
|
||||
});
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({ skillSelectionContext: {
|
||||
projectRootDir: "/tmp/root",
|
||||
requestedSkillNames: ["custom-skill"],
|
||||
sessionPurpose: "merger",
|
||||
}, resolvedSkillNames: ["custom-skill"], skillSource: "assigned-agent", additionalSkillPaths: [] });
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: {
|
||||
|
||||
@@ -75,6 +75,23 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
|
||||
inMemory: vi.fn(() => ({})),
|
||||
},
|
||||
}));
|
||||
// FNXC:McpConfig 2026-07-13: Mock connectMcpSessionTools so createFnAgent doesn't attempt real MCP server bootstrap (which fails in tests because the server binary doesn't exist). MAIN-008 made bootstrap failures throw McpSessionBootstrapError; this mock returns a clean toolset so the MCP forwarding path is exercised without a live server.
|
||||
vi.mock("../mcp-session-tools.js", () => ({
|
||||
connectMcpSessionTools: vi.fn().mockResolvedValue({
|
||||
tools: [],
|
||||
connected: [],
|
||||
skipped: [],
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
McpSessionBootstrapError: class McpSessionBootstrapError extends Error {
|
||||
failures: Array<{ name: string; reason: string }>;
|
||||
constructor(failures: Array<{ name: string; reason: string }>) {
|
||||
super("MCP session bootstrap failed");
|
||||
this.name = "McpSessionBootstrapError";
|
||||
this.failures = failures;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mock accessors after mocking (must use dynamic import for hoisted mocks)
|
||||
let resolveSessionSkillsMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -105,7 +105,8 @@ describe("reliability interactions: FN-5436 executor pending-review skip", () =>
|
||||
const executor = new TaskExecutor(store as any, "/repo");
|
||||
await executor.execute(task);
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-5436-RI-D", { workflowStepRetries: undefined, taskDoneRetryCount: null });
|
||||
// FNXC:ExecutorRetry 2026-07-13: Use objectContaining because production now passes additional fields (executeRequeueLoopCount, executeRequeueLoopSignature, branch, worktree, sessionFile) in the same updateTask call.
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-5436-RI-D", expect.objectContaining({ workflowStepRetries: undefined, taskDoneRetryCount: null }));
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-5436-RI-D", {
|
||||
status: "failed",
|
||||
error: "executor-exit-while-review-pending",
|
||||
|
||||
@@ -1326,7 +1326,7 @@ describe("reviewStep — user comments in spec review", () => {
|
||||
});
|
||||
|
||||
describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", () => {
|
||||
// Mock session-skill-context to control skill selection behavior
|
||||
// FNXC:SessionSkillContext 2026-07-13: buildSessionSkillContext mockResolvedValue objects MUST include additionalSkillPaths: [] — the production code (reviewer.ts:429) reads skillContext.additionalSkillPaths.length unconditionally when skillContext is truthy; omitting the field crashes with TypeError before createFnAgent is reached.
|
||||
vi.mock("../session-skill-context.js", () => ({
|
||||
buildSessionSkillContext: vi.fn(),
|
||||
}));
|
||||
@@ -1337,15 +1337,11 @@ describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", (
|
||||
|
||||
it("passes skillSelection to createFnAgent when agentStore and rootDir are provided", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: {
|
||||
projectRootDir: "/tmp/project",
|
||||
requestedSkillNames: ["fusion"],
|
||||
sessionPurpose: "reviewer",
|
||||
},
|
||||
resolvedSkillNames: ["fusion"],
|
||||
skillSource: "role-fallback",
|
||||
});
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({ skillSelectionContext: {
|
||||
projectRootDir: "/tmp/project",
|
||||
requestedSkillNames: ["fusion"],
|
||||
sessionPurpose: "reviewer",
|
||||
}, resolvedSkillNames: ["fusion"], skillSource: "role-fallback", additionalSkillPaths: [] });
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue(
|
||||
createMockSession("### Verdict: APPROVE\n### Summary\nGood."),
|
||||
@@ -1374,15 +1370,11 @@ describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", (
|
||||
|
||||
it("uses assigned agent skills when available", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: {
|
||||
projectRootDir: "/tmp/project",
|
||||
requestedSkillNames: ["custom-skill", "another-skill"],
|
||||
sessionPurpose: "reviewer",
|
||||
},
|
||||
resolvedSkillNames: ["custom-skill", "another-skill"],
|
||||
skillSource: "assigned-agent",
|
||||
});
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({ skillSelectionContext: {
|
||||
projectRootDir: "/tmp/project",
|
||||
requestedSkillNames: ["custom-skill", "another-skill"],
|
||||
sessionPurpose: "reviewer",
|
||||
}, resolvedSkillNames: ["custom-skill", "another-skill"], skillSource: "assigned-agent", additionalSkillPaths: [] });
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue(
|
||||
createMockSession("### Verdict: APPROVE\n### Summary\nGood."),
|
||||
@@ -1411,11 +1403,7 @@ describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", (
|
||||
|
||||
it("does not pass skillSelection when buildSessionSkillContext returns undefined context", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: undefined,
|
||||
resolvedSkillNames: [],
|
||||
skillSource: "none",
|
||||
});
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({ skillSelectionContext: undefined, resolvedSkillNames: [], skillSource: "none", additionalSkillPaths: [] });
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue(
|
||||
createMockSession("### Verdict: APPROVE\n### Summary\nGood."),
|
||||
@@ -1489,15 +1477,11 @@ describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", (
|
||||
it("records resolved skill names in skill context result", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
const resolvedNames = ["skill-a", "skill-b", "skill-c"];
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: {
|
||||
projectRootDir: "/tmp/project",
|
||||
requestedSkillNames: resolvedNames,
|
||||
sessionPurpose: "reviewer",
|
||||
},
|
||||
resolvedSkillNames: resolvedNames,
|
||||
skillSource: "assigned-agent",
|
||||
});
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({ skillSelectionContext: {
|
||||
projectRootDir: "/tmp/project",
|
||||
requestedSkillNames: resolvedNames,
|
||||
sessionPurpose: "reviewer",
|
||||
}, resolvedSkillNames: resolvedNames, skillSource: "assigned-agent", additionalSkillPaths: [] });
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue(
|
||||
createMockSession("### Verdict: APPROVE\n### Summary\nGood."),
|
||||
@@ -1524,15 +1508,11 @@ describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", (
|
||||
|
||||
it("uses sessionPurpose='reviewer' in skill selection context", async () => {
|
||||
const { buildSessionSkillContext } = await import("../session-skill-context.js");
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({
|
||||
skillSelectionContext: {
|
||||
projectRootDir: "/tmp/project",
|
||||
requestedSkillNames: ["fusion"],
|
||||
sessionPurpose: "reviewer",
|
||||
},
|
||||
resolvedSkillNames: ["fusion"],
|
||||
skillSource: "role-fallback",
|
||||
});
|
||||
vi.mocked(buildSessionSkillContext).mockResolvedValue({ skillSelectionContext: {
|
||||
projectRootDir: "/tmp/project",
|
||||
requestedSkillNames: ["fusion"],
|
||||
sessionPurpose: "reviewer",
|
||||
}, resolvedSkillNames: ["fusion"], skillSource: "role-fallback", additionalSkillPaths: [] });
|
||||
|
||||
mockedCreateFnAgent.mockResolvedValue(
|
||||
createMockSession("### Verdict: APPROVE\n### Summary\nGood."),
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
/*
|
||||
* FNXC:TestInfrastructure 2026-07-13-09:30:
|
||||
* Static gate check that prevents the recurring full-suite failure pattern where
|
||||
* a new export added to the @fusion/dashboard barrel is imported by CLI source
|
||||
* code but missing from the hardcoded vi.mock("@fusion/dashboard") factory in
|
||||
* the corresponding CLI test file.
|
||||
*
|
||||
* This runs as part of the merge gate (pnpm test:gate) so drift is caught
|
||||
* before merge, not after full-suite fails on main.
|
||||
*
|
||||
* The check is purely static (regex-based, no module evaluation) and fast (<1s).
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, "..");
|
||||
const cliSrc = join(root, "packages/cli/src");
|
||||
|
||||
// ── 1. Extract value exports from the dashboard barrel ──────────────────────
|
||||
|
||||
const barrelPath = join(root, "packages/dashboard/src/index.ts");
|
||||
const barrelSrc = readFileSync(barrelPath, "utf8");
|
||||
|
||||
/**
|
||||
* Extract named exports from the barrel, excluding type-only exports.
|
||||
* Handles: export { foo, type Bar, baz as qux } from "./mod.js";
|
||||
*/
|
||||
function extractBarrelExports(src) {
|
||||
const exports = new Set();
|
||||
const namedRe = /export\s*\{([^}]+)\}\s*from\s*"[^"]+"/g;
|
||||
let m;
|
||||
while ((m = namedRe.exec(src)) !== null) {
|
||||
for (let raw of m[1].split(",")) {
|
||||
raw = raw.trim();
|
||||
if (!raw || raw.startsWith("type ")) continue;
|
||||
const aliased = raw.split(/\s+as\s+/);
|
||||
const name = (aliased[aliased.length - 1] || raw).trim();
|
||||
if (name && /^[A-Za-z_]/.test(name)) exports.add(name);
|
||||
}
|
||||
}
|
||||
return exports;
|
||||
}
|
||||
|
||||
const barrelExports = extractBarrelExports(barrelSrc);
|
||||
|
||||
// ── 2. Extract @fusion/dashboard usage from a source file ───────────────────
|
||||
|
||||
/**
|
||||
* Extract dashboard member usage from a source file.
|
||||
* Handles both named imports and namespace imports.
|
||||
* Returns a Set of member names.
|
||||
*/
|
||||
function extractDashboardUsage(filePath) {
|
||||
let src;
|
||||
try { src = readFileSync(filePath, "utf8"); } catch { return new Set(); }
|
||||
const used = new Set();
|
||||
|
||||
// Named imports: import { A, type B, C as D } from "@fusion/dashboard"
|
||||
const namedRe = /import\s*\{([^}]+)\}\s*from\s*"@fusion\/dashboard"/g;
|
||||
let m;
|
||||
while ((m = namedRe.exec(src)) !== null) {
|
||||
for (let raw of m[1].split(",")) {
|
||||
raw = raw.trim();
|
||||
if (!raw || raw.startsWith("type ")) continue;
|
||||
const aliased = raw.split(/\s+as\s+/);
|
||||
const name = (aliased[0] || raw).trim();
|
||||
if (name && /^[A-Za-z_]/.test(name)) used.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Namespace imports: import * as X from "@fusion/dashboard"
|
||||
// Then find all X.member usages.
|
||||
const nsRe = /import\s*\*\s*as\s+(\w+)\s*from\s*"@fusion\/dashboard"/g;
|
||||
while ((m = nsRe.exec(src)) !== null) {
|
||||
const ns = m[1];
|
||||
const memberRe = new RegExp(`\\b${ns}\\.(\\w+)`, "g");
|
||||
let mm;
|
||||
while ((mm = memberRe.exec(src)) !== null) {
|
||||
used.add(mm[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return used;
|
||||
}
|
||||
|
||||
// ── 3. Resolve source files from a test file ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find source files a test covers by:
|
||||
* 1. Parsing static/dynamic imports in the test file
|
||||
* 2. Convention: __tests__/foo.test.ts → ../foo.ts
|
||||
*/
|
||||
function resolveSourceFiles(testPath) {
|
||||
const sources = new Set();
|
||||
const testDir = dirname(testPath);
|
||||
let testSrc;
|
||||
try { testSrc = readFileSync(testPath, "utf8"); } catch { return sources; }
|
||||
|
||||
// Static imports: import { ... } from "../foo.js" or "../commands/bar.js"
|
||||
const staticRe = /import\s+(?:type\s+)?[\w{},\s*]*\s*from\s*"(\.\.?\/[^"]+\.js)"/g;
|
||||
let m;
|
||||
while ((m = staticRe.exec(testSrc)) !== null) {
|
||||
const resolved = resolve(testDir, m[1].replace(/\.js$/, ".ts"));
|
||||
if (existsSync(resolved)) sources.add(resolved);
|
||||
}
|
||||
|
||||
// Dynamic imports: await import("../foo.js") or import("../commands/bar.js")
|
||||
const dynRe = /import\(\s*"(\.\.?\/[^"]+\.js)"\s*\)/g;
|
||||
while ((m = dynRe.exec(testSrc)) !== null) {
|
||||
const resolved = resolve(testDir, m[1].replace(/\.js$/, ".ts"));
|
||||
if (existsSync(resolved)) sources.add(resolved);
|
||||
}
|
||||
|
||||
// Convention fallback: __tests__/foo.test.ts → ../foo.ts
|
||||
const noTests = testPath.replace(/__tests\//, "");
|
||||
const convPath = noTests.replace(/\.test\.ts$/, ".ts");
|
||||
if (existsSync(convPath)) sources.add(convPath);
|
||||
|
||||
// bin.test.ts special case
|
||||
if (testPath.endsWith("__tests__/bin.test.ts")) {
|
||||
const binPath = join(cliSrc, "bin.ts");
|
||||
if (existsSync(binPath)) sources.add(binPath);
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
// ── 4. Extract mock keys from a hardcoded vi.mock factory ───────────────────
|
||||
|
||||
/**
|
||||
* Extract mock keys from vi.mock("@fusion/dashboard", () => ({ ... })).
|
||||
* Returns null if mock uses importOriginal/importActual (auto-spread, safe).
|
||||
*/
|
||||
function extractMockKeys(testSrc) {
|
||||
if (/vi\.mock\(\s*"@fusion\/dashboard"[^)]*importOriginal/.test(testSrc) ||
|
||||
/vi\.mock\(\s*"@fusion\/dashboard"[^)]*importActual/.test(testSrc)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the start of the mock factory object: () => ({
|
||||
const startRe = /vi\.mock\(\s*"@fusion\/dashboard"\s*,\s*\([^)]*\)\s*=>\s*\(\s*\{/;
|
||||
const startMatch = startRe.exec(testSrc);
|
||||
if (!startMatch) return null;
|
||||
|
||||
// Depth-aware extraction: track { } depth to find the matching close
|
||||
const bodyStart = startMatch.index + startMatch[0].length;
|
||||
let depth = 1;
|
||||
let i = bodyStart;
|
||||
while (i < testSrc.length && depth > 0) {
|
||||
const ch = testSrc[i];
|
||||
if (ch === "{") depth++;
|
||||
else if (ch === "}") depth--;
|
||||
i++;
|
||||
}
|
||||
const body = testSrc.slice(bodyStart, i - 1);
|
||||
|
||||
// Extract property keys from the mock body
|
||||
const keys = new Set();
|
||||
const keyRe = /(?:^|\n)\s*([A-Za-z_$][\w$]*)\s*(?::)/g;
|
||||
let km;
|
||||
while ((km = keyRe.exec(body)) !== null) {
|
||||
keys.add(km[1]);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// ── 5. Run the check ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Recursively collect .ts files under a directory.
|
||||
*/
|
||||
function collectTs(dir) {
|
||||
let out = [];
|
||||
try {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) out = out.concat(collectTs(full));
|
||||
else if (entry.endsWith(".ts")) out.push(full);
|
||||
}
|
||||
} catch { /* dir may not exist */ }
|
||||
return out;
|
||||
}
|
||||
|
||||
const testFiles = collectTs(join(cliSrc, "__tests__"))
|
||||
.concat(collectTs(join(cliSrc, "commands", "__tests__")))
|
||||
.concat(collectTs(join(cliSrc, "plugins", "__tests__")))
|
||||
.filter(f => f.endsWith(".test.ts"));
|
||||
|
||||
const errors = [];
|
||||
|
||||
for (const testFile of testFiles) {
|
||||
const testSrc = readFileSync(testFile, "utf8");
|
||||
if (!testSrc.includes('vi.mock("@fusion/dashboard"')) continue;
|
||||
|
||||
const mockKeys = extractMockKeys(testSrc);
|
||||
if (mockKeys === null) continue;
|
||||
|
||||
// Collect required dashboard exports from all source files this test covers
|
||||
const sourcePaths = resolveSourceFiles(testFile);
|
||||
const requiredExports = new Set();
|
||||
|
||||
for (const srcPath of sourcePaths) {
|
||||
const used = extractDashboardUsage(srcPath);
|
||||
for (const e of used) {
|
||||
// Only flag exports that actually exist in the dashboard barrel
|
||||
// (filters out namespace typos and non-export members)
|
||||
if (barrelExports.has(e)) requiredExports.add(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check: every required export must be in mock keys
|
||||
const missing = [...requiredExports].filter(e => !mockKeys.has(e));
|
||||
if (missing.length > 0) {
|
||||
const rel = testFile.replace(root + "/", "");
|
||||
errors.push(
|
||||
` ${rel}\n missing: ${missing.map(m => `"${m}"`).join(", ")}\n` +
|
||||
` (imported from @fusion/dashboard in source, absent from vi.mock factory)\n` +
|
||||
` resolved sources: ${[...sourcePaths].map(s => s.replace(root + "/", "")).join(", ") || "(none found)"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
`\n❌ CLI dashboard mock completeness check failed (${errors.length} issue${errors.length > 1 ? "s" : ""}):\n`
|
||||
);
|
||||
for (const e of errors) console.error(e + "\n");
|
||||
console.error(
|
||||
`Fix: add the missing export(s) to each vi.mock("@fusion/dashboard") factory.`
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("✅ CLI dashboard mock completeness: all hardcoded mocks cover source imports.");
|
||||
}
|
||||
288
scripts/check-mock-completeness.mjs
Normal file
288
scripts/check-mock-completeness.mjs
Normal file
@@ -0,0 +1,288 @@
|
||||
/*
|
||||
* FNXC:TestInfrastructure 2026-07-13-10:00:
|
||||
* Static gate check that prevents the recurring full-suite failure pattern where
|
||||
* a new export added to a barrel module (@fusion/dashboard, @fusion/engine) is
|
||||
* imported by source code but missing from the hardcoded vi.mock factory in the
|
||||
* corresponding test file.
|
||||
*
|
||||
* This runs as part of the merge gate (pnpm test:gate) so drift is caught
|
||||
* before merge, not after full-suite fails on main.
|
||||
*
|
||||
* The check is purely static (regex-based, no module evaluation) and fast (<0.2s).
|
||||
*
|
||||
* Covers both CLI and engine test files. For each hardcoded vi.mock of a barrel
|
||||
* module, it:
|
||||
* 1. Extracts the mock factory's exported keys
|
||||
* 2. Finds what the test's source files import from that barrel
|
||||
* 3. Reports any barrel exports that are imported by source but absent from the mock
|
||||
*
|
||||
* Mocks that use importOriginal/importActual (auto-spread) are skipped — they
|
||||
* inherit all real exports automatically.
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, "..");
|
||||
|
||||
// ── Config: barrel modules to check ──────────────────────────────────────────
|
||||
|
||||
const BARRELS = [
|
||||
{
|
||||
moduleName: "@fusion/dashboard",
|
||||
barrelPath: join(root, "packages/dashboard/src/index.ts"),
|
||||
testDirs: [
|
||||
join(root, "packages/cli/src/__tests__"),
|
||||
join(root, "packages/cli/src/commands/__tests__"),
|
||||
join(root, "packages/cli/src/plugins/__tests__"),
|
||||
],
|
||||
cliSrc: join(root, "packages/cli/src"),
|
||||
},
|
||||
{
|
||||
moduleName: "@fusion/engine",
|
||||
barrelPath: join(root, "packages/engine/src/index.ts"),
|
||||
testDirs: [
|
||||
join(root, "packages/cli/src/__tests__"),
|
||||
join(root, "packages/cli/src/commands/__tests__"),
|
||||
join(root, "packages/cli/src/plugins/__tests__"),
|
||||
],
|
||||
cliSrc: join(root, "packages/cli/src"),
|
||||
},
|
||||
];
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function extractBarrelExports(src) {
|
||||
const exports = new Set();
|
||||
const namedRe = /export\s*\{([^}]+)\}\s*from\s*"[^"]+"/g;
|
||||
let m;
|
||||
while ((m = namedRe.exec(src)) !== null) {
|
||||
for (let raw of m[1].split(",")) {
|
||||
raw = raw.trim();
|
||||
if (!raw || raw.startsWith("type ")) continue;
|
||||
const aliased = raw.split(/\s+as\s+/);
|
||||
const name = (aliased[aliased.length - 1] || raw).trim();
|
||||
if (name && /^[A-Za-z_]/.test(name)) exports.add(name);
|
||||
}
|
||||
}
|
||||
// Also catch `export function foo`, `export const bar`, `export class Baz`
|
||||
const declRe = /export\s+(?:async\s+)?(?:function|const|class|let|var)\s+([A-Za-z_$][\w$]*)/g;
|
||||
while ((m = declRe.exec(src)) !== null) {
|
||||
exports.add(m[1]);
|
||||
}
|
||||
return exports;
|
||||
}
|
||||
|
||||
function extractModuleUsage(filePath, moduleName) {
|
||||
let src;
|
||||
try { src = readFileSync(filePath, "utf8"); } catch { return new Set(); }
|
||||
const used = new Set();
|
||||
|
||||
// Named imports: import { A, type B, C as D } from "@fusion/engine"
|
||||
const namedRe = new RegExp(
|
||||
`import\\s*\\{([^}]+)\\}\\s*from\\s*"${moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`,
|
||||
"g",
|
||||
);
|
||||
let m;
|
||||
while ((m = namedRe.exec(src)) !== null) {
|
||||
for (let raw of m[1].split(",")) {
|
||||
raw = raw.trim();
|
||||
if (!raw || raw.startsWith("type ")) continue;
|
||||
const aliased = raw.split(/\s+as\s+/);
|
||||
const name = (aliased[0] || raw).trim();
|
||||
if (name && /^[A-Za-z_]/.test(name)) used.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: namespace imports (import * as X) do NOT throw when a member is
|
||||
// missing from the mock — the member is simply `undefined`. Only named
|
||||
// imports trigger the "[vitest] No X export defined" error, so we skip
|
||||
// namespace import member extraction here.
|
||||
|
||||
return used;
|
||||
}
|
||||
|
||||
function resolveSourceFiles(testPath, cliSrc) {
|
||||
const sources = new Set();
|
||||
const testDir = dirname(testPath);
|
||||
let testSrc;
|
||||
try { testSrc = readFileSync(testPath, "utf8"); } catch { return sources; }
|
||||
|
||||
// Static imports: import { ... } from "../foo.js"
|
||||
const staticRe = /import\s+(?:type\s+)?[\w{},\s*]*\s*from\s*"(\.\.?\/[^"]+\.js)"/g;
|
||||
let m;
|
||||
while ((m = staticRe.exec(testSrc)) !== null) {
|
||||
const resolved = resolve(testDir, m[1].replace(/\.js$/, ".ts"));
|
||||
if (existsSync(resolved)) sources.add(resolved);
|
||||
}
|
||||
|
||||
// Dynamic imports: await import("../foo.js")
|
||||
const dynRe = /import\(\s*"(\.\.?\/[^"]+\.js)"\s*\)/g;
|
||||
while ((m = dynRe.exec(testSrc)) !== null) {
|
||||
const resolved = resolve(testDir, m[1].replace(/\.js$/, ".ts"));
|
||||
if (existsSync(resolved)) sources.add(resolved);
|
||||
}
|
||||
|
||||
// Convention: __tests__/foo.test.ts → ../foo.ts
|
||||
const noTests = testPath.replace(/__tests\//, "");
|
||||
const convPath = noTests.replace(/\.test\.ts$/, ".ts");
|
||||
if (existsSync(convPath)) sources.add(convPath);
|
||||
|
||||
// bin.test.ts special case
|
||||
if (testPath.endsWith("__tests__/bin.test.ts")) {
|
||||
const binPath = join(cliSrc, "bin.ts");
|
||||
if (existsSync(binPath)) sources.add(binPath);
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
function extractMockKeys(testSrc, moduleName, testPath) {
|
||||
const escaped = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
if (
|
||||
new RegExp(`vi\\.mock\\(\\s*"${escaped}"[^)]*importOriginal`).test(testSrc) ||
|
||||
new RegExp(`vi\\.mock\\(\\s*"${escaped}"[^)]*importActual`).test(testSrc)
|
||||
) {
|
||||
return null; // auto-spread, safe
|
||||
}
|
||||
|
||||
const startRe = new RegExp(
|
||||
`vi\\.mock\\(\\s*"${escaped}"\\s*,\\s*\\([^)]*\\)\\s*=>\\s*\\(\\s*\\{`,
|
||||
);
|
||||
const startMatch = startRe.exec(testSrc);
|
||||
if (!startMatch) return null;
|
||||
|
||||
const bodyStart = startMatch.index + startMatch[0].length;
|
||||
let depth = 1;
|
||||
let i = bodyStart;
|
||||
while (i < testSrc.length && depth > 0) {
|
||||
const ch = testSrc[i];
|
||||
if (ch === "{") depth++;
|
||||
else if (ch === "}") depth--;
|
||||
i++;
|
||||
}
|
||||
const body = testSrc.slice(bodyStart, i - 1);
|
||||
|
||||
const keys = new Set();
|
||||
const keyRe = /(?:^|\n)\s*([A-Za-z_$][\w$]*)\s*(?::)/g;
|
||||
let km;
|
||||
while ((km = keyRe.exec(body)) !== null) {
|
||||
keys.add(km[1]);
|
||||
}
|
||||
|
||||
// FNXC:TestInfrastructure 2026-07-13-10:15:
|
||||
// Resolve spread helpers: when the mock body contains `...someHelper`,
|
||||
// find the import for that helper, read its exported object, and add its keys.
|
||||
// This handles centralized mock helpers like `workflowAuthoringEngineMock`.
|
||||
const testDir = dirname(testPath);
|
||||
const spreadRe = /\.\.\.\s*([A-Za-z_$][\w$]*)/g;
|
||||
let sm;
|
||||
while ((sm = spreadRe.exec(body)) !== null) {
|
||||
const helperName = sm[1];
|
||||
const importRe = new RegExp(
|
||||
`import\\s*\\{[^}]*\\b${helperName}\\b[^}]*\\}\\s*from\\s*"([^"]+)"`,
|
||||
);
|
||||
const importMatch = importRe.exec(testSrc);
|
||||
if (!importMatch) continue;
|
||||
const helperPath = resolve(testDir, importMatch[1].replace(/\.js$/, ".ts"));
|
||||
if (!existsSync(helperPath)) continue;
|
||||
const helperSrc = readFileSync(helperPath, "utf8");
|
||||
const objStartRe = new RegExp(`export\\s+const\\s+${helperName}\\s*=\\s*\\{`);
|
||||
const objStart = objStartRe.exec(helperSrc);
|
||||
if (!objStart) continue;
|
||||
let depth2 = 1;
|
||||
let j = objStart.index + objStart[0].length;
|
||||
while (j < helperSrc.length && depth2 > 0) {
|
||||
if (helperSrc[j] === "{") depth2++;
|
||||
else if (helperSrc[j] === "}") depth2--;
|
||||
j++;
|
||||
}
|
||||
const helperBody = helperSrc.slice(objStart.index + objStart[0].length, j - 1);
|
||||
const helperKeyRe = /(?:^|\n)\s*([A-Za-z_$][\w$]*)\s*(?::)/g;
|
||||
let hkm;
|
||||
while ((hkm = helperKeyRe.exec(helperBody)) !== null) {
|
||||
keys.add(hkm[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function collectTs(dir) {
|
||||
let out = [];
|
||||
try {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) out = out.concat(collectTs(full));
|
||||
else if (entry.endsWith(".ts")) out.push(full);
|
||||
}
|
||||
} catch { /* dir may not exist */ }
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Run ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
let totalErrors = 0;
|
||||
|
||||
for (const cfg of BARRELS) {
|
||||
if (!existsSync(cfg.barrelPath)) continue;
|
||||
|
||||
const barrelSrc = readFileSync(cfg.barrelPath, "utf8");
|
||||
const barrelExports = extractBarrelExports(barrelSrc);
|
||||
|
||||
const testFiles = new Set();
|
||||
for (const dir of cfg.testDirs) {
|
||||
for (const f of collectTs(dir)) {
|
||||
if (f.endsWith(".test.ts")) testFiles.add(f);
|
||||
}
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
|
||||
for (const testFile of testFiles) {
|
||||
const testSrc = readFileSync(testFile, "utf8");
|
||||
if (!testSrc.includes(`vi.mock("${cfg.moduleName}"`)) continue;
|
||||
|
||||
const mockKeys = extractMockKeys(testSrc, cfg.moduleName, testFile);
|
||||
if (mockKeys === null) continue;
|
||||
|
||||
const sourcePaths = resolveSourceFiles(testFile, cfg.cliSrc);
|
||||
const requiredExports = new Set();
|
||||
|
||||
for (const srcPath of sourcePaths) {
|
||||
const used = extractModuleUsage(srcPath, cfg.moduleName);
|
||||
for (const e of used) {
|
||||
if (barrelExports.has(e)) requiredExports.add(e);
|
||||
}
|
||||
}
|
||||
|
||||
const missing = [...requiredExports].filter((e) => !mockKeys.has(e));
|
||||
if (missing.length > 0) {
|
||||
const rel = testFile.replace(root + "/", "");
|
||||
errors.push(
|
||||
` ${rel}\n missing: ${missing.map((m) => `"${m}"`).join(", ")}` +
|
||||
`\n (imported from ${cfg.moduleName} in source, absent from vi.mock factory)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
`\n❌ ${cfg.moduleName} mock completeness check failed (${errors.length} issue${errors.length > 1 ? "s" : ""}):\n`,
|
||||
);
|
||||
for (const e of errors) console.error(e + "\n");
|
||||
console.error(
|
||||
`Fix: add the missing export(s) to each vi.mock("${cfg.moduleName}") factory,\n` +
|
||||
`or convert the mock to use importOriginal spread: { ...await importOriginal(), onlyOverride: vi.fn() }`,
|
||||
);
|
||||
totalErrors += errors.length;
|
||||
} else {
|
||||
console.log(`✅ ${cfg.moduleName} mock completeness: all hardcoded mocks cover source imports.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (totalErrors > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user