@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 +1 -1
- package/scripts/test-build.mjs +126 -9
- package/scripts/vite-plugin-hmr-notifier.mjs +55 -19
package/package.json
CHANGED
package/scripts/test-build.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
}
|