/** * Integration test for scripts/generate-invoke.ts. * * The generator scans an `invoke.ts` file from @decocms/apps-vtex and emits a * site-local `src/server/invoke.gen.ts` with top-level `createServerFn` * declarations. The piece we care most about locking is the Set-Cookie * bridge: every handler must call `forwardResponseCookies()` after the * action awaits, so VTEX-set cookies captured by `vtexFetchWithCookies` * reach the browser via TanStack Start's HTTP response. Without this, * `checkout.vtex.com` never reaches the browser, the storefront's * mini-cart and VTEX's server-side orderForm drift apart, and /checkout * loads with an empty cart. * * We exercise the generator end-to-end against a minimal fixture * `invoke.ts` rather than unit-test internal helpers — the failure * mode we want to prevent (a missing `forwardResponseCookies()` call * in the emit string) only shows up in the produced source text. */ import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; const GENERATOR = path.resolve(__dirname, "generate-invoke.ts"); const FIXTURE_INVOKE_TS = `\ import { createInvokeFn } from "@decocms/start/sdk/createInvoke"; import { getOrCreateCart, simulateCart } from "./actions/checkout"; import { createSession } from "./actions/session"; import type { OrderForm } from "./types"; export const invoke = { vtex: { actions: { // Direct pass-through wrapper — most common shape. getOrCreateCart: createInvokeFn( (data: { orderFormId?: string }) => getOrCreateCart(data), ) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise, simulateCart: createInvokeFn( (data: { postalCode: string }) => simulateCart(data), ), // Adapting wrapper — payload gets wrapped into the action's // props shape. The generator MUST preserve this wrap or the // action call typechecks against the wrong props type. createSession: createInvokeFn( (data: Record) => createSession({ data }), ), }, }, } as const; `; // Minimal stubs the generator's import-resolution doesn't *execute* but // ts-morph parses these to populate the importMap. We only need names to // resolve. const FIXTURE_ACTIONS_CHECKOUT_TS = `\ export async function getOrCreateCart(_data: any): Promise { return null; } export async function simulateCart(_data: any): Promise { return null; } `; const FIXTURE_ACTIONS_SESSION_TS = `\ export interface CreateSessionProps { data: Record; } export async function createSession(_props: CreateSessionProps): Promise { return null; } `; const FIXTURE_TYPES_TS = `export type OrderForm = unknown;\n`; describe("generate-invoke.ts — output shape", () => { // The fixture is read-only across tests: every assertion runs against // the same generated `invoke.gen.ts`. Running the generator once in // `beforeAll` keeps the test fast (each `npx tsx` spawn is ~3-5s) and // sidesteps the vitest 5s default per-test timeout that this suite was // tipping over once we grew the fixture. let appsDir: string; let siteDir: string; let outFile: string; let generatedOutput: string; let generatorStatus: number | null; beforeAll(() => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-")); // Mirrors @decocms/apps-vtex's actual layout post-migration: invoke.ts // sits at the package root (no more vtex/ nesting — the old vtex/ // directory in apps-start IS this package's root now). appsDir = path.join(tmp, "apps-vtex"); siteDir = path.join(tmp, "site"); fs.mkdirSync(path.join(appsDir, "actions"), { recursive: true }); fs.mkdirSync(path.join(siteDir, "src", "server"), { recursive: true }); fs.writeFileSync(path.join(appsDir, "invoke.ts"), FIXTURE_INVOKE_TS); fs.writeFileSync(path.join(appsDir, "actions", "checkout.ts"), FIXTURE_ACTIONS_CHECKOUT_TS); fs.writeFileSync(path.join(appsDir, "actions", "session.ts"), FIXTURE_ACTIONS_SESSION_TS); fs.writeFileSync(path.join(appsDir, "types.ts"), FIXTURE_TYPES_TS); outFile = path.join(siteDir, "src", "server", "invoke.gen.ts"); const result = spawnSync( "npx", ["tsx", GENERATOR, "--apps-dir", appsDir, "--out-file", outFile], { cwd: siteDir, encoding: "utf8" }, ); generatorStatus = result.status; generatedOutput = fs.readFileSync(outFile, "utf8"); }, 30_000); afterAll(() => { // Best-effort cleanup; tmp dirs leak otherwise. try { fs.rmSync(path.dirname(appsDir), { recursive: true, force: true }); } catch { // ignore } }); it("runs to completion against a minimal fixture", () => { // Sanity check — every subsequent assertion is wasted if the // generator process bombed. expect(generatorStatus).toBe(0); }); it("imports the framework helpers needed for Set-Cookie propagation", () => { // Both imports must be present — without them, forwardResponseCookies // doesn't compile in the site, and the regression we're fixing // resurfaces silently when someone deletes one of them. expect(generatedOutput).toContain('from "@tanstack/react-start/server"'); expect(generatedOutput).toMatch(/getResponseHeaders\s*,?\s*\n?\s*setResponseHeader/); expect(generatedOutput).toContain( 'import { RequestContext } from "@decocms/blocks/sdk/requestContext"', ); }); it("emits the forwardResponseCookies helper exactly once", () => { const declMatches = generatedOutput.match(/function forwardResponseCookies\(\)/g); expect(declMatches).toHaveLength(1); // The helper must read from RequestContext.responseHeaders and call // setResponseHeader. Locking the bridge ends-to-ends. expect(generatedOutput).toContain("RequestContext.current"); expect(generatedOutput).toContain("ctx.responseHeaders.getSetCookie"); expect(generatedOutput).toContain('setResponseHeader("set-cookie"'); }); it("calls forwardResponseCookies() inside every generated handler", () => { // Each .handler(async ({ data }) ...) block produced by the // generator must contain a `forwardResponseCookies()` call. We // count handlers vs. call sites — excluding the declaration site // (`function forwardResponseCookies()`), which also matches the // bare `forwardResponseCookies()` token. const handlerCount = (generatedOutput.match(/\.handler\(async \(\{ data \}\)/g) ?? []).length; const allOccurrences = (generatedOutput.match(/\bforwardResponseCookies\(\)/g) ?? []).length; const declSites = (generatedOutput.match(/function\s+forwardResponseCookies\(\)/g) ?? []) .length; const callSites = allOccurrences - declSites; expect(handlerCount).toBe(3); expect(declSites).toBe(1); expect(callSites).toBe(3); }); it("calls forwardResponseCookies AFTER the action awaits (so RequestContext is populated)", () => { // The action must complete (await result) before we read the // captured Set-Cookies — otherwise we'd forward an empty set // every time. The expression body after `await` can be any call // shape the wrapper produces (`fn(data)` or `fn({ data })`), // so we match across the whole expression up to the semicolon. const pattern = /const result = await [^;]+;\s+forwardResponseCookies\(\);\s+return (?:unwrapResult\(result\)|result);/g; const orderedCalls = generatedOutput.match(pattern) ?? []; expect(orderedCalls.length).toBe(3); }); it("preserves adapting wrappers verbatim (does not collapse to actionFn(data))", () => { // Regression for the createSession-shape wrapper: the generator // previously hard-coded `${importedFn}(data)` in every handler, // silently dropping the wrap that wrappers like // createSession: createInvokeFn((data) => createSession({ data })) // perform to bridge the external invoke shape to the internal // action shape. Sites that regenerated against this hit // `TS2345: Property 'data' is missing in type '{ [x: string]: any; }'` // at the regenerated call site of every adapting wrapper. expect(generatedOutput).toContain("const result = await createSession({ data });"); // And the broken shortcut must NOT appear for this action. expect(generatedOutput).not.toMatch(/const result = await createSession\(data\);/); // Direct pass-through wrappers are unaffected: their body is // already `actionFn(data)`, so emitting verbatim produces the same // output that the previous shortcut produced. Lock that too — a // future refactor that breaks pass-throughs would be just as bad. expect(generatedOutput).toContain("const result = await getOrCreateCart(data);"); expect(generatedOutput).toContain("const result = await simulateCart(data);"); }); }); describe("generate-invoke.ts — default --apps-dir resolution", () => { // Regression for the published-tarball layout: @decocms/apps-vtex 7.x // ships its sources under src/ (`"files": ["src"]`), so invoke.ts lives // at node_modules/@decocms/apps-vtex/src/invoke.ts — NOT at the package // root. The old default only probed the root, never resolved on a site // with npm-installed packages, and forced sites to pass // `--apps-dir node_modules/@decocms/apps-vtex/src` by hand (a production // site's migration workaround). The default must probe /invoke.ts first, // then /src/invoke.ts. function makeSite(layout: "root" | "src"): { siteDir: string; cleanup: () => void } { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-default-")); const siteDir = path.join(tmp, "site"); const pkgDir = path.join(siteDir, "node_modules", "@decocms", "apps-vtex"); const appsDir = layout === "src" ? path.join(pkgDir, "src") : pkgDir; fs.mkdirSync(path.join(appsDir, "actions"), { recursive: true }); fs.writeFileSync(path.join(appsDir, "invoke.ts"), FIXTURE_INVOKE_TS); fs.writeFileSync(path.join(appsDir, "actions", "checkout.ts"), FIXTURE_ACTIONS_CHECKOUT_TS); fs.writeFileSync(path.join(appsDir, "actions", "session.ts"), FIXTURE_ACTIONS_SESSION_TS); fs.writeFileSync(path.join(appsDir, "types.ts"), FIXTURE_TYPES_TS); return { siteDir, cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }) }; } it("resolves the published layout (node_modules/@decocms/apps-vtex/src/invoke.ts) without --apps-dir", () => { const { siteDir, cleanup } = makeSite("src"); try { const outFile = path.join(siteDir, "src", "server", "invoke.gen.ts"); const result = spawnSync("npx", ["tsx", GENERATOR, "--out-file", outFile], { cwd: siteDir, encoding: "utf8", }); expect(result.status, result.stderr).toBe(0); const generated = fs.readFileSync(outFile, "utf8"); // Relative `./actions/*` imports must still be rewritten to package // subpaths — the exports map points them back into src/, so the src/ // nesting must not leak into the emitted specifiers. expect(generated).toContain('from "@decocms/apps-vtex/actions/checkout"'); expect(generated).not.toContain("apps-vtex/src/"); expect(generated).toContain("export const vtexActions"); } finally { cleanup(); } }, 30_000); it("still resolves invoke.ts at the package root (legacy dev-checkout layout) without --apps-dir", () => { const { siteDir, cleanup } = makeSite("root"); try { const outFile = path.join(siteDir, "src", "server", "invoke.gen.ts"); const result = spawnSync("npx", ["tsx", GENERATOR, "--out-file", outFile], { cwd: siteDir, encoding: "utf8", }); expect(result.status, result.stderr).toBe(0); const generated = fs.readFileSync(outFile, "utf8"); expect(generated).toContain('from "@decocms/apps-vtex/actions/checkout"'); expect(generated).toContain("export const vtexActions"); } finally { cleanup(); } }, 30_000); it("fails with a clear error when @decocms/apps-vtex cannot be found", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-missing-")); try { const outFile = path.join(tmp, "src", "server", "invoke.gen.ts"); const result = spawnSync("npx", ["tsx", GENERATOR, "--out-file", outFile], { cwd: tmp, encoding: "utf8", }); expect(result.status).not.toBe(0); expect(result.stderr).toContain("Could not find @decocms/apps-vtex"); expect(result.stderr).toContain("--apps-dir"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }, 30_000); }); describe("generate-invoke.ts — privileged MasterData actions are never exposed", () => { // Security regression guard: generic admin-credentialed MasterData CRUD // (searchDocuments / createDocument / patchDocument / getDocument / // uploadAttachment) must NOT be emitted as createServerFn endpoints, even if // they appear in invoke.vtex.actions. Each emitted action is a public, // unauthenticated /_serverFn/; a generic (entity, filter) proxy over the // app's appKey/appToken is broken access control (e.g. entity: "CL" dumps // customer PII). The generator's PRIVILEGED_ACTIONS guard must skip them. const FIXTURE_WITH_PRIVILEGED = `\ import { createInvokeFn } from "@decocms/tanstack/sdk/createInvoke"; import { getOrCreateCart } from "./actions/checkout"; import { createDocument, searchDocuments, patchDocument } from "./actions/masterData"; import type { OrderForm } from "./types"; export const invoke = { vtex: { actions: { getOrCreateCart: createInvokeFn( (data: { orderFormId?: string }) => getOrCreateCart(data), ) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise, createDocument: createInvokeFn( (data: { entity: string; data: Record }) => createDocument(data), ), searchDocuments: createInvokeFn( (data: { entity: string; filter: string }) => searchDocuments(data), ), patchDocument: createInvokeFn( (data: { entity: string; documentId: string; data: Record }) => patchDocument(data), ), }, }, } as const; `; const FIXTURE_ACTIONS_MASTERDATA_TS = `\ export async function createDocument(_p: any): Promise { return null; } export async function searchDocuments(_p: any): Promise { return null; } export async function patchDocument(_p: any): Promise { return null; } `; let generatedOutput: string; let stderr: string; let status: number | null; let cleanup: () => void; beforeAll(() => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-priv-")); const appsDir = path.join(tmp, "apps-vtex"); const siteDir = path.join(tmp, "site"); fs.mkdirSync(path.join(appsDir, "actions"), { recursive: true }); fs.mkdirSync(path.join(siteDir, "src", "server"), { recursive: true }); fs.writeFileSync(path.join(appsDir, "invoke.ts"), FIXTURE_WITH_PRIVILEGED); fs.writeFileSync(path.join(appsDir, "actions", "checkout.ts"), FIXTURE_ACTIONS_CHECKOUT_TS); fs.writeFileSync(path.join(appsDir, "actions", "masterData.ts"), FIXTURE_ACTIONS_MASTERDATA_TS); fs.writeFileSync(path.join(appsDir, "types.ts"), FIXTURE_TYPES_TS); const outFile = path.join(siteDir, "src", "server", "invoke.gen.ts"); const result = spawnSync( "npx", ["tsx", GENERATOR, "--apps-dir", appsDir, "--out-file", outFile], { cwd: siteDir, encoding: "utf8" }, ); status = result.status; stderr = result.stderr; generatedOutput = fs.readFileSync(outFile, "utf8"); cleanup = () => fs.rmSync(tmp, { recursive: true, force: true }); }, 30_000); afterAll(() => { try { cleanup(); } catch { // ignore } }); it("runs to completion", () => { expect(status, stderr).toBe(0); }); it("does NOT emit createServerFn consts for privileged MasterData actions", () => { expect(generatedOutput).not.toContain("$createDocument"); expect(generatedOutput).not.toContain("$searchDocuments"); expect(generatedOutput).not.toContain("$patchDocument"); }); it("does NOT list privileged actions in the exported vtexActions map", () => { expect(generatedOutput).not.toMatch(/^\s*createDocument:/m); expect(generatedOutput).not.toMatch(/^\s*searchDocuments:/m); expect(generatedOutput).not.toMatch(/^\s*patchDocument:/m); }); it("does NOT import the privileged action functions", () => { expect(generatedOutput).not.toContain("actions/masterData"); }); it("still emits the safe action alongside the skipped ones", () => { expect(generatedOutput).toContain("const $getOrCreateCart = createServerFn"); expect(generatedOutput).toContain("const result = await getOrCreateCart(data);"); }); it("warns on stderr for each skipped privileged action", () => { expect(stderr).toContain('Skipping privileged action "createDocument"'); expect(stderr).toContain('Skipping privileged action "searchDocuments"'); expect(stderr).toContain('Skipping privileged action "patchDocument"'); }); });