@heroiclands/package-build 0.5.0 → 0.6.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/e2e.mjs ADDED
@@ -0,0 +1,876 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The end-to-end harness: a disposable Foundry world, served from a container,
16
+ * with a browser suite driven against it.
17
+ *
18
+ * **The harness does not know what the suite is.** That is the whole division
19
+ * of labour here. Standing a licensed Foundry up, seeding a world whose
20
+ * Gamemaster password is known, waiting for that world to be *active* rather
21
+ * than merely reachable, tearing it all down again — none of that is one
22
+ * repository's problem, and all of it used to live in one. What runs against
23
+ * the served world is named in `packageBuild.e2e.suite`, the same way an asset
24
+ * transform or a manifest-flags module is named: the repository's code, the
25
+ * toolchain's plumbing.
26
+ *
27
+ * Three shapes of run, and they answer different questions:
28
+ *
29
+ * - **`run`** — from scratch. Deploy, reseed, recreate the container, wait,
30
+ * run, tear down. The only path that may change Foundry build, because the
31
+ * seeded world is stamped with the build that created it and Foundry refuses
32
+ * to auto-launch a world stamped by another.
33
+ * - **`fast`** — the iteration loop. Rebuild what changed, redeploy, cycle the
34
+ * world, wait, re-run. Every step of it has a quiet failure mode, which is
35
+ * why it is one command rather than a remembered sequence.
36
+ * - **`sweep`** — the same full run against a build the repository does *not*
37
+ * pin, so `compatibility.verified` can be evidence rather than hope.
38
+ *
39
+ * @module
40
+ */
41
+
42
+ import crypto from "node:crypto";
43
+ import fs from "node:fs/promises";
44
+ import path from "node:path";
45
+ import process from "node:process";
46
+ import { spawnSync } from "node:child_process";
47
+
48
+ import { deployStage } from "./deploy.mjs";
49
+ import {
50
+ containerAction,
51
+ containerExists,
52
+ dataEnvVar,
53
+ resolveContainer,
54
+ resolveDataRoot,
55
+ runDocker,
56
+ runningFoundryContainers,
57
+ } from "./container.mjs";
58
+
59
+ /**
60
+ * The Gamemaster's document id in every seeded world.
61
+ *
62
+ * Fixed, and deliberately not derived from the package: a spec has to know it
63
+ * without a handoff from the seed, and the world is disposable, so there is
64
+ * nothing for a per-package id to disambiguate. Sixteen alphanumeric
65
+ * characters, which is the shape Foundry document ids take.
66
+ */
67
+ export const E2E_GM_ID = "heroiclandsE2EGM";
68
+
69
+ /**
70
+ * The seeded, pre-activated default scene's id.
71
+ *
72
+ * A non-empty world means Foundry's New User Experience does not auto-start the
73
+ * welcome tour, whose callout overlays sheets; an *active* scene at load means
74
+ * the canvas is ready, which several read paths depend on.
75
+ */
76
+ export const E2E_SCENE_ID = "heroiclandsScene";
77
+
78
+ /** The world setting that switches a module on. */
79
+ const E2E_MODULE_SETTING_ID = "heroiclandsMods1";
80
+
81
+ /** How the suite may be run. */
82
+ export const E2E_MODES = Object.freeze(["run", "open"]);
83
+
84
+ /** An exact Foundry build: a major and a build number, nothing else. */
85
+ const EXACT_BUILD = /^\d+\.\d+$/;
86
+
87
+ /**
88
+ * Hash a password the way Foundry's `core/auth.mjs` does: pbkdf2, 1000 rounds,
89
+ * 64 bytes, sha512. A different shape logs nobody in.
90
+ *
91
+ * @param {string} password - The plaintext password.
92
+ * @param {string} salt - The hex salt.
93
+ * @returns {string} The hex hash.
94
+ */
95
+ export function hashPassword(password, salt) {
96
+ return crypto
97
+ .pbkdf2Sync(password, salt, 1000, 64, "sha512")
98
+ .toString("hex");
99
+ }
100
+
101
+ /**
102
+ * The disposable world's identity and credentials.
103
+ *
104
+ * @typedef {object} E2EWorld
105
+ * @property {string} worldId
106
+ * @property {string} worldTitle
107
+ * @property {string} worldDescription
108
+ * @property {string} gmId
109
+ * @property {string} gmName
110
+ * @property {string} gmPassword
111
+ */
112
+
113
+ /**
114
+ * Resolve the seeded world from configuration and the environment.
115
+ *
116
+ * Everything derives from the package id unless the repository says otherwise,
117
+ * and the environment overrides under the repository's own variable prefix —
118
+ * the same prefix the deploy already uses — so a contributor can point a run at
119
+ * a scratch world without touching committed configuration.
120
+ *
121
+ * @param {object} config - The resolved package-build configuration.
122
+ * @param {NodeJS.ProcessEnv} [env] - Environment to read.
123
+ * @returns {E2EWorld} The resolved world.
124
+ */
125
+ export function resolveE2EWorld(config, env = process.env) {
126
+ const prefix = `${config.envPrefix}_E2E_`;
127
+ const world = config.e2eWorld ?? {};
128
+ const gm = config.e2eGm ?? {};
129
+ return {
130
+ worldId:
131
+ env[`${prefix}WORLD_ID`]?.trim() ||
132
+ world.id ||
133
+ `${config.packageId}-e2e`,
134
+ worldTitle:
135
+ env[`${prefix}WORLD_TITLE`]?.trim() ||
136
+ world.title ||
137
+ `${config.packageId} E2E`,
138
+ worldDescription:
139
+ world.description ||
140
+ `Disposable world for ${config.packageId} end-to-end tests.`,
141
+ gmId: E2E_GM_ID,
142
+ gmName: env[`${prefix}GM_NAME`]?.trim() || gm.name || "Gamemaster",
143
+ gmPassword:
144
+ env[`${prefix}GM_PASSWORD`]?.trim() ||
145
+ gm.password ||
146
+ `${config.packageId}-e2e`,
147
+ };
148
+ }
149
+
150
+ /**
151
+ * The `world.json` a seeded world carries.
152
+ *
153
+ * Foundry re-stamps `coreVersion`, `systemVersion` and `compatibility` from the
154
+ * running core on launch, so a generation-level range is enough here — the
155
+ * point is a world that launches without a migration prompt or a setup step.
156
+ *
157
+ * @param {object} opts
158
+ * @param {string} opts.worldId
159
+ * @param {string} opts.worldTitle
160
+ * @param {string} opts.worldDescription
161
+ * @param {string} opts.systemId - The system the world runs.
162
+ * @param {string} opts.systemVersion - That system's version.
163
+ * @param {string} opts.coreVersion - The Foundry generation.
164
+ * @returns {object} The world manifest.
165
+ */
166
+ export function worldManifest({
167
+ worldId,
168
+ worldTitle,
169
+ worldDescription,
170
+ systemId,
171
+ systemVersion,
172
+ coreVersion,
173
+ }) {
174
+ return {
175
+ id: worldId,
176
+ title: worldTitle,
177
+ description: worldDescription,
178
+ system: systemId,
179
+ coreVersion,
180
+ systemVersion,
181
+ compatibility: { minimum: coreVersion, verified: coreVersion },
182
+ background: "",
183
+ nextSession: null,
184
+ resetKeys: false,
185
+ safeMode: false,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * The seeded world's single Gamemaster.
191
+ *
192
+ * @param {object} opts
193
+ * @param {string} opts.id - The fixed document id.
194
+ * @param {string} opts.name - The user name.
195
+ * @param {string} opts.password - The plaintext password.
196
+ * @param {string} opts.salt - The hex salt to hash it with.
197
+ * @returns {object} The user document.
198
+ */
199
+ export function gmDocument({ id, name, password, salt }) {
200
+ return {
201
+ _id: id,
202
+ name,
203
+ role: 4, // GAMEMASTER
204
+ password: hashPassword(password, salt),
205
+ passwordSalt: salt,
206
+ permissions: {},
207
+ flags: {},
208
+ _key: `!users!${id}`,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * The one pre-activated scene every seeded world carries.
214
+ *
215
+ * @returns {object} The scene document.
216
+ */
217
+ export function defaultSceneDocument() {
218
+ return {
219
+ _id: E2E_SCENE_ID,
220
+ name: "E2E Default Scene",
221
+ active: true,
222
+ width: 2000,
223
+ height: 2000,
224
+ padding: 0.25,
225
+ grid: { type: 1, size: 100 }, // 1 = CONST.GRID_TYPES.SQUARE
226
+ _key: `!scenes!${E2E_SCENE_ID}`,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * The world setting that switches a module package on.
232
+ *
233
+ * A **system** is the world's own — `world.json` names it and Foundry loads it.
234
+ * A **module** is not: it has to be activated, and the switch is
235
+ * `core.moduleConfiguration`, a world setting holding a JSON map of package id
236
+ * to enabled. Without it a module repository would stand its suite up against a
237
+ * world that never loaded the very thing under test.
238
+ *
239
+ * @param {string} packageId - The module to enable.
240
+ * @returns {object} The setting document.
241
+ */
242
+ export function moduleConfigurationDocument(packageId) {
243
+ return {
244
+ _id: E2E_MODULE_SETTING_ID,
245
+ key: "core.moduleConfiguration",
246
+ value: JSON.stringify({ [packageId]: true }),
247
+ _key: `!settings!${E2E_MODULE_SETTING_ID}`,
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Whether a `/join` response shows a world that is actually **active**.
253
+ *
254
+ * Foundry answers on the port long before a world is serving; a suite started
255
+ * at that moment fails every spec for no visible reason. The join screen
256
+ * renders the form only once a world is active, so that is what is waited for.
257
+ *
258
+ * @param {string} body - The response body.
259
+ * @returns {boolean} Whether the world is active.
260
+ */
261
+ export function isWorldActive(body) {
262
+ return body.includes('id="join-game"');
263
+ }
264
+
265
+ /**
266
+ * The build a sweep was asked to run against.
267
+ *
268
+ * There is no default, deliberately. The product of a sweep is a citable result
269
+ * — "the full suite passed on 14.367" — and a hard-coded "newest release" would
270
+ * rot on Foundry's next release day, quietly turning the sweep into a second
271
+ * pinned build. A bare major or a tag is refused for the same reason: the image
272
+ * passes it through verbatim, so the run would name no particular Foundry.
273
+ *
274
+ * @param {string[]} argv - Arguments after the action.
275
+ * @returns {string} The exact build, trimmed.
276
+ * @throws {Error} When none was given, or it is not an exact build.
277
+ */
278
+ export function resolveSweepVersion(argv) {
279
+ const version = (argv[0] ?? "").trim();
280
+ if (!version) {
281
+ throw new Error(
282
+ "A sweep must name the build it runs against, e.g. " +
283
+ "`package-build e2e sweep 14.367`. There is no default: the " +
284
+ "point of a sweep is a result you can cite, and the newest " +
285
+ "release is not a constant a repository can hold.",
286
+ );
287
+ }
288
+ if (!EXACT_BUILD.test(version)) {
289
+ throw new Error(
290
+ `"${version}" is not an exact Foundry build. Name a major and a ` +
291
+ "build number (e.g. `14.367`) — a bare major or a tag resolves " +
292
+ "to whatever the registry serves that day, so the run would " +
293
+ "name no particular Foundry.",
294
+ );
295
+ }
296
+ return version;
297
+ }
298
+
299
+ /**
300
+ * What a fast loop was asked to do.
301
+ *
302
+ * @typedef {object} FastArgs
303
+ * @property {string[]} targets Build targets, in declared order.
304
+ * @property {boolean} recreate Whether the container must be recreated.
305
+ * @property {boolean} runSuite Whether to run the suite at all.
306
+ * @property {string[]} suiteArgs Arguments handed to the suite verbatim.
307
+ */
308
+
309
+ /**
310
+ * Parse the fast loop's arguments against the repository's build table.
311
+ *
312
+ * Build order is **declaration order**, not the order they were asked for: a
313
+ * bundler that empties the stage has to run before the passes that copy into
314
+ * it, and the repository already stated that by writing them down in order.
315
+ *
316
+ * @param {string[]} argv - Arguments after the action.
317
+ * @param {Record<string, {script: string, recreate: boolean}>} build - The
318
+ * declared build table.
319
+ * @returns {FastArgs} What to do.
320
+ * @throws {Error} On an unknown build target.
321
+ */
322
+ export function parseFastArgs(argv, build) {
323
+ const passthroughAt = argv.indexOf("--");
324
+ const own = passthroughAt === -1 ? argv : argv.slice(0, passthroughAt);
325
+ const suiteArgs = passthroughAt === -1 ? [] : argv.slice(passthroughAt + 1);
326
+
327
+ let requested = "all";
328
+ let recreate = false;
329
+ let runSuite = true;
330
+
331
+ for (let i = 0; i < own.length; i += 1) {
332
+ const arg = /** @type {string} */ (own[i]);
333
+ if (arg.startsWith("--build="))
334
+ requested = arg.slice("--build=".length);
335
+ else if (arg === "--recreate") recreate = true;
336
+ else if (arg === "--no-run") runSuite = false;
337
+ else if (arg === "--spec") {
338
+ suiteArgs.push("--spec", own[i + 1] ?? "");
339
+ i += 1;
340
+ } else if (arg.startsWith("--spec=")) {
341
+ suiteArgs.push("--spec", arg.slice("--spec=".length));
342
+ } else suiteArgs.push(arg);
343
+ }
344
+
345
+ const declared = Object.keys(build);
346
+ let wanted;
347
+ if (requested === "all") wanted = declared;
348
+ else if (requested === "none") wanted = [];
349
+ else {
350
+ wanted = requested
351
+ .split(",")
352
+ .map((target) => target.trim())
353
+ .filter(Boolean);
354
+ for (const target of wanted) {
355
+ if (!build[target]) {
356
+ throw new Error(
357
+ `Unknown build target '${target}'. ` +
358
+ (declared.length ?
359
+ `Declared under \`packageBuild.e2e.build\`: ` +
360
+ `${declared.join(", ")}, plus all and none.`
361
+ : `This repository declares none.`),
362
+ );
363
+ }
364
+ }
365
+ }
366
+
367
+ const targets = declared.filter((target) => wanted.includes(target));
368
+ // A target that changes something read once at world launch — the manifest
369
+ // — needs the world relaunched, not merely the files replaced.
370
+ if (targets.some((target) => build[target].recreate)) recreate = true;
371
+
372
+ return { targets, recreate, runSuite, suiteArgs };
373
+ }
374
+
375
+ /**
376
+ * Seed the disposable world into the end-to-end stage's data root.
377
+ *
378
+ * Writes `world.json` and compiles each LevelDB collection from JSON, so the
379
+ * result is a genuine world Foundry launches without migration or setup. The
380
+ * world directory is wiped and rewritten each time, which is what makes a run
381
+ * repeatable.
382
+ *
383
+ * @param {object} opts
384
+ * @param {object} opts.config - The resolved package-build configuration.
385
+ * @param {object} opts.packageJson - The repository's `package.json`.
386
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
387
+ * @param {(message: string) => void} [opts.log] - Progress reporting.
388
+ * @returns {Promise<{worldDir: string, world: E2EWorld}>} Where it landed.
389
+ */
390
+ export async function seedTestWorld({
391
+ config,
392
+ packageJson,
393
+ env = process.env,
394
+ log = () => {},
395
+ }) {
396
+ const stage = config.e2eStage;
397
+ const dataRoot = requireIsolatedDataRoot(stage, env);
398
+ const world = resolveE2EWorld(config, env);
399
+
400
+ const worldDir = path.join(dataRoot, "Data", "worlds", world.worldId);
401
+ await fs.rm(worldDir, { recursive: true, force: true });
402
+ await fs.mkdir(worldDir, { recursive: true });
403
+
404
+ const coreVersion = String(config.compatibilityMinimum ?? "").split(".")[0];
405
+ if (!coreVersion) {
406
+ throw new Error(
407
+ "Cannot seed a world without knowing which Foundry generation it " +
408
+ "is for. Declare `compatibility.minimum` at the top level of " +
409
+ "content-build.config.yaml.",
410
+ );
411
+ }
412
+
413
+ await fs.writeFile(
414
+ path.join(worldDir, "world.json"),
415
+ JSON.stringify(
416
+ worldManifest({
417
+ ...world,
418
+ systemId: config.systemId,
419
+ systemVersion: config.systemVersion ?? packageJson.version,
420
+ coreVersion,
421
+ }),
422
+ null,
423
+ 2,
424
+ ) + "\n",
425
+ );
426
+
427
+ // Everything the world holds, as collection → documents. The built-ins are
428
+ // what makes a world *testable* rather than what makes it this
429
+ // repository's: one known Gamemaster, and one active scene.
430
+ const salt = crypto.randomBytes(32).toString("hex");
431
+ const collections = {
432
+ users: [
433
+ gmDocument({
434
+ id: world.gmId,
435
+ name: world.gmName,
436
+ password: world.gmPassword,
437
+ salt,
438
+ }),
439
+ ],
440
+ scenes: [defaultSceneDocument()],
441
+ };
442
+ if (config.packageKind === "modules") {
443
+ collections.settings = [moduleConfigurationDocument(config.packageId)];
444
+ }
445
+
446
+ const { compilePack } = await import("@foundryvtt/foundryvtt-cli");
447
+ const sourceRoot = path.join(worldDir, ".seed-src");
448
+ for (const [collection, documents] of Object.entries(collections)) {
449
+ const sourceDir = path.join(sourceRoot, collection);
450
+ await fs.mkdir(sourceDir, { recursive: true });
451
+ for (const [index, document] of documents.entries()) {
452
+ await fs.writeFile(
453
+ path.join(sourceDir, `${index}-${document._id}.json`),
454
+ JSON.stringify(document, null, 2) + "\n",
455
+ );
456
+ }
457
+ }
458
+
459
+ // Whatever else the repository wants in the world — which actors, which
460
+ // journals. Copied in beside the built-ins so a declared `scenes` directory
461
+ // joins the default scene rather than replacing the collection.
462
+ for (const [collection, from] of Object.entries(config.e2eDocuments)) {
463
+ const sourceDir = path.join(sourceRoot, collection);
464
+ await fs.mkdir(sourceDir, { recursive: true });
465
+ await fs.cp(path.join(config.rootDir, from), sourceDir, {
466
+ recursive: true,
467
+ });
468
+ }
469
+
470
+ for (const collection of await fs.readdir(sourceRoot)) {
471
+ await compilePack(
472
+ path.join(sourceRoot, collection),
473
+ path.join(worldDir, "data", collection),
474
+ { log: false },
475
+ );
476
+ }
477
+ await fs.rm(sourceRoot, { recursive: true, force: true });
478
+
479
+ log(`Seeded world '${world.worldId}' at ${worldDir}`);
480
+ log(` GM user: ${world.gmName} (id ${world.gmId})`);
481
+ log(` password: ${world.gmPassword}`);
482
+ return { worldDir, world };
483
+ }
484
+
485
+ /**
486
+ * The end-to-end stage's data root, refused when it is another stage's.
487
+ *
488
+ * Pointing it at a real stage would let the seed wipe worlds there, and would
489
+ * make the image reuse that stage's `Config/license.json` — ignoring the key
490
+ * dedicated to the suite. Both failures are quiet and one of them loses data.
491
+ *
492
+ * @param {string} stage - The end-to-end stage.
493
+ * @param {NodeJS.ProcessEnv} env - Environment to read.
494
+ * @returns {string} The local path.
495
+ * @throws {Error} When it is unset, remote, or shared with another stage.
496
+ */
497
+ function requireIsolatedDataRoot(stage, env) {
498
+ const dataRoot = resolveDataRoot(stage, { env });
499
+ const resolved = path.resolve(dataRoot);
500
+ for (const other of ["dev", "qa", "prod"]) {
501
+ if (other === stage) continue;
502
+ const target = env[dataEnvVar(other)]?.trim();
503
+ if (target && path.resolve(target) === resolved) {
504
+ throw new Error(
505
+ `${dataEnvVar(stage)} must be a separate, empty directory — it ` +
506
+ `currently matches ${dataEnvVar(other)} (${resolved}). Point ` +
507
+ `it at a fresh directory so the disposable world and the ` +
508
+ `licence stay isolated.`,
509
+ );
510
+ }
511
+ }
512
+ return dataRoot;
513
+ }
514
+
515
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
516
+
517
+ /**
518
+ * Poll until the world is active, or fail with a diagnosis.
519
+ *
520
+ * A licence failure never recovers, so it is detected from the container's own
521
+ * log and reported at once rather than after a three-minute timeout that says
522
+ * nothing about why.
523
+ *
524
+ * @param {object} opts
525
+ * @param {string} opts.url - The container's base URL.
526
+ * @param {string} opts.container - The container name, for its log.
527
+ * @param {string} opts.stage - The stage, named in a licence failure.
528
+ * @param {number} [opts.timeoutMs] - How long to wait.
529
+ * @param {(message: string) => void} [opts.log] - Progress reporting.
530
+ * @returns {Promise<void>} Resolves once the world is active.
531
+ * @throws {Error} On a licence failure or a timeout.
532
+ */
533
+ export async function waitForWorld({
534
+ url,
535
+ container,
536
+ stage,
537
+ timeoutMs = 180_000,
538
+ log = () => {},
539
+ }) {
540
+ const join = `${url}/join`;
541
+ const deadline = Date.now() + timeoutMs;
542
+ log(`Waiting for the world at ${join} …`);
543
+ while (Date.now() < deadline) {
544
+ const logs = captureContainerLog(container);
545
+ if (/license verification failed/i.test(logs)) {
546
+ throw new Error(
547
+ "Foundry licence verification failed. The end-to-end container " +
548
+ "needs its own SIGNED licence: dedicate one with " +
549
+ `FOUNDRYVTT_${stage.toUpperCase()}_LICENSE_KEY ` +
550
+ "plus FOUNDRY_USERNAME/FOUNDRY_PASSWORD so the image signs " +
551
+ "it — a bare key stays unsigned, and another installation's " +
552
+ "license.json does not verify. A licence shared with a " +
553
+ "running container is single-seat and will fail too.",
554
+ );
555
+ }
556
+ try {
557
+ const response = await fetch(join);
558
+ if (isWorldActive(await response.text())) {
559
+ log("The world is active.");
560
+ return;
561
+ }
562
+ } catch {
563
+ // Not answering yet — the server is still booting.
564
+ }
565
+ await sleep(2000);
566
+ }
567
+ throw new Error(
568
+ `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the ` +
569
+ `world to activate. Check \`package-build container logs\`.`,
570
+ );
571
+ }
572
+
573
+ /**
574
+ * @param {string} container - Container name.
575
+ * @returns {string} The tail of its log, or `""`.
576
+ */
577
+ function captureContainerLog(container) {
578
+ const result = spawnSync("docker", ["logs", "--tail", "40", container], {
579
+ encoding: "utf8",
580
+ });
581
+ return `${result.stdout ?? ""}${result.stderr ?? ""}`;
582
+ }
583
+
584
+ /**
585
+ * Run the repository's suite.
586
+ *
587
+ * `ELECTRON_RUN_AS_NODE` is stripped from the child environment. Editor
588
+ * terminals and most agent shells export it, and with it set an Electron-based
589
+ * runner launches as plain Node, rejects its own flags, and dies with a
590
+ * `MODULE_NOT_FOUND` naming nothing relevant.
591
+ *
592
+ * @param {object} opts
593
+ * @param {string[]} opts.command - The program and its arguments.
594
+ * @param {string[]} [opts.args] - Extra arguments, appended verbatim.
595
+ * @param {string} opts.cwd - The repository root.
596
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment for the child.
597
+ * @param {(message: string) => void} [opts.log] - Progress reporting.
598
+ * @returns {number} The suite's exit status.
599
+ */
600
+ export function runSuite({
601
+ command,
602
+ args = [],
603
+ cwd,
604
+ env = process.env,
605
+ log = () => {},
606
+ }) {
607
+ const [program, ...rest] = command;
608
+ const childEnv = { ...env };
609
+ delete childEnv.ELECTRON_RUN_AS_NODE;
610
+ log(`▸ ${[...command, ...args].join(" ")}`);
611
+ const result = spawnSync(
612
+ /** @type {string} */ (program),
613
+ [...rest, ...args],
614
+ {
615
+ stdio: "inherit",
616
+ cwd,
617
+ env: childEnv,
618
+ shell: process.platform === "win32",
619
+ },
620
+ );
621
+ if (result.error) throw result.error;
622
+ return result.status ?? 1;
623
+ }
624
+
625
+ /**
626
+ * The suite command for a mode, or a clear failure when none is declared.
627
+ *
628
+ * @param {object} config - The resolved package-build configuration.
629
+ * @param {"run"|"open"} mode - Which command to take.
630
+ * @returns {string[]} The program and its arguments.
631
+ * @throws {Error} When the repository declares no such command.
632
+ */
633
+ export function suiteCommand(config, mode) {
634
+ const command = config.e2eSuite?.[mode];
635
+ if (!command?.length) {
636
+ throw new Error(
637
+ `This repository declares no end-to-end suite to \`${mode}\`. Name ` +
638
+ `one under \`packageBuild.e2e.suite.${mode}\` — for example ` +
639
+ `\`${mode}: [npx, cypress, ${mode}]\`.`,
640
+ );
641
+ }
642
+ return command;
643
+ }
644
+
645
+ /**
646
+ * A full, from-scratch end-to-end run.
647
+ *
648
+ * Deploy the staged package, reseed the world, recreate the container onto it,
649
+ * wait for it to activate, run the suite, and tear the container down again —
650
+ * except in interactive mode, where it is left serving.
651
+ *
652
+ * This is the only path that may change Foundry build: the seeded world is
653
+ * stamped with the build that created it, and Foundry refuses to auto-launch a
654
+ * world stamped by another.
655
+ *
656
+ * @param {object} opts
657
+ * @param {object} opts.config - The resolved package-build configuration.
658
+ * @param {object} opts.packageJson - The repository's `package.json`.
659
+ * @param {"run"|"open"} [opts.mode] - Headless or interactive.
660
+ * @param {string[]} [opts.suiteArgs] - Extra arguments for the suite.
661
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
662
+ * @param {(message: string) => void} [opts.log] - Progress reporting.
663
+ * @returns {Promise<number>} The suite's exit status.
664
+ */
665
+ export async function e2eRun({
666
+ config,
667
+ packageJson,
668
+ mode = "run",
669
+ suiteArgs = [],
670
+ env = process.env,
671
+ log = () => {},
672
+ }) {
673
+ if (!E2E_MODES.includes(mode)) {
674
+ throw new Error(`Invalid mode '${mode}'. Valid modes: run, open.`);
675
+ }
676
+ const command = suiteCommand(config, mode);
677
+ const stage = config.e2eStage;
678
+ const container = resolveContainer({ stage, config, env });
679
+
680
+ // Foundry is single-seat, so a second licensed instance will simply refuse
681
+ // to verify. Say so before the run rather than after the timeout.
682
+ const others = runningFoundryContainers(container.name);
683
+ if (others.length) {
684
+ log(
685
+ `⚠ Other Foundry container(s) running: ${others.join(", ")}. Fine ` +
686
+ `if this stage uses a DIFFERENT licence ` +
687
+ `(FOUNDRYVTT_${stage.toUpperCase()}_LICENSE_KEY); a shared one ` +
688
+ `is single-seat and will fail to verify.`,
689
+ );
690
+ }
691
+
692
+ log(`▸ deploy ${stage}`);
693
+ await deployStage({
694
+ stage,
695
+ source: path.join(config.rootDir, config.stageDir),
696
+ packageKind: config.packageKind,
697
+ packageId: config.packageId,
698
+ env,
699
+ prefix: config.envPrefix,
700
+ log,
701
+ });
702
+
703
+ log("▸ seed");
704
+ const { world } = await seedTestWorld({ config, packageJson, env, log });
705
+
706
+ // The world is chosen at container-create time, so it is passed in the
707
+ // environment the recreate bakes in.
708
+ const runEnv = {
709
+ ...env,
710
+ [`FOUNDRYVTT_${stage.toUpperCase()}_WORLD`]: world.worldId,
711
+ };
712
+ log(`▸ container recreate (world ${world.worldId})`);
713
+ const status = containerAction({
714
+ action: "recreate",
715
+ stage,
716
+ config,
717
+ env: runEnv,
718
+ log,
719
+ });
720
+ if (status !== 0) return status;
721
+
722
+ try {
723
+ await waitForWorld({
724
+ url: container.url,
725
+ container: container.name,
726
+ stage,
727
+ log,
728
+ });
729
+ return runSuite({
730
+ command,
731
+ args: suiteArgs,
732
+ cwd: config.rootDir,
733
+ env: runEnv,
734
+ log,
735
+ });
736
+ } finally {
737
+ // Interactive mode leaves the server up; a headless run does not.
738
+ if (mode === "run") runDocker(["stop", container.name]);
739
+ }
740
+ }
741
+
742
+ /**
743
+ * The iteration loop: rebuild what changed, redeploy, cycle, re-run.
744
+ *
745
+ * Each step has a quiet failure mode, and hand-rolling the sequence means
746
+ * meeting them one at a time. The bundler empties the stage, so build order is
747
+ * the declared one; the deploy is a destructive mirror, so it runs on a
748
+ * complete stage; a running Foundry holds its packs open, so the world is
749
+ * always cycled; the container answers on its port long before the world is
750
+ * serving, so the loop waits for the world.
751
+ *
752
+ * @param {object} opts
753
+ * @param {object} opts.config - The resolved package-build configuration.
754
+ * @param {string[]} [opts.argv] - Arguments after the action.
755
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
756
+ * @param {(message: string) => void} [opts.log] - Progress reporting.
757
+ * @returns {Promise<number>} The suite's exit status.
758
+ */
759
+ export async function e2eFast({
760
+ config,
761
+ argv = [],
762
+ env = process.env,
763
+ log = () => {},
764
+ }) {
765
+ const {
766
+ targets,
767
+ recreate,
768
+ runSuite: shouldRun,
769
+ suiteArgs,
770
+ } = parseFastArgs(argv, config.e2eBuild);
771
+ const command = shouldRun ? suiteCommand(config, "run") : null;
772
+ const stage = config.e2eStage;
773
+ const container = resolveContainer({ stage, config, env });
774
+
775
+ for (const target of targets) {
776
+ const script = config.e2eBuild[target].script;
777
+ log(`▸ npm run ${script}`);
778
+ const result = spawnSync("npm", ["run", script], {
779
+ stdio: "inherit",
780
+ cwd: config.rootDir,
781
+ env,
782
+ shell: process.platform === "win32",
783
+ });
784
+ if (result.error) throw result.error;
785
+ if ((result.status ?? 0) !== 0) return result.status ?? 1;
786
+ }
787
+
788
+ // Whether a container exists decides restart vs recreate, and it has to be
789
+ // asked before the deploy — a first-ever run has no container to restart.
790
+ const exists = containerExists(container.name);
791
+
792
+ log(`▸ deploy ${stage}`);
793
+ await deployStage({
794
+ stage,
795
+ source: path.join(config.rootDir, config.stageDir),
796
+ packageKind: config.packageKind,
797
+ packageId: config.packageId,
798
+ env,
799
+ prefix: config.envPrefix,
800
+ log,
801
+ });
802
+
803
+ const action = !exists || recreate ? "recreate" : "restart";
804
+ log(`▸ container ${action}`);
805
+ const status = containerAction({ action, stage, config, env, log });
806
+ if (status !== 0) return status;
807
+
808
+ await waitForWorld({
809
+ url: container.url,
810
+ container: container.name,
811
+ stage,
812
+ log,
813
+ });
814
+
815
+ if (!command) {
816
+ log("✅ The environment is current. Skipping the suite (--no-run).");
817
+ return 0;
818
+ }
819
+ return runSuite({
820
+ command,
821
+ args: suiteArgs,
822
+ cwd: config.rootDir,
823
+ env,
824
+ log,
825
+ });
826
+ }
827
+
828
+ /**
829
+ * The forward sweep: the full suite against a build the repository does not
830
+ * pin.
831
+ *
832
+ * Routine runs go against the pinned build, which is the manifest's
833
+ * `compatibility.minimum` — the claim the suite exists to defend. That leaves
834
+ * the other direction untested: a new Foundry release can break the package and
835
+ * nothing would notice until a user did.
836
+ *
837
+ * A green sweep is what licenses moving `compatibility.verified` to that build.
838
+ * A red one is the early warning it exists to produce.
839
+ *
840
+ * @param {object} opts
841
+ * @param {object} opts.config - The resolved package-build configuration.
842
+ * @param {object} opts.packageJson - The repository's `package.json`.
843
+ * @param {string[]} [opts.argv] - Arguments after the action.
844
+ * @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
845
+ * @param {(message: string) => void} [opts.log] - Progress reporting.
846
+ * @returns {Promise<number>} The suite's exit status.
847
+ */
848
+ export async function e2eSweep({
849
+ config,
850
+ packageJson,
851
+ argv = [],
852
+ env = process.env,
853
+ log = () => {},
854
+ }) {
855
+ const version = resolveSweepVersion(argv);
856
+ const stage = config.e2eStage;
857
+ log(
858
+ `\ne2e sweep → Foundry ${version} (full suite, reseeded world)\n` +
859
+ "This overrides the pin for this run only; nothing on disk " +
860
+ "changes.\n",
861
+ );
862
+ // A full run, not a fast one: the seeded world is stamped with the build
863
+ // that created it, so changing build requires the reseed only this path
864
+ // does. Overriding the variable here also beats one set in a `.env` file,
865
+ // which is loaded without overwriting what is already set.
866
+ return e2eRun({
867
+ config,
868
+ packageJson,
869
+ suiteArgs: argv.slice(1),
870
+ env: {
871
+ ...env,
872
+ [`FOUNDRYVTT_${stage.toUpperCase()}_VERSION`]: version,
873
+ },
874
+ log,
875
+ });
876
+ }