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
This commit is contained in:
@@ -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<typeof import("node:child_process").spawn>;
|
||||
(fakeChild as unknown as Record<string, unknown>).stdout = fakeStdout;
|
||||
(fakeChild as unknown as Record<string, unknown>).stderr = fakeStderr;
|
||||
(fakeChild as unknown as Record<string, unknown>).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<typeof import("node:child_process").spawn>;
|
||||
(fakeChild as unknown as Record<string, unknown>).stdout = Readable.from([]);
|
||||
(fakeChild as unknown as Record<string, unknown>).stderr = Readable.from([]);
|
||||
(fakeChild as unknown as Record<string, unknown>).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<typeof import("node:child_process").spawn>;
|
||||
(fakeChild as unknown as Record<string, unknown>).stdout = fakeStdout;
|
||||
(fakeChild as unknown as Record<string, unknown>).stderr = fakeStderr;
|
||||
(fakeChild as unknown as Record<string, unknown>).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<typeof import("node:child_process").spawn>;
|
||||
(fakeChild as unknown as Record<string, unknown>).stdout = fakeStdout;
|
||||
(fakeChild as unknown as Record<string, unknown>).stderr = fakeStderr;
|
||||
(fakeChild as unknown as Record<string, unknown>).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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user