Files
fusion/packages/engine/src/__tests__/executor-runtime-env.test.ts
gsxdsm bbaa254dc3 test: add the missing debug to 27 logger mocks (206 → 4 failures) (#2573)
**Test-infrastructure fix.** 29 test files. No production code, no
altered assertions, no widened timeouts.

Now **3 commits** (#2584 merged into this branch): the logger-mock
sweep, a cron-runner follow-up from review, and the 4 residual failures
the sweep deliberately deferred.

**Whole branch: 764 tests, 0 failures** across the touched set.

---

## Commit 1 — the missing `debug` on 27 logger mocks

`createLogger`'s real shape is `{ log, debug, warn, error }`. 27 engine
test files mock `../logger.js` with logger-shaped literals that **omit
`debug`**, so any production path reaching `log.debug` threw:

```
TypeError: schedulerLog.debug is not a function
TypeError: runtimeLog.debug is not a function
TypeError: log.debug is not a function      (SelfHealingManager.start)
```

Measured, same commit, same 27 files:

| | Failed | Passed |
|---|---|---|
| before | **206** | 558 |
| after | **4** | 760 |

**202 failures fixed by one missing mock export.** Per-file: `notifier`
36→0, `plugin-runner` 56→0, `grok-runtime-routing` 14→0,
`self-healing-completion-fanout` 1→0. That last one also leaked an
unhandled rejection out of `startMaintenance`, which vitest warns "might
cause false positive tests" elsewhere in the file.

*A note on the number:* a full `engine-default` run went 283 → 106
across my two sessions, but `main` moved in between (U11 landed), so
that spread is **not** attributable here. 206 → 4 is the honest figure:
same commit, same file set, only this diff varying.

## Commit 2 — cron-runner's factory (greptile P1)

My regex required `log: vi.fn()`; `cron-runner.test.ts` uses `log:
cronLoggerSpies.log`, so the `createLogger` factory's returned literal
never matched and the logger production received still lacked `debug`.

**Measured before claiming a live fix, and the numbers don't support
that part:** `cronLoggerSpies.debug.mock.calls.length` is **0** across
all 155 tests, and the suite is 155 passed both before and after. The
described failure mode — `tick()` hitting `log.debug`, throwing, and
being swallowed by its own error handler — is **not reachable today**,
because no test exercises those three branches (`cron-runner.ts:377`,
`:385`, `:410`). The fix is defensive, not curative. The real gap it
surfaced is **missing coverage** for schedule dedupe / scope mismatch /
lost atomic claim, which I did not write blind to close a thread.

## Commit 3 — the 4 residuals

**`notification-service` (3):** messages moved to DEBUG in production
(`:580`, `:846`) while tests asserted `schedulerLog.log`.

The token case needed more than a relocation. It asserted
`expect(schedulerLog.log).not.toHaveBeenCalledWith(containing("new-token"))`.
Moving only the *positive* assertion to `debug` would leave the secrecy
check watching a channel the message no longer uses — a token could leak
through `debug` and the test would still pass. The negative now runs
across all four channels. **Verified it bites:** interpolating the token
into the debug line fails the test.

**`openclaw-runtime-integration` (1):** `../pi.js` mock missing
`wrapToolsWithOutputBudget` (same class as #2547); this suite exercises
a non-pi runtime, exactly where that wrapper applies.

**Not swept repo-wide, and the measurement is why.** 37 `pi.js` mocks
omit that export. Patching 30 moved the set from **11 failed to 10** —
thirty files of churn for one test. Reverted. Commit 1 earned its
27-file diff with 202 fixes; this one earned nothing, and a no-op sweep
is just future merge conflicts for other workers on this program.

---

## Why none of this is appeasement

AGENTS.md forbids making a red test pass by loosening it. This does the
opposite: the mocks were **wrong** — they claimed to stand in for
`createLogger` while missing part of its interface. Nothing was relaxed;
stubs were completed, and the one assertion I did move got **stronger**
(four channels instead of one).

## Also deliberately not done

Extending `scripts/check-mock-completeness.mjs` to catch this class.
Measured first: a naive rule over relative intra-package mocks flags
**147** factories of which **146 are green** — almost pure false
positives. The barrel heuristic works because `cliSrc` gives a tight
import surface; that doesn't transfer. A gate that noisy gets ignored,
which is worse than no gate.

## How this was found

While characterizing U9's review lane. These files were pre-existing
baseline noise under mutation runs — and that noise is exactly what made
my own safeguard baseline (#2511, corrected in #2520) report two false
verdicts. **A red suite does not merely lack coverage; it makes every
nearby measurement untrustworthy.**

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:50:46 -07:00

145 lines
5.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import type { FusionPlugin, PluginLoader, PluginStore, TaskStore } from "@fusion/core";
import { PluginRunner } from "../plugin-runner.js";
import { createLogger } from "../logger.js";
vi.mock("../logger.js", () => ({
createLogger: vi.fn(() => ({ log: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() })),
executorLog: { log: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
describe("PluginRunner.collectExecutorRuntimeEnv", () => {
const createMockPlugin = (id: string, executorRuntimeEnv?: FusionPlugin["executorRuntimeEnv"]): FusionPlugin => ({
manifest: { id, name: id, version: "1.0.0" },
state: "started",
hooks: {},
executorRuntimeEnv,
});
const createRunner = (plugins: FusionPlugin[]) => {
const pluginLoader = {
getLoadedPlugins: vi.fn().mockReturnValue(plugins),
getPlugin: vi.fn(),
getPluginTools: vi.fn().mockReturnValue([]),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPluginUiSlots: vi.fn().mockReturnValue([]),
getPluginUiContributions: vi.fn().mockReturnValue([]),
getPluginRuntimes: vi.fn().mockReturnValue([]),
getCliProviderContributions: vi.fn().mockReturnValue([]),
getPluginSkills: vi.fn().mockReturnValue([]),
getPluginWorkflowSteps: vi.fn().mockReturnValue([]),
getPluginWorkflowStepTemplates: vi.fn().mockReturnValue([]),
getPluginPromptContributions: vi.fn().mockReturnValue([]),
getPluginSetupInfo: vi.fn().mockReturnValue([]),
on: vi.fn(),
off: vi.fn(),
invokeHook: vi.fn(),
loadAllPlugins: vi.fn(),
stopAllPlugins: vi.fn(),
getPluginSchemaInitHooks: vi.fn().mockReturnValue([]),
checkPluginSetup: vi.fn(),
installPluginSetup: vi.fn(),
uninstallPluginSetup: vi.fn(),
loadPlugin: vi.fn(),
stopPlugin: vi.fn(),
reloadPlugin: vi.fn(),
} as unknown as PluginLoader;
const pluginStore = {
getPlugin: vi.fn(async (pluginId: string) => ({ id: pluginId, settings: {} })),
on: vi.fn(),
off: vi.fn(),
} as unknown as PluginStore;
const taskStore = {
on: vi.fn(),
off: vi.fn(),
getDatabase: vi.fn(),
} as unknown as TaskStore;
return new PluginRunner({ pluginLoader, pluginStore, taskStore, rootDir: "/repo" });
};
beforeEach(() => {
vi.clearAllMocks();
});
it("returns empty env/path when no plugins contribute", async () => {
const runner = createRunner([]);
const result = await runner.collectExecutorRuntimeEnv({ taskId: "FN-1", worktreePath: "/tmp/wt", rootDir: "/repo" });
expect(result).toEqual({ env: {}, pathPrepend: [], perPluginErrors: [] });
});
it("collects env/path from one plugin", async () => {
const runner = createRunner([
createMockPlugin("plugin-a", () => ({ pathPrepend: ["/opt/plugin-a/bin"], env: { A_TOKEN: "a" } })),
]);
const result = await runner.collectExecutorRuntimeEnv({ taskId: "FN-1", worktreePath: "/tmp/wt", rootDir: "/repo" });
expect(result.env).toEqual({ A_TOKEN: "a" });
expect(result.pathPrepend).toEqual(["/opt/plugin-a/bin"]);
expect(result.perPluginErrors).toEqual([]);
});
it("merges plugins with later env overriding and later path entries first", async () => {
const runner = createRunner([
createMockPlugin("plugin-a", () => ({ pathPrepend: ["/opt/a/bin"], env: { SHARED: "a", A_ONLY: "1" } })),
createMockPlugin("plugin-b", () => ({ pathPrepend: ["/opt/b/bin"], env: { SHARED: "b", B_ONLY: "1" } })),
]);
const result = await runner.collectExecutorRuntimeEnv({ taskId: "FN-1", worktreePath: "/tmp/wt", rootDir: "/repo" });
expect(result.env).toEqual({ SHARED: "b", A_ONLY: "1", B_ONLY: "1" });
expect(result.pathPrepend).toEqual(["/opt/b/bin", "/opt/a/bin"]);
});
it("records per-plugin errors when plugin throws", async () => {
const runner = createRunner([
createMockPlugin("plugin-ok", () => ({ env: { OK: "1" } })),
createMockPlugin("plugin-bad", () => {
throw new Error("boom");
}),
]);
const result = await runner.collectExecutorRuntimeEnv({ taskId: "FN-1", worktreePath: "/tmp/wt", rootDir: "/repo" });
expect(result.env).toEqual({ OK: "1" });
expect(result.perPluginErrors).toHaveLength(1);
expect(result.perPluginErrors[0]?.pluginId).toBe("plugin-bad");
expect(result.perPluginErrors[0]?.error.message).toContain("boom");
});
it("records schema errors for invalid contribution shapes", async () => {
const runner = createRunner([
createMockPlugin("plugin-path", () => ({ pathPrepend: ["relative/bin"] })),
createMockPlugin("plugin-path-env", () => ({ env: { PATH: "forbidden" } })),
createMockPlugin("plugin-value", () => ({ env: { GOOD: "ok", BAD: 42 as unknown as string } })),
]);
const result = await runner.collectExecutorRuntimeEnv({ taskId: "FN-1", worktreePath: "/tmp/wt", rootDir: "/repo" });
expect(result.env).toEqual({});
expect(result.pathPrepend).toEqual([]);
expect(result.perPluginErrors).toHaveLength(3);
expect(result.perPluginErrors.map((item) => item.pluginId)).toEqual([
"plugin-path",
"plugin-path-env",
"plugin-value",
]);
});
it("logs warnings when env keys are overridden", async () => {
const runner = createRunner([
createMockPlugin("plugin-a", () => ({ env: { SHARED: "a" } })),
createMockPlugin("plugin-b", () => ({ env: { SHARED: "b" } })),
]);
await runner.collectExecutorRuntimeEnv({ taskId: "FN-1", worktreePath: "/tmp/wt", rootDir: "/repo" });
const logger = vi.mocked(createLogger).mock.results.at(-1)?.value as { warn: ReturnType<typeof vi.fn> };
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("key override: SHARED"));
});
});