@commercebuild/extension 0.0.22 → 0.0.23

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercebuild/extension",
3
- "version": "0.0.22",
3
+ "version": "0.0.23",
4
4
  "types": "./types/index.d.ts",
5
5
  "exports": {
6
6
  ".": {
@@ -11,7 +11,7 @@
11
11
  * the canonical side of that parity contract.
12
12
  */
13
13
  import assert from "node:assert/strict";
14
- import { execFileSync } from "node:child_process";
14
+ import { execFileSync, spawn } from "node:child_process";
15
15
  import {
16
16
  cpSync,
17
17
  existsSync,
@@ -26,11 +26,23 @@ import { tmpdir } from "node:os";
26
26
  import path from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
28
 
29
- const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
29
+ const pkgRoot = path.resolve(
30
+ path.dirname(fileURLToPath(import.meta.url)),
31
+ "..",
32
+ );
30
33
  const viteBin = path.join(pkgRoot, "node_modules", ".bin", "vite");
31
34
  const viteConfig = path.join(pkgRoot, "config", "vite.config.mjs");
35
+ const cliBin = path.join(pkgRoot, "scripts", "cli.js");
32
36
 
33
- const fixture = mkdtempSync(path.join(tmpdir(), "cb-ext-build-"));
37
+ // The fixture lives in a folder whose name is hostile to every path
38
+ // consumer we have: a shell (space, parentheses, `$`, `&`), a glob
39
+ // matcher (`(`, `[`), and anything ASCII-only. `extension open` numbers
40
+ // duplicate downloads "<name>(2)" and users pick their own folder names, so
41
+ // build, dev and type-check must work here or they do not work.
42
+ const HOSTILE_DIR = "My App (2) [beta] $x & y 中文";
43
+ const scratch = mkdtempSync(path.join(tmpdir(), "cb-ext-build-"));
44
+ const fixture = path.join(scratch, HOSTILE_DIR);
45
+ mkdirSync(fixture);
34
46
 
35
47
  function write(rel, content) {
36
48
  const target = path.join(fixture, rel);
@@ -83,7 +95,10 @@ function scaffold() {
83
95
  "src/admin/index.ts",
84
96
  'import "./styles/index.css";\nexport * as adminPages from "./pages";\n',
85
97
  );
86
- write("src/admin/pages/index.ts", 'export * as Dashboard from "./dashboard";\n');
98
+ write(
99
+ "src/admin/pages/index.ts",
100
+ 'export * as Dashboard from "./dashboard";\n',
101
+ );
87
102
  write(
88
103
  "src/admin/pages/dashboard.tsx",
89
104
  'import React from "react";\nimport { getFirestore } from "firebase/firestore";\nimport { shared } from "../../shared/util";\nexport default function Dashboard() {\n const [n] = React.useState(0);\n return <div className="p-3">{String(!!getFirestore)}{shared()}{n}</div>;\n}\n',
@@ -142,7 +157,11 @@ try {
142
157
  assert.match(style, /\.p-2\b/, "style.css has shared utilities");
143
158
  assert.doesNotMatch(style, /\.p-3\b/, "style.css has NO admin utilities");
144
159
  const adminCss = dist("admin.css");
145
- assert.doesNotMatch(adminCss, /\.p-1\b/, "admin.css has NO storefront utilities");
160
+ assert.doesNotMatch(
161
+ adminCss,
162
+ /\.p-1\b/,
163
+ "admin.css has NO storefront utilities",
164
+ );
146
165
  assert.match(adminCss, /\.p-2\b/, "admin.css has shared utilities");
147
166
  assert.match(adminCss, /\.p-3\b/, "admin.css has admin utilities");
148
167
 
@@ -165,19 +184,19 @@ try {
165
184
  "src/admin/pages/bad.ts",
166
185
  'export { x } from "../../cms/bad-target";\n',
167
186
  "src/admin/pages/index.ts",
168
- './bad',
187
+ "./bad",
169
188
  ],
170
189
  [
171
190
  "src/pages/bad.ts",
172
191
  'export { helper } from "../admin/helper";\n',
173
192
  "src/pages/index.ts",
174
- './bad',
193
+ "./bad",
175
194
  ],
176
195
  [
177
196
  "src/shared/bad.ts",
178
197
  'export { helper } from "../admin/helper";\n',
179
198
  "src/pages/index.ts",
180
- '../shared/bad',
199
+ "../shared/bad",
181
200
  ],
182
201
  ];
183
202
  write("src/cms/bad-target.ts", "export const x = 1;\n");
@@ -217,7 +236,105 @@ try {
217
236
  /src\/admin\/index\.ts/,
218
237
  );
219
238
 
239
+ // --- The CLI entry in the hostile folder: no shell, exit codes kept. ---
240
+ rmSync(path.join(fixture, "dist"), { recursive: true, force: true });
241
+ execFileSync(process.execPath, [cliBin, "build"], {
242
+ cwd: fixture,
243
+ stdio: "pipe",
244
+ });
245
+ assert.ok(
246
+ existsSync(path.join(fixture, "dist", "index.js")),
247
+ "cli.js build works in a folder with spaces, parentheses and unicode",
248
+ );
249
+ assert.throws(
250
+ () =>
251
+ execFileSync(process.execPath, [cliBin], { cwd: fixture, stdio: "pipe" }),
252
+ (err) => err.status === 1,
253
+ "cli.js without a command exits 1",
254
+ );
255
+
256
+ // --- Dev server: one source change = one rebuild, and the build's own
257
+ // output never counts as a change (the loop a "(2)" folder used to cause).
258
+ await devServerRebuildsOnce();
259
+
220
260
  console.log("extension dual-build integration test: OK");
221
261
  } finally {
222
- rmSync(fixture, { recursive: true, force: true });
262
+ rmSync(scratch, { recursive: true, force: true });
263
+ }
264
+
265
+ /**
266
+ * Starts the dev server in the fixture, edits one source file, and asserts
267
+ * exactly one rebuild follows — then writes into dist/ directly and asserts
268
+ * that triggers nothing. Kills the server either way.
269
+ */
270
+ async function devServerRebuildsOnce() {
271
+ const port = 25000 + Math.floor(Math.random() * 500);
272
+ let output = "";
273
+ const server = spawn(viteBin, ["--config", viteConfig], {
274
+ cwd: fixture,
275
+ env: {
276
+ ...process.env,
277
+ COMMERCEBUILD_STORE_URL: "https://store.example/",
278
+ PORT: String(port),
279
+ BROWSER: "none", // vite's `server.open` honours this: no browser tab
280
+ },
281
+ stdio: ["ignore", "pipe", "pipe"],
282
+ });
283
+ server.stdout.on("data", (chunk) => (output += chunk));
284
+ server.stderr.on("data", (chunk) => (output += chunk));
285
+ const count = (needle) => output.split(needle).length - 1;
286
+ const waitFor = async (predicate, ms, what) => {
287
+ const deadline = Date.now() + ms;
288
+ while (Date.now() < deadline) {
289
+ if (predicate()) return;
290
+ await new Promise((r) => setTimeout(r, 200));
291
+ }
292
+ assert.fail(`${what} (dev server output so far:\n${output})`);
293
+ };
294
+ const settle = (ms) => new Promise((r) => setTimeout(r, ms));
295
+ try {
296
+ await waitFor(
297
+ () => output.includes("WebSocket mounted"),
298
+ 30_000,
299
+ "dev server did not start",
300
+ );
301
+ await settle(1_000); // let the watcher finish its initial scan
302
+ write(
303
+ "src/pages/shop.tsx",
304
+ readFileSync(path.join(fixture, "src/pages/shop.tsx"), "utf8") +
305
+ "// touched\n",
306
+ );
307
+ await waitFor(
308
+ () => count("Build finished") >= 1,
309
+ 30_000,
310
+ "source change did not trigger a rebuild",
311
+ );
312
+ await settle(4_000); // a self-triggered loop would show up here
313
+ assert.equal(
314
+ count("File changed"),
315
+ 1,
316
+ `one change → one rebuild:\n${output}`,
317
+ );
318
+ assert.doesNotMatch(
319
+ output,
320
+ /File changed: [^\n]*[\\/]dist[\\/]/,
321
+ "dist/ never counts as a change",
322
+ );
323
+
324
+ writeFileSync(path.join(fixture, "dist", "style.css"), "/* poked */\n", {
325
+ flag: "a",
326
+ });
327
+ await settle(3_000);
328
+ assert.equal(
329
+ count("File changed"),
330
+ 1,
331
+ "writing into dist/ triggers nothing",
332
+ );
333
+ assert.doesNotMatch(output, /Build failed/);
334
+ } finally {
335
+ server.kill("SIGTERM");
336
+ await new Promise(
337
+ (r) => server.once("exit", r).unref?.() ?? setTimeout(r, 2000),
338
+ );
339
+ }
223
340
  }
@@ -18,38 +18,74 @@ export default function ViteHMRNotifierPlugin() {
18
18
  }
19
19
  });
20
20
  console.debug("[hmr-notifier] WebSocket mounted at /hmr-notifier");
21
- let building = false;
22
- let pendingChanges = false;
23
21
 
24
- server.watcher.on("change", async (file) => {
22
+ // Rebuild only for the project's own files. The watcher also reports
23
+ // the build's OWN output: vite ignores outDir through a glob built
24
+ // from the absolute path, and that glob stops matching when the
25
+ // folder name contains glob characters ("my-app(2)" — `open` numbers
26
+ // duplicate downloads that way), so every build then triggered the
27
+ // next one. Compare paths as plain prefixes instead; no globs.
28
+ const root = path.resolve(server.config.root);
29
+ const outDir = path.resolve(root, server.config.build.outDir);
30
+ // vite's dependency cache (.vite/deps_temp_*) is ignored by vite the
31
+ // same glob way and leaks through for the same folder names.
32
+ const cacheDir = path.resolve(root, server.config.cacheDir);
33
+ const inside = (dir, file) =>
34
+ file === dir || file.startsWith(dir + path.sep);
35
+ const isSourceChange = (file) => {
36
+ const abs = path.resolve(file);
37
+ if (!inside(root, abs)) return false;
38
+ if (inside(outDir, abs) || inside(cacheDir, abs)) return false;
39
+ const segments = abs.slice(root.length).split(path.sep);
40
+ return !segments.some(
41
+ (segment) =>
42
+ segment === "node_modules" ||
43
+ segment.startsWith(".") || // .git, .vite, .env, editor swap files
44
+ segment.endsWith(".log"),
45
+ );
46
+ };
47
+
48
+ // Single-flight with coalescing: changes that arrive during a build
49
+ // queue exactly ONE follow-up build (not one per file, and not a
50
+ // synthetic "change" event that would log and could re-trigger).
51
+ let building = false;
52
+ let pending = false;
53
+ const notify = () => {
54
+ wss.clients.forEach((client) => {
55
+ if (client.readyState === 1) {
56
+ client.send(JSON.stringify({ type: "reload-remote-component" }));
57
+ }
58
+ });
59
+ };
60
+ const rebuild = async (file) => {
25
61
  if (building) {
26
- pendingChanges = true;
62
+ pending = true;
27
63
  return;
28
64
  }
29
65
  building = true;
30
66
  console.debug(`[hmr-notifier] File changed: ${file}, Rebuilding...`);
31
67
  try {
32
- await build({
33
- configFile: path.resolve(__dirname, "../config/vite.config.mjs"),
34
- logLevel: "silent",
35
- });
68
+ do {
69
+ pending = false;
70
+ await build({
71
+ configFile: path.resolve(__dirname, "../config/vite.config.mjs"),
72
+ logLevel: "silent",
73
+ });
74
+ } while (pending);
36
75
  console.debug(`[hmr-notifier] Build finished, notifying clients...`);
37
- wss.clients.forEach((client) => {
38
- if (client.readyState === 1) {
39
- client.send(JSON.stringify({ type: "reload-remote-component" }));
40
- }
41
- });
76
+ notify();
42
77
  } catch (err) {
43
78
  console.error(`[hmr-notifier] Build failed:`, err);
44
79
  } finally {
45
80
  building = false;
46
- if (pendingChanges) {
47
- pendingChanges = false;
48
- // Emit change event to re-trigger build after current build completes
49
- server.watcher.emit("change", file);
50
- }
51
81
  }
52
- });
82
+ };
83
+
84
+ for (const event of ["change", "add", "unlink"]) {
85
+ server.watcher.on(event, (file) => {
86
+ if (isSourceChange(file)) void rebuild(file);
87
+ });
88
+ }
53
89
  },
54
90
  };
55
91
  }