// IMP-43 (#72) u6 — /api/run reuseFromRunId forwarding coverage. // // Stage 2 unit scope: // 1) Front/client/src/services/designAgentApi.ts `runPipeline`: // • accepts an optional 3rd arg `reuseFromRunId: string`. // • includes `reuseFromRunId` in the POST body when truthy. // • OMITS `reuseFromRunId` from the body when absent / empty / undefined // → byte-identical to the pre-u6 POST contract (absent flag = full // pipeline; backend u1 guard never sees an empty PREV_RUN_ID). // • leaves `filename`, `content`, and `overrides` untouched alongside // the new field (no payload-shape regression). // 2) Front/vite.config.ts `/api/run` handler: // • declares `reuseFromRunId?: string` in the payload type so a typed // client cannot send a payload the server silently drops. // • destructures `reuseFromRunId` from `payload` (sibling of // `overrides`, NOT nested under it — the backend u1 post-merge // guard treats reuse as a pipeline mode, not an override). // • forwards `--reuse-from ` to spawn cliArgs guarded by // a truthy check (empty string / undefined ⇒ no flag, per Stage 2 // contract: invalid CLI args must never reach argparse). // • places the forward block AFTER the `--override-section-assignment` // loop so the spawn argv preserves backend argparse's no-positional- // before-flag expectation and so `--override-frame` (still allowed // by the u1 guard) is positioned ahead of `--reuse-from`. // // runPipeline is exercised with a duck-typed `File` plus a `vi.stubGlobal` // fetch mock — mirrors the user_overrides_service.test.ts pattern. The // vite handler is source-sliced (mirrors handle_generate_diag.test.ts) // because the handler spawns python and a real /api/run round-trip is // out of unit-test scope. import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { runPipeline } from "../src/services/designAgentApi"; // --------------------------------------------------------------------------- // vite.config.ts source — read once for the handler source-slice assertions. // Path: Front/client/tests/ → Front/vite.config.ts (two levels up). // --------------------------------------------------------------------------- const VITE_CONFIG_PATH = resolve(__dirname, "..", "..", "vite.config.ts"); const VITE_CONFIG_SOURCE = readFileSync(VITE_CONFIG_PATH, "utf-8"); // --------------------------------------------------------------------------- // fetch mock — minimal Response stub mirroring runPipeline's `.ok` + `.json()` // + `.status` surface. Same shape as the user_overrides_service.test.ts // helper so the two test files stay drift-free. // --------------------------------------------------------------------------- type MockResponse = { ok: boolean; status: number; json: () => Promise; }; function mockResponse(body: unknown, ok = true, status = 200): MockResponse { return { ok, status, json: async () => body }; } const SUCCESS_BODY = { success: true, run_id: "test_run_id_20260524", exit_code: 0, final_html_exists: true, preview_exists: true, stdout: "", stderr: "", }; // Duck-typed File — runPipeline reads only `.name` and `.text()`. Avoids a // hard dependency on the global File constructor (varies across node / // jsdom / happy-dom test environments). function makeFakeFile(name: string, content: string): File { return { name, text: async () => content, } as unknown as File; } let fetchMock: Mock; beforeEach(() => { fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); }); function lastPostBody(): Record { const lastCall = fetchMock.mock.calls.at(-1); if (!lastCall) throw new Error("fetch was not called"); const init = lastCall[1] as RequestInit | undefined; if (!init?.body) throw new Error("fetch was called without a body"); return JSON.parse(String(init.body)); } // ============================================================================ // runPipeline (designAgentApi.ts) — forwarding/omission coverage // ============================================================================ describe("runPipeline reuseFromRunId forwarding (IMP-43 #72 u6)", () => { it("posts to /api/run via POST with JSON content-type", async () => { fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY)); await runPipeline(makeFakeFile("03.mdx", "# title")); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe("/api/run"); expect((init as RequestInit).method).toBe("POST"); expect((init as RequestInit).headers).toMatchObject({ "Content-Type": "application/json", }); }); it("includes reuseFromRunId in the POST body when provided", async () => { fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY)); await runPipeline( makeFakeFile("03.mdx", "# title"), undefined, "mdx03_20260524080000", ); const body = lastPostBody(); expect(body.reuseFromRunId).toBe("mdx03_20260524080000"); expect(body.filename).toBe("03.mdx"); expect(body.content).toBe("# title"); }); it("omits reuseFromRunId when 3rd arg is undefined (pre-u6 byte-identical)", async () => { fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY)); await runPipeline(makeFakeFile("03.mdx", "# title")); const body = lastPostBody(); expect("reuseFromRunId" in body).toBe(false); // Pre-u6 contract: filename/content are the only keys when overrides // is undefined (JSON.stringify drops undefined values; pre-u6 emitted // `JSON.stringify({filename, content, overrides})` with the same // drop-undefined behaviour, so the wire body is byte-identical). expect(Object.keys(body).sort()).toEqual(["content", "filename"]); }); it("omits reuseFromRunId but keeps overrides when only overrides provided", async () => { fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY)); await runPipeline(makeFakeFile("03.mdx", "# title"), { frames: { "03-1": "frame_07" }, }); const body = lastPostBody(); expect("reuseFromRunId" in body).toBe(false); expect(Object.keys(body).sort()).toEqual([ "content", "filename", "overrides", ]); expect(body.overrides).toEqual({ frames: { "03-1": "frame_07" } }); }); it("omits reuseFromRunId when passed an empty string (truthy guard)", async () => { fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY)); await runPipeline(makeFakeFile("03.mdx", "# title"), undefined, ""); const body = lastPostBody(); expect("reuseFromRunId" in body).toBe(false); }); it("forwards reuseFromRunId alongside frame overrides (the only u1-permitted combo)", async () => { fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY)); await runPipeline( makeFakeFile("03.mdx", "# title"), { frames: { "03-1+03-2": "frame_07" } }, "mdx03_20260524080000", ); const body = lastPostBody(); expect(body.overrides).toEqual({ frames: { "03-1+03-2": "frame_07" } }); expect(body.reuseFromRunId).toBe("mdx03_20260524080000"); }); it("returns the parsed RunPipelineResult on success", async () => { fetchMock.mockResolvedValueOnce(mockResponse(SUCCESS_BODY)); const res = await runPipeline( makeFakeFile("03.mdx", "# title"), undefined, "mdx03_20260524080000", ); expect(res.success).toBe(true); expect(res.run_id).toBe("test_run_id_20260524"); }); }); // ============================================================================ // /api/run handler (vite.config.ts) — source-slice forwarding contract // ============================================================================ describe("/api/run handler reuseFromRunId source-slice (IMP-43 #72 u6)", () => { it("declares reuseFromRunId?: string on the /api/run payload type", () => { // Payload type at the top of the /api/run handler body. The // optional-string declaration is the single source-of-truth for what // shape the handler accepts; a typed frontend client (u5 saveUserOverrides // sibling pattern) cannot silently send a payload the server drops. expect(VITE_CONFIG_SOURCE).toMatch(/reuseFromRunId\?:\s*string\s*;/); }); it("destructures reuseFromRunId from payload alongside filename/content/overrides", () => { expect(VITE_CONFIG_SOURCE).toMatch( /const\s*\{\s*filename\s*,\s*content\s*,\s*overrides\s*,\s*reuseFromRunId\s*\}\s*=\s*payload\s*;/, ); }); it("forwards --reuse-from after the override-section-assignment loop", () => { // Stage 2 contract: reuse_from is a pipeline mode, not an override. // The forward block must sit AFTER the last override loop so the spawn // argv preserves the order documented in the u1 backend post-merge // guard (overrides parsed first; reuse_from precondition runs against // the merged overrides view). const reuseFromIdx = VITE_CONFIG_SOURCE.indexOf('"--reuse-from"'); const zoneSectionsIdx = VITE_CONFIG_SOURCE.indexOf( '"--override-section-assignment"', ); expect(reuseFromIdx).toBeGreaterThan(-1); expect(zoneSectionsIdx).toBeGreaterThan(-1); expect(reuseFromIdx).toBeGreaterThan(zoneSectionsIdx); }); it("guards the forward with a truthy check on reuseFromRunId", () => { // Empty string / undefined ⇒ no flag pushed (Stage 2 contract: invalid // CLI args must never reach argparse — the backend u1 guard would // fail-closed with `reuse_artifact_missing` on the empty PREV_RUN_ID). const reuseFromIdx = VITE_CONFIG_SOURCE.indexOf('"--reuse-from"'); expect(reuseFromIdx).toBeGreaterThan(-1); const preface = VITE_CONFIG_SOURCE.slice( Math.max(0, reuseFromIdx - 200), reuseFromIdx, ); expect(preface).toMatch(/if\s*\(\s*reuseFromRunId/); expect(preface).toMatch(/typeof\s+reuseFromRunId\s*===\s*"string"/); }); it("pushes reuseFromRunId as the --reuse-from argument value (no string interpolation)", () => { // The CLI value must be the raw PREV_RUN_ID — no `=` join, no quoting // (spawn is shell:false). Mirrors the `--override-layout` shape. const reuseFromIdx = VITE_CONFIG_SOURCE.indexOf('"--reuse-from"'); expect(reuseFromIdx).toBeGreaterThan(-1); // Window spans both before (`cliArgs.push(`) and after // (`reuseFromRunId)`) the literal so the full push expression is // captured. const window = VITE_CONFIG_SOURCE.slice( Math.max(0, reuseFromIdx - 100), reuseFromIdx + 200, ); expect(window).toMatch( /cliArgs\.push\(\s*"--reuse-from"\s*,\s*reuseFromRunId\s*\)/, ); }); });