## Summary
**Structural fix** for the recurring full-suite failure pattern where a
new `@fusion/dashboard` barrel export is imported by CLI source code but
missing from the hardcoded `vi.mock("@fusion/dashboard")` factory in CLI
tests.
## What's new
### Gate check script:
`scripts/check-cli-dashboard-mock-completeness.mjs`
Added to the merge gate (`pnpm test:gate`). Statically validates that
every hardcoded `vi.mock("@fusion/dashboard")` factory in CLI tests
includes all `@fusion/dashboard` exports that the corresponding source
files import.
- Pure static analysis (regex + depth-aware brace tracking) — no module
evaluation, <0.1s
- Handles named imports (`import { foo } from "@fusion/dashboard"`) AND
namespace imports (`import * as dashboard from "@fusion/dashboard"` →
scans `dashboard.X` usages)
- Filters against the real barrel exports to avoid false positives from
typos
- Resolves test→source mapping by parsing static/dynamic imports in the
test file (not just naming convention)
**Result:** the next time someone adds `export { newFunc } from
"./mod.js"` to `dashboard/src/index.ts` and `cli/src/commands/daemon.ts`
imports it, the gate catches the missing mock before merge instead of
the full-suite failing on main.
### Completed all 9 incomplete CLI dashboard mocks
Added the missing exports identified by the check:
| File | Missing exports added |
|---|---|
| `daemon.test.ts` | `registerGithubTrackingHook` |
| `serve.test.ts` | `registerGithubTrackingHook` |
| `dashboard.test.ts` | `AttachTicketStore`, `CliInputAttributionLog`,
`CliConfirmAdvanceRegistry`, `CliRelaunchRegistry`,
`registerGithubTrackingHook` |
| `task.test.ts` | `registerGithubTrackingHook`, `GitLabClient`,
`resolveGitlabAuth`, `buildGitLabTaskProvenance`,
`isGitLabAlreadyImported`, `buildGitLabTaskDescription` |
| `extension-*.test.ts` (×4) | `GitLabClient`, `resolveGitlabAuth`,
`buildGitLabTaskProvenance`, `isGitLabAlreadyImported`,
`buildGitLabTaskDescription` |
| `task-command-github-import-tracking.test.ts` | Same GitLab exports |
These were latent issues — the mocks were incomplete but tests passed
because the missing exports weren't called during test execution. Any
test change that exercises those code paths would have broken.
## Why not `importActual` spread?
Tried converting daemon.test.ts to `vi.mock("@fusion/dashboard", async
(importOriginal) => { ... })` — fails because the barrel's `export *
from "./plugins/index.js"` transitively imports
`@agentclientprotocol/sdk` which isn't available at test evaluation
time. The static check approach avoids this entirely.
## Verification
- `pnpm test:gate`: exit 0 (includes new check)
- `pnpm lint`: exit 0
- CLI tests: daemon 21/21, serve 58/58, dashboard 91/91, task 149/149 ✅
- Gate script: `✅ CLI dashboard mock completeness: all hardcoded mocks
cover source imports.`
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- **Tests**
- Added automated validation to ensure CLI test mocks remain aligned
with available dashboard functionality.
- Updated test coverage setup so GitHub, GitLab, daemon, dashboard,
server, and task scenarios use complete dashboard mocks.
- Test verification now reports missing mocked functionality and blocks
the release gate when inconsistencies are detected.
- **Chores**
- Improved reliability and maintainability of automated verification for
CLI and dashboard integrations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
182 lines
6.0 KiB
TypeScript
182 lines
6.0 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { TaskStore, setTaskCreatedHook } from "@fusion/core";
|
|
import { runGhJsonAsync } from "@fusion/core/gh-cli";
|
|
import { workflowAuthoringEngineMock } from "./helpers/engine-workflow-authoring-mock.js";
|
|
|
|
const hookSpy = vi.hoisted(() => vi.fn(async () => {}));
|
|
const registerGithubTrackingHookMock = vi.hoisted(() => vi.fn(() => {
|
|
setTaskCreatedHook(async (task, store) => {
|
|
try {
|
|
await hookSpy(task, store);
|
|
} catch {
|
|
// Best-effort, mirrors real dashboard hook contract.
|
|
}
|
|
});
|
|
}));
|
|
|
|
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).
|
|
GitLabClient: vi.fn(),
|
|
resolveGitlabAuth: vi.fn(() => ({})),
|
|
buildGitLabTaskProvenance: vi.fn(() => ({})),
|
|
isGitLabAlreadyImported: vi.fn(),
|
|
buildGitLabTaskDescription: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@fusion/core/gh-cli", () => ({
|
|
isGhAvailable: vi.fn(() => true),
|
|
isGhAuthenticated: vi.fn(() => true),
|
|
runGhJsonAsync: vi.fn(),
|
|
getGhErrorMessage: vi.fn((error: unknown) => (error instanceof Error ? error.message : String(error))),
|
|
}));
|
|
|
|
vi.mock("@fusion/engine", () => ({
|
|
...workflowAuthoringEngineMock,
|
|
createFnAgent: vi.fn(),
|
|
fetchWebContent: vi.fn(),
|
|
assertNoSecretPlaintext: vi.fn(),
|
|
emitGoalRetrievalAudit: vi.fn(),
|
|
createWorkflowAuthoringTools: vi.fn(() => ({})),
|
|
workflowListParams: {},
|
|
workflowGetParams: {},
|
|
workflowSelectParams: {},
|
|
workflowCreateParams: {},
|
|
workflowUpdateParams: {},
|
|
workflowDeleteParams: {},
|
|
workflowSettingsParams: {},
|
|
traitListParams: {},
|
|
}));
|
|
|
|
async function loadExtension() {
|
|
const mod = await import("../extension.js");
|
|
return mod.default;
|
|
}
|
|
|
|
describe("extension github tracking hook wiring", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
setTaskCreatedHook(undefined);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
setTaskCreatedHook(undefined);
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("fn_task_create triggers registered task-created hook exactly once", async () => {
|
|
const repoRoot = await mkdtemp(join(tmpdir(), "fn-5057-extension-gh-"));
|
|
const cwd = join(repoRoot, ".worktrees", "feature");
|
|
try {
|
|
await mkdir(join(repoRoot, ".fusion"), { recursive: true });
|
|
|
|
const extension = await loadExtension();
|
|
const tools = new Map<string, any>();
|
|
extension({
|
|
registerTool: (def: any) => tools.set(def.name, def),
|
|
registerCommand: vi.fn(),
|
|
registerShortcut: vi.fn(),
|
|
registerFlag: vi.fn(),
|
|
on: vi.fn(),
|
|
} as any);
|
|
|
|
extension({
|
|
registerTool: (def: any) => tools.set(def.name, def),
|
|
registerCommand: vi.fn(),
|
|
registerShortcut: vi.fn(),
|
|
registerFlag: vi.fn(),
|
|
on: vi.fn(),
|
|
} as any);
|
|
|
|
expect(registerGithubTrackingHookMock).toHaveBeenCalledTimes(2);
|
|
|
|
const tool = tools.get("fn_task_create");
|
|
const taskStore = new TaskStore(repoRoot, undefined, { inMemoryDb: false });
|
|
await taskStore.init();
|
|
await taskStore.updateSettings({
|
|
githubTrackingEnabledByDefault: true,
|
|
githubTrackingDefaultRepo: "owner/repo",
|
|
});
|
|
|
|
const result = await tool.execute(
|
|
"call-1",
|
|
{ description: "extension-created task" },
|
|
undefined,
|
|
undefined,
|
|
{ cwd },
|
|
);
|
|
|
|
expect(result.details?.taskId).toMatch(/^FN-/);
|
|
expect(hookSpy).toHaveBeenCalledTimes(1);
|
|
expect(hookSpy.mock.calls[0]?.[0]).toEqual(
|
|
expect.objectContaining({ id: result.details.taskId }),
|
|
);
|
|
|
|
const persisted = await taskStore.getTask(result.details.taskId);
|
|
expect(persisted).toBeTruthy();
|
|
expect(persisted?.githubTracking?.enabled).toBe(true);
|
|
taskStore.close();
|
|
} finally {
|
|
await rm(repoRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("fn_task_import_github_issue creates a tracked source issue task when tracking defaults are on", async () => {
|
|
const repoRoot = await mkdtemp(join(tmpdir(), "fn-7090-extension-gh-import-"));
|
|
const cwd = join(repoRoot, ".worktrees", "feature");
|
|
try {
|
|
await mkdir(join(repoRoot, ".fusion"), { recursive: true });
|
|
|
|
const extension = await loadExtension();
|
|
const tools = new Map<string, any>();
|
|
extension({
|
|
registerTool: (def: any) => tools.set(def.name, def),
|
|
registerCommand: vi.fn(),
|
|
registerShortcut: vi.fn(),
|
|
registerFlag: vi.fn(),
|
|
on: vi.fn(),
|
|
} as any);
|
|
|
|
const taskStore = new TaskStore(repoRoot, undefined, { inMemoryDb: false });
|
|
await taskStore.init();
|
|
await taskStore.updateSettings({ githubTrackingEnabledByDefault: true });
|
|
vi.mocked(runGhJsonAsync).mockResolvedValueOnce({
|
|
number: 123,
|
|
title: "Imported issue",
|
|
body: "Imported issue body",
|
|
html_url: "https://github.com/upstream/repo/issues/123",
|
|
} as never);
|
|
|
|
const result = await tools.get("fn_task_import_github_issue").execute(
|
|
"import-1",
|
|
{ owner: "upstream", repo: "repo", issueNumber: 123 },
|
|
undefined,
|
|
undefined,
|
|
{ cwd },
|
|
);
|
|
|
|
const persisted = await taskStore.getTask(result.details.taskId);
|
|
expect(persisted?.githubTracking?.enabled).toBe(true);
|
|
expect(persisted?.sourceIssue).toEqual(expect.objectContaining({
|
|
provider: "github",
|
|
repository: "upstream/repo",
|
|
issueNumber: 123,
|
|
}));
|
|
expect(hookSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: result.details.taskId,
|
|
githubTracking: { enabled: true },
|
|
sourceIssue: expect.objectContaining({ issueNumber: 123 }),
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
taskStore.close();
|
|
} finally {
|
|
await rm(repoRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|