Files
fusion/packages/cli/src/__tests__/extension-github-tracking.test.ts
gsxdsm c8999369c3 feat: add gate check for CLI dashboard mock completeness — prevents recurring full-suite barrel-export drift (#2035)
## 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 -->
2026-07-12 23:09:50 -07:00

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 });
}
});
});