@decocms/start 6.0.0 → 6.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/generate-invoke.test.ts +83 -56
- package/scripts/generate-invoke.ts +26 -10
package/package.json
CHANGED
|
@@ -21,18 +21,20 @@ import { spawnSync } from "node:child_process";
|
|
|
21
21
|
import * as fs from "node:fs";
|
|
22
22
|
import * as os from "node:os";
|
|
23
23
|
import * as path from "node:path";
|
|
24
|
-
import {
|
|
24
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
25
25
|
|
|
26
26
|
const GENERATOR = path.resolve(__dirname, "generate-invoke.ts");
|
|
27
27
|
|
|
28
28
|
const FIXTURE_INVOKE_TS = `\
|
|
29
29
|
import { createInvokeFn } from "@decocms/start/sdk/createInvoke";
|
|
30
30
|
import { getOrCreateCart, simulateCart } from "./actions/checkout";
|
|
31
|
+
import { createSession } from "./actions/session";
|
|
31
32
|
import type { OrderForm } from "./types";
|
|
32
33
|
|
|
33
34
|
export const invoke = {
|
|
34
35
|
vtex: {
|
|
35
36
|
actions: {
|
|
37
|
+
// Direct pass-through wrapper — most common shape.
|
|
36
38
|
getOrCreateCart: createInvokeFn(
|
|
37
39
|
(data: { orderFormId?: string }) => getOrCreateCart(data),
|
|
38
40
|
) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise<OrderForm>,
|
|
@@ -40,6 +42,13 @@ export const invoke = {
|
|
|
40
42
|
simulateCart: createInvokeFn(
|
|
41
43
|
(data: { postalCode: string }) => simulateCart(data),
|
|
42
44
|
),
|
|
45
|
+
|
|
46
|
+
// Adapting wrapper — payload gets wrapped into the action's
|
|
47
|
+
// props shape. The generator MUST preserve this wrap or the
|
|
48
|
+
// action call typechecks against the wrong props type.
|
|
49
|
+
createSession: createInvokeFn(
|
|
50
|
+
(data: Record<string, any>) => createSession({ data }),
|
|
51
|
+
),
|
|
43
52
|
},
|
|
44
53
|
},
|
|
45
54
|
} as const;
|
|
@@ -52,14 +61,25 @@ const FIXTURE_ACTIONS_CHECKOUT_TS = `\
|
|
|
52
61
|
export async function getOrCreateCart(_data: any): Promise<any> { return null; }
|
|
53
62
|
export async function simulateCart(_data: any): Promise<any> { return null; }
|
|
54
63
|
`;
|
|
64
|
+
const FIXTURE_ACTIONS_SESSION_TS = `\
|
|
65
|
+
export interface CreateSessionProps { data: Record<string, any>; }
|
|
66
|
+
export async function createSession(_props: CreateSessionProps): Promise<any> { return null; }
|
|
67
|
+
`;
|
|
55
68
|
const FIXTURE_TYPES_TS = `export type OrderForm = unknown;\n`;
|
|
56
69
|
|
|
57
70
|
describe("generate-invoke.ts — output shape", () => {
|
|
71
|
+
// The fixture is read-only across tests: every assertion runs against
|
|
72
|
+
// the same generated `invoke.gen.ts`. Running the generator once in
|
|
73
|
+
// `beforeAll` keeps the test fast (each `npx tsx` spawn is ~3-5s) and
|
|
74
|
+
// sidesteps the vitest 5s default per-test timeout that this suite was
|
|
75
|
+
// tipping over once we grew the fixture.
|
|
58
76
|
let appsDir: string;
|
|
59
77
|
let siteDir: string;
|
|
60
78
|
let outFile: string;
|
|
79
|
+
let generatedOutput: string;
|
|
80
|
+
let generatorStatus: number | null;
|
|
61
81
|
|
|
62
|
-
|
|
82
|
+
beforeAll(() => {
|
|
63
83
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-"));
|
|
64
84
|
appsDir = path.join(tmp, "apps");
|
|
65
85
|
siteDir = path.join(tmp, "site");
|
|
@@ -70,11 +90,23 @@ describe("generate-invoke.ts — output shape", () => {
|
|
|
70
90
|
path.join(appsDir, "vtex", "actions", "checkout.ts"),
|
|
71
91
|
FIXTURE_ACTIONS_CHECKOUT_TS,
|
|
72
92
|
);
|
|
93
|
+
fs.writeFileSync(
|
|
94
|
+
path.join(appsDir, "vtex", "actions", "session.ts"),
|
|
95
|
+
FIXTURE_ACTIONS_SESSION_TS,
|
|
96
|
+
);
|
|
73
97
|
fs.writeFileSync(path.join(appsDir, "vtex", "types.ts"), FIXTURE_TYPES_TS);
|
|
74
98
|
outFile = path.join(siteDir, "src", "server", "invoke.gen.ts");
|
|
75
|
-
});
|
|
76
99
|
|
|
77
|
-
|
|
100
|
+
const result = spawnSync(
|
|
101
|
+
"npx",
|
|
102
|
+
["tsx", GENERATOR, "--apps-dir", appsDir, "--out-file", outFile],
|
|
103
|
+
{ cwd: siteDir, encoding: "utf8" },
|
|
104
|
+
);
|
|
105
|
+
generatorStatus = result.status;
|
|
106
|
+
generatedOutput = fs.readFileSync(outFile, "utf8");
|
|
107
|
+
}, 30_000);
|
|
108
|
+
|
|
109
|
+
afterAll(() => {
|
|
78
110
|
// Best-effort cleanup; tmp dirs leak otherwise.
|
|
79
111
|
try {
|
|
80
112
|
fs.rmSync(path.dirname(appsDir), { recursive: true, force: true });
|
|
@@ -83,86 +115,81 @@ describe("generate-invoke.ts — output shape", () => {
|
|
|
83
115
|
}
|
|
84
116
|
});
|
|
85
117
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
GENERATOR,
|
|
92
|
-
"--apps-dir",
|
|
93
|
-
appsDir,
|
|
94
|
-
"--out-file",
|
|
95
|
-
outFile,
|
|
96
|
-
],
|
|
97
|
-
{
|
|
98
|
-
cwd: siteDir,
|
|
99
|
-
encoding: "utf8",
|
|
100
|
-
},
|
|
101
|
-
);
|
|
102
|
-
return {
|
|
103
|
-
stdout: result.stdout ?? "",
|
|
104
|
-
stderr: result.stderr ?? "",
|
|
105
|
-
status: result.status,
|
|
106
|
-
};
|
|
107
|
-
}
|
|
118
|
+
it("runs to completion against a minimal fixture", () => {
|
|
119
|
+
// Sanity check — every subsequent assertion is wasted if the
|
|
120
|
+
// generator process bombed.
|
|
121
|
+
expect(generatorStatus).toBe(0);
|
|
122
|
+
});
|
|
108
123
|
|
|
109
124
|
it("imports the framework helpers needed for Set-Cookie propagation", () => {
|
|
110
|
-
const { status } = runGenerator();
|
|
111
|
-
expect(status).toBe(0);
|
|
112
|
-
|
|
113
|
-
const out = fs.readFileSync(outFile, "utf8");
|
|
114
125
|
// Both imports must be present — without them, forwardResponseCookies
|
|
115
126
|
// doesn't compile in the site, and the regression we're fixing
|
|
116
127
|
// resurfaces silently when someone deletes one of them.
|
|
117
|
-
expect(
|
|
118
|
-
expect(
|
|
119
|
-
expect(
|
|
128
|
+
expect(generatedOutput).toContain('from "@tanstack/react-start/server"');
|
|
129
|
+
expect(generatedOutput).toMatch(/getResponseHeaders\s*,?\s*\n?\s*setResponseHeader/);
|
|
130
|
+
expect(generatedOutput).toContain(
|
|
131
|
+
'import { RequestContext } from "@decocms/start/sdk/requestContext"',
|
|
132
|
+
);
|
|
120
133
|
});
|
|
121
134
|
|
|
122
135
|
it("emits the forwardResponseCookies helper exactly once", () => {
|
|
123
|
-
|
|
124
|
-
const out = fs.readFileSync(outFile, "utf8");
|
|
125
|
-
// Match the declaration, not call sites. There's only one helper.
|
|
126
|
-
const declMatches = out.match(/function forwardResponseCookies\(\)/g);
|
|
136
|
+
const declMatches = generatedOutput.match(/function forwardResponseCookies\(\)/g);
|
|
127
137
|
expect(declMatches).toHaveLength(1);
|
|
128
138
|
|
|
129
139
|
// The helper must read from RequestContext.responseHeaders and call
|
|
130
140
|
// setResponseHeader. Locking the bridge ends-to-ends.
|
|
131
|
-
expect(
|
|
132
|
-
expect(
|
|
133
|
-
expect(
|
|
141
|
+
expect(generatedOutput).toContain("RequestContext.current");
|
|
142
|
+
expect(generatedOutput).toContain("ctx.responseHeaders.getSetCookie");
|
|
143
|
+
expect(generatedOutput).toContain('setResponseHeader("set-cookie"');
|
|
134
144
|
});
|
|
135
145
|
|
|
136
146
|
it("calls forwardResponseCookies() inside every generated handler", () => {
|
|
137
|
-
runGenerator();
|
|
138
|
-
const out = fs.readFileSync(outFile, "utf8");
|
|
139
|
-
|
|
140
147
|
// Each .handler(async ({ data }) ...) block produced by the
|
|
141
148
|
// generator must contain a `forwardResponseCookies()` call. We
|
|
142
149
|
// count handlers vs. call sites — excluding the declaration site
|
|
143
150
|
// (`function forwardResponseCookies()`), which also matches the
|
|
144
151
|
// bare `forwardResponseCookies()` token.
|
|
145
|
-
const handlerCount = (
|
|
146
|
-
const allOccurrences = (
|
|
147
|
-
const declSites = (
|
|
152
|
+
const handlerCount = (generatedOutput.match(/\.handler\(async \(\{ data \}\)/g) ?? []).length;
|
|
153
|
+
const allOccurrences = (generatedOutput.match(/\bforwardResponseCookies\(\)/g) ?? []).length;
|
|
154
|
+
const declSites = (generatedOutput.match(/function\s+forwardResponseCookies\(\)/g) ?? [])
|
|
155
|
+
.length;
|
|
148
156
|
const callSites = allOccurrences - declSites;
|
|
149
157
|
|
|
150
|
-
expect(handlerCount).toBe(
|
|
158
|
+
expect(handlerCount).toBe(3);
|
|
151
159
|
expect(declSites).toBe(1);
|
|
152
|
-
expect(callSites).toBe(
|
|
160
|
+
expect(callSites).toBe(3);
|
|
153
161
|
});
|
|
154
162
|
|
|
155
163
|
it("calls forwardResponseCookies AFTER the action awaits (so RequestContext is populated)", () => {
|
|
156
|
-
runGenerator();
|
|
157
|
-
const out = fs.readFileSync(outFile, "utf8");
|
|
158
|
-
|
|
159
164
|
// The action must complete (await result) before we read the
|
|
160
165
|
// captured Set-Cookies — otherwise we'd forward an empty set
|
|
161
|
-
// every time.
|
|
162
|
-
//
|
|
166
|
+
// every time. The expression body after `await` can be any call
|
|
167
|
+
// shape the wrapper produces (`fn(data)` or `fn({ data })`),
|
|
168
|
+
// so we match across the whole expression up to the semicolon.
|
|
163
169
|
const pattern =
|
|
164
|
-
/const result = await
|
|
165
|
-
const orderedCalls =
|
|
166
|
-
expect(orderedCalls.length).toBe(
|
|
170
|
+
/const result = await [^;]+;\s+forwardResponseCookies\(\);\s+return (?:unwrapResult\(result\)|result);/g;
|
|
171
|
+
const orderedCalls = generatedOutput.match(pattern) ?? [];
|
|
172
|
+
expect(orderedCalls.length).toBe(3);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("preserves adapting wrappers verbatim (does not collapse to actionFn(data))", () => {
|
|
176
|
+
// Regression for the createSession-shape wrapper: the generator
|
|
177
|
+
// previously hard-coded `${importedFn}(data)` in every handler,
|
|
178
|
+
// silently dropping the wrap that wrappers like
|
|
179
|
+
// createSession: createInvokeFn((data) => createSession({ data }))
|
|
180
|
+
// perform to bridge the external invoke shape to the internal
|
|
181
|
+
// action shape. Sites that regenerated against this hit
|
|
182
|
+
// `TS2345: Property 'data' is missing in type '{ [x: string]: any; }'`
|
|
183
|
+
// at the regenerated call site of every adapting wrapper.
|
|
184
|
+
expect(generatedOutput).toContain("const result = await createSession({ data });");
|
|
185
|
+
// And the broken shortcut must NOT appear for this action.
|
|
186
|
+
expect(generatedOutput).not.toMatch(/const result = await createSession\(data\);/);
|
|
187
|
+
|
|
188
|
+
// Direct pass-through wrappers are unaffected: their body is
|
|
189
|
+
// already `actionFn(data)`, so emitting verbatim produces the same
|
|
190
|
+
// output that the previous shortcut produced. Lock that too — a
|
|
191
|
+
// future refactor that breaks pass-throughs would be just as bad.
|
|
192
|
+
expect(generatedOutput).toContain("const result = await getOrCreateCart(data);");
|
|
193
|
+
expect(generatedOutput).toContain("const result = await simulateCart(data);");
|
|
167
194
|
});
|
|
168
195
|
});
|
|
@@ -375,23 +375,39 @@ for (const action of actions) {
|
|
|
375
375
|
const varName = `$${action.name}`;
|
|
376
376
|
|
|
377
377
|
if (action.importedFn) {
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
378
|
+
// Emit the wrapper body verbatim. The arrow function in
|
|
379
|
+
// @decocms/apps/vtex/invoke.ts is the contract that maps the external
|
|
380
|
+
// invoke shape (what storefront callers send) to the internal action
|
|
381
|
+
// shape (what vtex/actions/* expects). Most wrappers are direct
|
|
382
|
+
// pass-throughs (`actionFn(data)`) but some adapt the payload
|
|
383
|
+
// (e.g. `createSession({ data })` wraps a flat session payload into
|
|
384
|
+
// CreateSessionProps). Hard-coding `${importedFn}(data)` silently
|
|
385
|
+
// dropped the wrap, producing typecheck errors at the call site of
|
|
386
|
+
// every adapting wrapper.
|
|
387
|
+
//
|
|
388
|
+
// Invariant: when action.importedFn is set, action.callBody was
|
|
389
|
+
// non-empty and contained `${importedFn}(` (that's how importedFn was
|
|
390
|
+
// discovered in the first place — see the importMap scan above).
|
|
391
|
+
// Block bodies clear callBody to "", which forces importedFn to ""
|
|
392
|
+
// and routes to the stub branch below.
|
|
393
|
+
//
|
|
394
|
+
// The arrow's parameter is `data` by convention across every wrapper
|
|
395
|
+
// in vtex/invoke.ts, and the generated handler destructures `{ data }`
|
|
396
|
+
// from the validator output, so callBody's `data` references resolve
|
|
397
|
+
// to the handler's local `data` without any rename.
|
|
381
398
|
//
|
|
382
399
|
// forwardResponseCookies() runs AFTER the action awaits, so any
|
|
383
400
|
// Set-Cookie that vtexFetchWithCookies captured onto
|
|
384
401
|
// RequestContext.responseHeaders gets promoted to the actual HTTP
|
|
385
|
-
// response. Safe no-op when the action didn't touch
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
// start propagating cookies.
|
|
402
|
+
// response. Safe no-op when the action didn't touch responseHeaders
|
|
403
|
+
// (e.g. masterData reads), so it's applied unconditionally — the
|
|
404
|
+
// alternative (a static allow-list of cookie-bearing actions)
|
|
405
|
+
// silently misses any new actions that start propagating cookies.
|
|
390
406
|
if (action.unwrap) {
|
|
391
407
|
out += `\nconst ${varName} = createServerFn({ method: "POST" })
|
|
392
408
|
.inputValidator((data: ${action.inputType}) => data)
|
|
393
409
|
.handler(async ({ data }): Promise<any> => {
|
|
394
|
-
const result = await ${action.
|
|
410
|
+
const result = await ${action.callBody};
|
|
395
411
|
forwardResponseCookies();
|
|
396
412
|
return unwrapResult(result);
|
|
397
413
|
});\n`;
|
|
@@ -399,7 +415,7 @@ for (const action of actions) {
|
|
|
399
415
|
out += `\nconst ${varName} = createServerFn({ method: "POST" })
|
|
400
416
|
.inputValidator((data: ${action.inputType}) => data)
|
|
401
417
|
.handler(async ({ data }): Promise<any> => {
|
|
402
|
-
const result = await ${action.
|
|
418
|
+
const result = await ${action.callBody};
|
|
403
419
|
forwardResponseCookies();
|
|
404
420
|
return result;
|
|
405
421
|
});\n`;
|