@decocms/blocks-cli 7.0.0
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 +44 -0
- package/scripts/analyze-traces.mjs +1117 -0
- package/scripts/audit-observability-config.test.ts +446 -0
- package/scripts/audit-observability-config.ts +511 -0
- package/scripts/deco-migrate-cli.ts +444 -0
- package/scripts/fast-deploy-kv.test.ts +131 -0
- package/scripts/generate-blocks.test.ts +94 -0
- package/scripts/generate-blocks.ts +274 -0
- package/scripts/generate-invoke.test.ts +195 -0
- package/scripts/generate-invoke.ts +469 -0
- package/scripts/generate-loaders.ts +217 -0
- package/scripts/generate-schema.ts +1287 -0
- package/scripts/generate-sections.ts +237 -0
- package/scripts/htmx-analyze.ts +226 -0
- package/scripts/lib/blocks-dedupe.test.ts +179 -0
- package/scripts/lib/blocks-dedupe.ts +142 -0
- package/scripts/lib/cf-kv-rest.ts +78 -0
- package/scripts/lib/jsonc.ts +122 -0
- package/scripts/lib/kv-snapshot.ts +51 -0
- package/scripts/lib/read-decofile.ts +70 -0
- package/scripts/lib/sync-helpers.ts +44 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
- package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
- package/scripts/migrate/analyzers/island-classifier.ts +96 -0
- package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
- package/scripts/migrate/analyzers/section-metadata.ts +147 -0
- package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
- package/scripts/migrate/colors.ts +46 -0
- package/scripts/migrate/config.test.ts +202 -0
- package/scripts/migrate/config.ts +186 -0
- package/scripts/migrate/phase-analyze.test.ts +63 -0
- package/scripts/migrate/phase-analyze.ts +782 -0
- package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
- package/scripts/migrate/phase-cleanup-audit.ts +105 -0
- package/scripts/migrate/phase-cleanup.test.ts +141 -0
- package/scripts/migrate/phase-cleanup.ts +1588 -0
- package/scripts/migrate/phase-compile.test.ts +193 -0
- package/scripts/migrate/phase-compile.ts +177 -0
- package/scripts/migrate/phase-report.ts +243 -0
- package/scripts/migrate/phase-scaffold.ts +593 -0
- package/scripts/migrate/phase-transform.ts +310 -0
- package/scripts/migrate/phase-verify.test.ts +127 -0
- package/scripts/migrate/phase-verify.ts +572 -0
- package/scripts/migrate/post-cleanup/rules.ts +1708 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
- package/scripts/migrate/post-cleanup/runner.ts +137 -0
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
- package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
- package/scripts/migrate/post-cleanup/types.ts +106 -0
- package/scripts/migrate/source-layout.test.ts +111 -0
- package/scripts/migrate/source-layout.ts +103 -0
- package/scripts/migrate/templates/app-css.ts +366 -0
- package/scripts/migrate/templates/cache-config.ts +26 -0
- package/scripts/migrate/templates/commerce-loaders.ts +230 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
- package/scripts/migrate/templates/cursor-rules.ts +70 -0
- package/scripts/migrate/templates/hooks.test.ts +141 -0
- package/scripts/migrate/templates/hooks.ts +152 -0
- package/scripts/migrate/templates/knip-config.ts +27 -0
- package/scripts/migrate/templates/lib-utils.test.ts +139 -0
- package/scripts/migrate/templates/lib-utils.ts +326 -0
- package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
- package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
- package/scripts/migrate/templates/package-json.ts +177 -0
- package/scripts/migrate/templates/routes.ts +237 -0
- package/scripts/migrate/templates/sdk-gen.ts +59 -0
- package/scripts/migrate/templates/section-loaders.ts +456 -0
- package/scripts/migrate/templates/server-entry.ts +561 -0
- package/scripts/migrate/templates/setup.ts +148 -0
- package/scripts/migrate/templates/tsconfig.ts +21 -0
- package/scripts/migrate/templates/types-gen.ts +174 -0
- package/scripts/migrate/templates/ui-components.ts +144 -0
- package/scripts/migrate/templates/vite-config.ts +101 -0
- package/scripts/migrate/transforms/dead-code.ts +455 -0
- package/scripts/migrate/transforms/deno-isms.ts +85 -0
- package/scripts/migrate/transforms/fresh-apis.ts +223 -0
- package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
- package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
- package/scripts/migrate/transforms/imports.ts +385 -0
- package/scripts/migrate/transforms/jsx.ts +317 -0
- package/scripts/migrate/transforms/section-conventions.ts +210 -0
- package/scripts/migrate/transforms/tailwind.ts +739 -0
- package/scripts/migrate/types.ts +244 -0
- package/scripts/migrate-blocks-to-kv.ts +104 -0
- package/scripts/migrate-post-cleanup.ts +191 -0
- package/scripts/migrate-to-cf-observability.test.ts +215 -0
- package/scripts/migrate-to-cf-observability.ts +699 -0
- package/scripts/migrate.ts +282 -0
- package/scripts/smoke-otlp-errorlog.ts +46 -0
- package/scripts/smoke-otlp-meter.ts +48 -0
- package/scripts/sync-blocks-to-kv.ts +153 -0
- package/scripts/tailwind-lint.ts +518 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Reads .deco/blocks/*.json and emits:
|
|
4
|
+
* 1. blocks.gen.json — compact JSON data (the source of truth)
|
|
5
|
+
* 2. blocks.gen.ts — thin TypeScript re-export for editor tooling
|
|
6
|
+
*
|
|
7
|
+
* At runtime the Vite plugin (src/vite/plugin.js) intercepts `blocks.gen.ts`
|
|
8
|
+
* imports and replaces them with `JSON.parse(...)` of the .json file. This
|
|
9
|
+
* avoids Vite's SSR module runner hanging on large (13MB+) JS object literals
|
|
10
|
+
* and lets V8 use its fast JSON parser instead of the full JS parser.
|
|
11
|
+
*
|
|
12
|
+
* Usage (from site root):
|
|
13
|
+
* npx tsx node_modules/@decocms/blocks-cli/scripts/generate-blocks.ts
|
|
14
|
+
*
|
|
15
|
+
* Env / CLI:
|
|
16
|
+
* --blocks-dir override input (default: .deco/blocks)
|
|
17
|
+
* --out-file override output (default: src/server/cms/blocks.gen.ts)
|
|
18
|
+
*
|
|
19
|
+
* Programmatic:
|
|
20
|
+
* import { generateBlocks } from "@decocms/blocks-cli/generate-blocks";
|
|
21
|
+
* await generateBlocks({ blocksDir, outFile });
|
|
22
|
+
*
|
|
23
|
+
* The Vite plugin's dev-mode watcher uses the programmatic entry to keep the
|
|
24
|
+
* generated artifact in sync with `.deco/blocks/` without spawning a child
|
|
25
|
+
* process per change.
|
|
26
|
+
*/
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import {
|
|
30
|
+
blockHasPath,
|
|
31
|
+
type Candidate,
|
|
32
|
+
decodeBlockNameWithPasses,
|
|
33
|
+
mergeCandidates,
|
|
34
|
+
singleDecodeBlockName,
|
|
35
|
+
} from "./lib/blocks-dedupe";
|
|
36
|
+
|
|
37
|
+
const TS_STUB = [
|
|
38
|
+
"// Auto-generated — thin wrapper around blocks.gen.json.",
|
|
39
|
+
"// The Vite plugin replaces this at load time with JSON.parse(...).",
|
|
40
|
+
"// Do not edit manually.",
|
|
41
|
+
"",
|
|
42
|
+
"export const blocks: Record<string, any> = {};",
|
|
43
|
+
"",
|
|
44
|
+
].join("\n");
|
|
45
|
+
|
|
46
|
+
export interface GenerateBlocksOptions {
|
|
47
|
+
blocksDir: string;
|
|
48
|
+
outFile: string;
|
|
49
|
+
/** Suppress the per-run summary log. Defaults to false. */
|
|
50
|
+
silent?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface GenerateBlocksResult {
|
|
54
|
+
count: number;
|
|
55
|
+
collisions: number;
|
|
56
|
+
jsonFile: string;
|
|
57
|
+
outFile: string;
|
|
58
|
+
/** True when the blocks dir was missing and an empty barrel was emitted. */
|
|
59
|
+
empty: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* The merged decofile map that was written to `jsonFile`. Returned so callers
|
|
62
|
+
* (the dev Vite plugin) can seed an in-memory cache and apply cheap deltas to
|
|
63
|
+
* it without re-reading the whole `.deco/blocks` directory on every edit.
|
|
64
|
+
*/
|
|
65
|
+
blocks: Record<string, unknown>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function generateBlocks(
|
|
69
|
+
options: GenerateBlocksOptions,
|
|
70
|
+
): Promise<GenerateBlocksResult> {
|
|
71
|
+
const blocksDir = path.resolve(options.blocksDir);
|
|
72
|
+
const outFile = path.resolve(options.outFile);
|
|
73
|
+
const jsonFile = outFile.replace(/\.ts$/, ".json");
|
|
74
|
+
const silent = options.silent ?? false;
|
|
75
|
+
|
|
76
|
+
if (!fs.existsSync(blocksDir)) {
|
|
77
|
+
if (!silent) {
|
|
78
|
+
console.warn(`Blocks directory not found: ${blocksDir} — generating empty barrel.`);
|
|
79
|
+
}
|
|
80
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
81
|
+
fs.writeFileSync(jsonFile, "{}");
|
|
82
|
+
fs.writeFileSync(outFile, TS_STUB);
|
|
83
|
+
return { count: 0, collisions: 0, jsonFile, outFile, empty: true, blocks: {} };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const files = fs.readdirSync(blocksDir).filter((f) => f.endsWith(".json"));
|
|
87
|
+
|
|
88
|
+
// Read each file into a Candidate, then let the dedupe lib pick the winner
|
|
89
|
+
// per decoded key and report any collisions. See `lib/blocks-dedupe.ts` for
|
|
90
|
+
// the priority order and the rationale behind it (TL;DR: never use file size,
|
|
91
|
+
// don't trust mtime alone in CI clones).
|
|
92
|
+
const candidatesWithKeys: Array<{ candidate: Candidate; key: string }> = [];
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
const { name, passes } = decodeBlockNameWithPasses(file);
|
|
95
|
+
const fp = path.join(blocksDir, file);
|
|
96
|
+
let parsed: unknown;
|
|
97
|
+
try {
|
|
98
|
+
parsed = JSON.parse(fs.readFileSync(fp, "utf-8"));
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (!silent) console.warn(`Failed to parse ${file}:`, e);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
candidatesWithKeys.push({
|
|
104
|
+
key: name,
|
|
105
|
+
candidate: {
|
|
106
|
+
file,
|
|
107
|
+
passes,
|
|
108
|
+
mtimeMs: fs.statSync(fp).mtimeMs,
|
|
109
|
+
hasPath: blockHasPath(parsed),
|
|
110
|
+
parsed,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { winners, collisions } = mergeCandidates(candidatesWithKeys);
|
|
116
|
+
|
|
117
|
+
if (!silent && collisions.length > 0) {
|
|
118
|
+
console.warn(
|
|
119
|
+
`Detected ${collisions.length} filename collision(s) in ${path.relative(process.cwd(), blocksDir)}:`,
|
|
120
|
+
);
|
|
121
|
+
for (const c of collisions) {
|
|
122
|
+
const losers = c.files.filter((f) => f !== c.winner);
|
|
123
|
+
console.warn(` - ${c.key}`);
|
|
124
|
+
console.warn(` winner: ${c.winner}`);
|
|
125
|
+
for (const l of losers) console.warn(` ignore: ${l}`);
|
|
126
|
+
}
|
|
127
|
+
console.warn(" Cause: multiple writers (manual sync vs deco-sync-bot) producing");
|
|
128
|
+
console.warn(" different filename encodings for the same logical key. Delete the");
|
|
129
|
+
console.warn(" stale file(s) listed under 'ignore' to silence this warning.");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Use single-decoded stem of the winning file as the decofile key.
|
|
133
|
+
// This matches the Deno runtime's `parseBlockId` (one decodeURIComponent)
|
|
134
|
+
// so that studio's `encodeURIComponent(blockKey)` round-trips back to the
|
|
135
|
+
// exact filename on disk.
|
|
136
|
+
const blocks: Record<string, unknown> = {};
|
|
137
|
+
for (const [_name, c] of Object.entries(winners)) {
|
|
138
|
+
blocks[singleDecodeBlockName(c.file)] = c.parsed;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
142
|
+
|
|
143
|
+
// 1. Compact JSON — the real data (no pretty-printing to save ~40% size)
|
|
144
|
+
const jsonStr = JSON.stringify(blocks);
|
|
145
|
+
fs.writeFileSync(jsonFile, jsonStr);
|
|
146
|
+
|
|
147
|
+
// 2. Thin TS wrapper — just for TypeScript tooling and as a Vite load target.
|
|
148
|
+
// Only write if content differs to avoid triggering Vite's file watcher,
|
|
149
|
+
// which would cascade module invalidation to the route tree and crash
|
|
150
|
+
// TanStack Router during dev hot-reload.
|
|
151
|
+
let existingTs: string | undefined;
|
|
152
|
+
try {
|
|
153
|
+
existingTs = fs.readFileSync(outFile, "utf-8");
|
|
154
|
+
} catch {}
|
|
155
|
+
if (existingTs !== TS_STUB) {
|
|
156
|
+
fs.writeFileSync(outFile, TS_STUB);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!silent) {
|
|
160
|
+
const jsonSizeMB = (Buffer.byteLength(jsonStr) / 1_048_576).toFixed(1);
|
|
161
|
+
console.log(
|
|
162
|
+
`Generated ${Object.keys(blocks).length} blocks → ${path.relative(process.cwd(), jsonFile)} (${jsonSizeMB} MB)`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
count: Object.keys(blocks).length,
|
|
168
|
+
collisions: collisions.length,
|
|
169
|
+
jsonFile,
|
|
170
|
+
outFile,
|
|
171
|
+
empty: false,
|
|
172
|
+
blocks,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface ReadBlockDeltaOptions {
|
|
177
|
+
blocksDir: string;
|
|
178
|
+
/**
|
|
179
|
+
* Changed block files as basenames within `blocksDir` (e.g.
|
|
180
|
+
* `pages-Home%2520(principal)-287364.json`), each tagged with whether the
|
|
181
|
+
* event was a delete.
|
|
182
|
+
*/
|
|
183
|
+
files: Array<{ name: string; isDelete: boolean }>;
|
|
184
|
+
/** Suppress per-file read warnings. Defaults to false. */
|
|
185
|
+
silent?: boolean;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Read ONLY the changed block files and return a delta map keyed by decofile
|
|
190
|
+
* key: `{ [key]: value }` for upserts, `{ [key]: null }` for deletes. The map
|
|
191
|
+
* is meant to be wrapped as `{ blocks: <delta> }` and POSTed to `/.decofile`,
|
|
192
|
+
* which applies it over the in-memory snapshot (see `applyDelta` in
|
|
193
|
+
* `src/admin/decofile.ts`).
|
|
194
|
+
*
|
|
195
|
+
* This is the incremental counterpart to `generateBlocks`. `generateBlocks`
|
|
196
|
+
* re-reads and re-merges the ENTIRE `.deco/blocks` directory and re-stringifies
|
|
197
|
+
* the whole snapshot — O(whole decofile), tens of MB of synchronous fs + JSON
|
|
198
|
+
* work that blocks the Node/Vite event loop for seconds on large sites (a CMS
|
|
199
|
+
* toggle that writes one 1 MB block file would otherwise re-process a 10 MB+
|
|
200
|
+
* decofile). `readBlockDelta` touches only the files that actually changed, so
|
|
201
|
+
* the dev watcher's hot path is O(changed files).
|
|
202
|
+
*
|
|
203
|
+
* Key derivation matches `generateBlocks` (`singleDecodeBlockName`). The
|
|
204
|
+
* cross-file collision dedupe that `generateBlocks` runs (see
|
|
205
|
+
* `lib/blocks-dedupe.ts`) is intentionally skipped here — the just-written
|
|
206
|
+
* file is treated as current truth ("newest write wins"), which is what a CMS
|
|
207
|
+
* editor expects. The full restart-time bootstrap regen reconciles any
|
|
208
|
+
* lingering collision via the dedupe logic.
|
|
209
|
+
*/
|
|
210
|
+
export function readBlockDelta(options: ReadBlockDeltaOptions): Record<string, unknown | null> {
|
|
211
|
+
const blocksDir = path.resolve(options.blocksDir);
|
|
212
|
+
const silent = options.silent ?? false;
|
|
213
|
+
const delta: Record<string, unknown | null> = {};
|
|
214
|
+
|
|
215
|
+
for (const { name, isDelete } of options.files) {
|
|
216
|
+
if (!name.endsWith(".json")) continue;
|
|
217
|
+
const key = singleDecodeBlockName(name);
|
|
218
|
+
|
|
219
|
+
if (isDelete) {
|
|
220
|
+
delta[key] = null;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const fp = path.join(blocksDir, name);
|
|
225
|
+
let parsed: unknown;
|
|
226
|
+
try {
|
|
227
|
+
parsed = JSON.parse(fs.readFileSync(fp, "utf-8"));
|
|
228
|
+
} catch (e) {
|
|
229
|
+
// File vanished mid-event or is a partial write in progress — skip it.
|
|
230
|
+
// A later watcher event (or the restart bootstrap) picks up the settled
|
|
231
|
+
// content, so dropping it here never permanently loses an update.
|
|
232
|
+
if (!silent) console.warn(`Failed to read changed block ${name}:`, e);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
delta[key] = parsed;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return delta;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// CLI shim — preserved so `npm run generate:blocks` and migration scripts
|
|
243
|
+
// keep working unchanged.
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
function isMainModule(): boolean {
|
|
247
|
+
// tsx/node ESM: import.meta.url matches process.argv[1] when invoked directly.
|
|
248
|
+
// Use a forgiving comparison so it works under both `tsx script.ts` and
|
|
249
|
+
// `node --import tsx script.ts`.
|
|
250
|
+
const entry = process.argv[1];
|
|
251
|
+
if (!entry) return false;
|
|
252
|
+
try {
|
|
253
|
+
const entryUrl = new URL(`file://${path.resolve(entry)}`).href;
|
|
254
|
+
return import.meta.url === entryUrl;
|
|
255
|
+
} catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (isMainModule()) {
|
|
261
|
+
const args = process.argv.slice(2);
|
|
262
|
+
const arg = (name: string, fallback: string): string => {
|
|
263
|
+
const idx = args.indexOf(`--${name}`);
|
|
264
|
+
return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const blocksDir = path.resolve(process.cwd(), arg("blocks-dir", ".deco/blocks"));
|
|
268
|
+
const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/blocks.gen.ts"));
|
|
269
|
+
|
|
270
|
+
generateBlocks({ blocksDir, outFile }).catch((err) => {
|
|
271
|
+
console.error(err);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for scripts/generate-invoke.ts.
|
|
3
|
+
*
|
|
4
|
+
* The generator scans a `vtex/invoke.ts` file from @decocms/apps and emits a
|
|
5
|
+
* site-local `src/server/invoke.gen.ts` with top-level `createServerFn`
|
|
6
|
+
* declarations. The piece we care most about locking is the Set-Cookie
|
|
7
|
+
* bridge: every handler must call `forwardResponseCookies()` after the
|
|
8
|
+
* action awaits, so VTEX-set cookies captured by `vtexFetchWithCookies`
|
|
9
|
+
* reach the browser via TanStack Start's HTTP response. Without this,
|
|
10
|
+
* `checkout.vtex.com` never reaches the browser, the storefront's
|
|
11
|
+
* mini-cart and VTEX's server-side orderForm drift apart, and /checkout
|
|
12
|
+
* loads with an empty cart.
|
|
13
|
+
*
|
|
14
|
+
* We exercise the generator end-to-end against a minimal fixture
|
|
15
|
+
* `invoke.ts` rather than unit-test internal helpers — the failure
|
|
16
|
+
* mode we want to prevent (a missing `forwardResponseCookies()` call
|
|
17
|
+
* in the emit string) only shows up in the produced source text.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as os from "node:os";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
25
|
+
|
|
26
|
+
const GENERATOR = path.resolve(__dirname, "generate-invoke.ts");
|
|
27
|
+
|
|
28
|
+
const FIXTURE_INVOKE_TS = `\
|
|
29
|
+
import { createInvokeFn } from "@decocms/start/sdk/createInvoke";
|
|
30
|
+
import { getOrCreateCart, simulateCart } from "./actions/checkout";
|
|
31
|
+
import { createSession } from "./actions/session";
|
|
32
|
+
import type { OrderForm } from "./types";
|
|
33
|
+
|
|
34
|
+
export const invoke = {
|
|
35
|
+
vtex: {
|
|
36
|
+
actions: {
|
|
37
|
+
// Direct pass-through wrapper — most common shape.
|
|
38
|
+
getOrCreateCart: createInvokeFn(
|
|
39
|
+
(data: { orderFormId?: string }) => getOrCreateCart(data),
|
|
40
|
+
) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise<OrderForm>,
|
|
41
|
+
|
|
42
|
+
simulateCart: createInvokeFn(
|
|
43
|
+
(data: { postalCode: string }) => simulateCart(data),
|
|
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
|
+
),
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
} as const;
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
// Minimal stubs the generator's import-resolution doesn't *execute* but
|
|
58
|
+
// ts-morph parses these to populate the importMap. We only need names to
|
|
59
|
+
// resolve.
|
|
60
|
+
const FIXTURE_ACTIONS_CHECKOUT_TS = `\
|
|
61
|
+
export async function getOrCreateCart(_data: any): Promise<any> { return null; }
|
|
62
|
+
export async function simulateCart(_data: any): Promise<any> { return null; }
|
|
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
|
+
`;
|
|
68
|
+
const FIXTURE_TYPES_TS = `export type OrderForm = unknown;\n`;
|
|
69
|
+
|
|
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.
|
|
76
|
+
let appsDir: string;
|
|
77
|
+
let siteDir: string;
|
|
78
|
+
let outFile: string;
|
|
79
|
+
let generatedOutput: string;
|
|
80
|
+
let generatorStatus: number | null;
|
|
81
|
+
|
|
82
|
+
beforeAll(() => {
|
|
83
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-"));
|
|
84
|
+
appsDir = path.join(tmp, "apps");
|
|
85
|
+
siteDir = path.join(tmp, "site");
|
|
86
|
+
fs.mkdirSync(path.join(appsDir, "vtex", "actions"), { recursive: true });
|
|
87
|
+
fs.mkdirSync(path.join(siteDir, "src", "server"), { recursive: true });
|
|
88
|
+
fs.writeFileSync(path.join(appsDir, "vtex", "invoke.ts"), FIXTURE_INVOKE_TS);
|
|
89
|
+
fs.writeFileSync(
|
|
90
|
+
path.join(appsDir, "vtex", "actions", "checkout.ts"),
|
|
91
|
+
FIXTURE_ACTIONS_CHECKOUT_TS,
|
|
92
|
+
);
|
|
93
|
+
fs.writeFileSync(
|
|
94
|
+
path.join(appsDir, "vtex", "actions", "session.ts"),
|
|
95
|
+
FIXTURE_ACTIONS_SESSION_TS,
|
|
96
|
+
);
|
|
97
|
+
fs.writeFileSync(path.join(appsDir, "vtex", "types.ts"), FIXTURE_TYPES_TS);
|
|
98
|
+
outFile = path.join(siteDir, "src", "server", "invoke.gen.ts");
|
|
99
|
+
|
|
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(() => {
|
|
110
|
+
// Best-effort cleanup; tmp dirs leak otherwise.
|
|
111
|
+
try {
|
|
112
|
+
fs.rmSync(path.dirname(appsDir), { recursive: true, force: true });
|
|
113
|
+
} catch {
|
|
114
|
+
// ignore
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
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
|
+
});
|
|
123
|
+
|
|
124
|
+
it("imports the framework helpers needed for Set-Cookie propagation", () => {
|
|
125
|
+
// Both imports must be present — without them, forwardResponseCookies
|
|
126
|
+
// doesn't compile in the site, and the regression we're fixing
|
|
127
|
+
// resurfaces silently when someone deletes one of them.
|
|
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/blocks/sdk/requestContext"',
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("emits the forwardResponseCookies helper exactly once", () => {
|
|
136
|
+
const declMatches = generatedOutput.match(/function forwardResponseCookies\(\)/g);
|
|
137
|
+
expect(declMatches).toHaveLength(1);
|
|
138
|
+
|
|
139
|
+
// The helper must read from RequestContext.responseHeaders and call
|
|
140
|
+
// setResponseHeader. Locking the bridge ends-to-ends.
|
|
141
|
+
expect(generatedOutput).toContain("RequestContext.current");
|
|
142
|
+
expect(generatedOutput).toContain("ctx.responseHeaders.getSetCookie");
|
|
143
|
+
expect(generatedOutput).toContain('setResponseHeader("set-cookie"');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("calls forwardResponseCookies() inside every generated handler", () => {
|
|
147
|
+
// Each .handler(async ({ data }) ...) block produced by the
|
|
148
|
+
// generator must contain a `forwardResponseCookies()` call. We
|
|
149
|
+
// count handlers vs. call sites — excluding the declaration site
|
|
150
|
+
// (`function forwardResponseCookies()`), which also matches the
|
|
151
|
+
// bare `forwardResponseCookies()` token.
|
|
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;
|
|
156
|
+
const callSites = allOccurrences - declSites;
|
|
157
|
+
|
|
158
|
+
expect(handlerCount).toBe(3);
|
|
159
|
+
expect(declSites).toBe(1);
|
|
160
|
+
expect(callSites).toBe(3);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("calls forwardResponseCookies AFTER the action awaits (so RequestContext is populated)", () => {
|
|
164
|
+
// The action must complete (await result) before we read the
|
|
165
|
+
// captured Set-Cookies — otherwise we'd forward an empty set
|
|
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.
|
|
169
|
+
const pattern =
|
|
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);");
|
|
194
|
+
});
|
|
195
|
+
});
|