From 301ae7f0d3e8ab87ee75fa250377a48d8a4b0bd3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 18 Jun 2026 06:56:36 -0700 Subject: [PATCH] FN-6636: rescue Paperclip spawn tests Rescue the Paperclip CLI spawn coverage by making fake child events deterministic and re-enabling the suite. - Route mintAgentApiKeyViaCli cases through the shared fake spawn helper so close and error events are emitted after listener registration. - Document the async import/listener-order requirement that caused the quarantine. - Remove paperclip-client.test.ts from the quarantine ledger and Vitest exclude list so the rescued tests run. Files changed: .../src/__tests__/paperclip-client.test.ts | 134 +++++++-------------- .../vitest.config.ts | 2 - scripts/lib/test-quarantine.json | 5 - 3 files changed, 44 insertions(+), 97 deletions(-) Fusion-Task-Id: FN-6636 Fusion-Task-Lineage: 79038ab3-0aa6-465e-9c6e-f6bc83fcd407 --- .../src/__tests__/paperclip-client.test.ts | 134 ++++++------------ .../vitest.config.ts | 2 - scripts/lib/test-quarantine.json | 5 - 3 files changed, 44 insertions(+), 97 deletions(-) diff --git a/plugins/fusion-plugin-paperclip-runtime/src/__tests__/paperclip-client.test.ts b/plugins/fusion-plugin-paperclip-runtime/src/__tests__/paperclip-client.test.ts index d85a3cf0e6..19132270a9 100644 --- a/plugins/fusion-plugin-paperclip-runtime/src/__tests__/paperclip-client.test.ts +++ b/plugins/fusion-plugin-paperclip-runtime/src/__tests__/paperclip-client.test.ts @@ -385,7 +385,11 @@ describe("probePaperclipConnection", () => { // --------------------------------------------------------------------------- describe("mintAgentApiKeyViaCli", () => { - // We mock node:child_process.spawn for each case. + /* + * FNXC:PaperclipRuntimeTests 2026-06-18-06:31: + * Spawn-backed tests must emit fake child `close`/`error` events only after the production Promise attaches listeners. + * `mintAgentApiKeyViaCli` awaits the dynamic `node:child_process` import before listener registration, so bare `setImmediate` emits can be lost and produce 5000ms timeouts or unhandled ENOENT errors. + */ it("success path — parses apiKey from JSON", async () => { const mockPayload = { @@ -394,105 +398,55 @@ describe("mintAgentApiKeyViaCli", () => { agentId: "AG-1", companyId: "CO-1", }; - const { EventEmitter } = await import("node:events"); - const { Readable } = await import("node:stream"); - const fakeStdout = Readable.from([Buffer.from(JSON.stringify(mockPayload))]); - const fakeStderr = Readable.from([]); - const fakeChild = new EventEmitter() as ReturnType; - (fakeChild as unknown as Record).stdout = fakeStdout; - (fakeChild as unknown as Record).stderr = fakeStderr; - (fakeChild as unknown as Record).kill = vi.fn(); - - const spawnMock = vi.fn().mockReturnValue(fakeChild); - vi.doMock("node:child_process", () => ({ spawn: spawnMock })); - - // Emit close asynchronously after mock is in place - setImmediate(() => { - fakeChild.emit("close", 0); - }); - - const result = await mintAgentApiKeyViaCli({ agentRef: "my-agent", companyId: "CO-1" }); - expect(result.apiKey).toBe("sk-test-mint-key"); - expect(result.apiBase).toBe("http://localhost:3100"); - expect(result.agentId).toBe("AG-1"); - expect(result.companyId).toBe("CO-1"); - - vi.doUnmock("node:child_process"); + await withFakeSpawn( + { stdoutChunks: [JSON.stringify(mockPayload)], stderrChunks: [], exitCode: 0 }, + async () => { + const result = await mintAgentApiKeyViaCli({ agentRef: "my-agent", companyId: "CO-1" }); + expect(result.apiKey).toBe("sk-test-mint-key"); + expect(result.apiBase).toBe("http://localhost:3100"); + expect(result.agentId).toBe("AG-1"); + expect(result.companyId).toBe("CO-1"); + }, + ); }); it("ENOENT on spawn error → throws with install hint", async () => { - const { EventEmitter } = await import("node:events"); - const { Readable } = await import("node:stream"); - - const fakeChild = new EventEmitter() as ReturnType; - (fakeChild as unknown as Record).stdout = Readable.from([]); - (fakeChild as unknown as Record).stderr = Readable.from([]); - (fakeChild as unknown as Record).kill = vi.fn(); - - const spawnMock = vi.fn().mockReturnValue(fakeChild); - vi.doMock("node:child_process", () => ({ spawn: spawnMock })); - - setImmediate(() => { - const err = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }); - fakeChild.emit("error", err); - }); - - await expect( - mintAgentApiKeyViaCli({ agentRef: "my-agent", cliBinaryPath: "/usr/local/bin/paperclipai", companyId: "CO-1" }), - ).rejects.toThrow(/binary not found.*npm i -g paperclipai/i); - - vi.doUnmock("node:child_process"); + await withFakeSpawn( + { + stdoutChunks: [], + stderrChunks: [], + exitCode: null, + errorOnSpawn: Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }), + }, + async () => { + await expect( + mintAgentApiKeyViaCli({ agentRef: "my-agent", cliBinaryPath: "/usr/local/bin/paperclipai", companyId: "CO-1" }), + ).rejects.toThrow(/binary not found.*npm i -g paperclipai/i); + }, + ); }); it("non-zero exit → throws with stderr hint", async () => { - const { EventEmitter } = await import("node:events"); - const { Readable } = await import("node:stream"); - - const fakeStdout = Readable.from([]); - const fakeStderr = Readable.from([Buffer.from("Error: CLI is not authenticated\n")]); - const fakeChild = new EventEmitter() as ReturnType; - (fakeChild as unknown as Record).stdout = fakeStdout; - (fakeChild as unknown as Record).stderr = fakeStderr; - (fakeChild as unknown as Record).kill = vi.fn(); - - const spawnMock = vi.fn().mockReturnValue(fakeChild); - vi.doMock("node:child_process", () => ({ spawn: spawnMock })); - - setImmediate(() => { - fakeChild.emit("close", 1); - }); - - await expect( - mintAgentApiKeyViaCli({ agentRef: "my-agent", companyId: "CO-1" }), - ).rejects.toThrow(/exited 1.*paperclipai onboard/i); - - vi.doUnmock("node:child_process"); + await withFakeSpawn( + { stdoutChunks: [], stderrChunks: ["Error: CLI is not authenticated\n"], exitCode: 1 }, + async () => { + await expect( + mintAgentApiKeyViaCli({ agentRef: "my-agent", companyId: "CO-1" }), + ).rejects.toThrow(/exited 1.*paperclipai onboard/i); + }, + ); }); it("malformed JSON output → throws", async () => { - const { EventEmitter } = await import("node:events"); - const { Readable } = await import("node:stream"); - - const fakeStdout = Readable.from([Buffer.from("not-json-at-all")]); - const fakeStderr = Readable.from([]); - const fakeChild = new EventEmitter() as ReturnType; - (fakeChild as unknown as Record).stdout = fakeStdout; - (fakeChild as unknown as Record).stderr = fakeStderr; - (fakeChild as unknown as Record).kill = vi.fn(); - - const spawnMock = vi.fn().mockReturnValue(fakeChild); - vi.doMock("node:child_process", () => ({ spawn: spawnMock })); - - setImmediate(() => { - fakeChild.emit("close", 0); - }); - - await expect( - mintAgentApiKeyViaCli({ agentRef: "my-agent", companyId: "CO-1" }), - ).rejects.toThrow(/non-JSON output/i); - - vi.doUnmock("node:child_process"); + await withFakeSpawn( + { stdoutChunks: ["not-json-at-all"], stderrChunks: [], exitCode: 0 }, + async () => { + await expect( + mintAgentApiKeyViaCli({ agentRef: "my-agent", companyId: "CO-1" }), + ).rejects.toThrow(/non-JSON output/i); + }, + ); }); }); diff --git a/plugins/fusion-plugin-paperclip-runtime/vitest.config.ts b/plugins/fusion-plugin-paperclip-runtime/vitest.config.ts index 92fb277b11..caf5c70616 100644 --- a/plugins/fusion-plugin-paperclip-runtime/vitest.config.ts +++ b/plugins/fusion-plugin-paperclip-runtime/vitest.config.ts @@ -13,8 +13,6 @@ export default defineConfig({ }, test: { include: ["src/**/*.test.ts"], - /* FNXC:PaperclipRuntimeTests 2026-06-18-06:11: The broad FN-6631 gate exposed unrelated Paperclip CLI spawn-mock timeouts; exclude the file under the deletion-ratchet quarantine until the runtime owner replaces the flaky mock seam. */ - exclude: ["src/__tests__/paperclip-client.test.ts"], setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))], globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))], pool: "threads", diff --git a/scripts/lib/test-quarantine.json b/scripts/lib/test-quarantine.json index bbaad8bfdc..c729235e58 100644 --- a/scripts/lib/test-quarantine.json +++ b/scripts/lib/test-quarantine.json @@ -1,11 +1,6 @@ { "$comment": "Flaky-test quarantine ledger (deletion ratchet — see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date — the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config exclude is the mechanism, and the sweep is policy executed by whoever touches the suite.", "entries": [ - { - "file": "plugins/fusion-plugin-paperclip-runtime/src/__tests__/paperclip-client.test.ts", - "reason": "FN-6631 broad `pnpm test` run observed unrelated Paperclip CLI spawn-mock timeouts and unhandled ENOENT errors in mintAgentApiKeyViaCli coverage; targeted Command Center/core checks, lint, typecheck, build, and engine tests passed. Quarantined per deletion-ratchet policy pending Paperclip runtime owner rescue.", - "quarantinedAt": "2026-06-18" - }, { "file": "packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx", "reason": "FN-6633 workspace pnpm test observed focus-restoration assertion flake after this task's targeted chat-manager coverage passed; unrelated dashboard jsdom focus race, quarantined under deletion ratchet instead of appeasement.",