@hominis/fireforge 0.16.2 → 0.16.5

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +15 -2
  3. package/dist/bin/fireforge.js +11 -2
  4. package/dist/src/commands/doctor-furnace.js +83 -1
  5. package/dist/src/commands/doctor.js +18 -0
  6. package/dist/src/commands/download.js +58 -12
  7. package/dist/src/commands/export-all.js +19 -2
  8. package/dist/src/commands/export-shared.d.ts +36 -0
  9. package/dist/src/commands/export-shared.js +76 -0
  10. package/dist/src/commands/export.js +23 -2
  11. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +21 -3
  12. package/dist/src/commands/furnace/chrome-doc-templates.js +23 -5
  13. package/dist/src/commands/furnace/chrome-doc-tests.js +42 -17
  14. package/dist/src/commands/furnace/create-readback.d.ts +23 -0
  15. package/dist/src/commands/furnace/create-readback.js +34 -0
  16. package/dist/src/commands/furnace/create-templates.d.ts +17 -7
  17. package/dist/src/commands/furnace/create-templates.js +85 -31
  18. package/dist/src/commands/furnace/create-xpcshell.d.ts +1 -1
  19. package/dist/src/commands/furnace/create-xpcshell.js +1 -1
  20. package/dist/src/commands/furnace/create.js +2 -0
  21. package/dist/src/commands/furnace/preview.d.ts +12 -0
  22. package/dist/src/commands/furnace/preview.js +34 -2
  23. package/dist/src/commands/furnace/status.js +1 -1
  24. package/dist/src/commands/import.js +63 -11
  25. package/dist/src/commands/patch/delete.js +10 -1
  26. package/dist/src/commands/patch/index.js +10 -1
  27. package/dist/src/commands/re-export.js +79 -6
  28. package/dist/src/commands/resolve.js +15 -1
  29. package/dist/src/commands/run.js +27 -5
  30. package/dist/src/commands/setup-support.js +60 -7
  31. package/dist/src/commands/status.js +28 -1
  32. package/dist/src/commands/test.js +28 -5
  33. package/dist/src/commands/token-coverage.js +55 -1
  34. package/dist/src/commands/token.js +19 -2
  35. package/dist/src/commands/wire.js +22 -2
  36. package/dist/src/core/branding.d.ts +10 -0
  37. package/dist/src/core/branding.js +7 -9
  38. package/dist/src/core/build-prepare.js +8 -1
  39. package/dist/src/core/file-lock.js +49 -15
  40. package/dist/src/core/furnace-operation.d.ts +17 -0
  41. package/dist/src/core/furnace-operation.js +30 -1
  42. package/dist/src/core/furnace-validate-helpers.d.ts +33 -1
  43. package/dist/src/core/furnace-validate-helpers.js +53 -2
  44. package/dist/src/core/git.js +39 -10
  45. package/dist/src/core/mach-error-hints.js +16 -0
  46. package/dist/src/core/mach.js +15 -6
  47. package/dist/src/core/manifest-rules.js +16 -0
  48. package/dist/src/core/marionette-preflight.js +43 -12
  49. package/dist/src/core/patch-files.d.ts +12 -1
  50. package/dist/src/core/patch-files.js +14 -11
  51. package/dist/src/core/patch-lint.js +62 -11
  52. package/dist/src/core/wire-destroy.js +18 -5
  53. package/dist/src/core/wire-init.js +20 -5
  54. package/dist/src/core/wire-utils.d.ts +15 -0
  55. package/dist/src/core/wire-utils.js +17 -0
  56. package/dist/src/types/commands/options.d.ts +7 -0
  57. package/package.json +1 -1
@@ -4,6 +4,7 @@ import { confirm, select, text } from '@clack/prompts';
4
4
  import { addLicenseHeaderToFile, getLicenseHeader } from '../core/license-headers.js';
5
5
  import { findAllPatchesForFiles } from '../core/patch-export.js';
6
6
  import { commentStyleForFile, detectNewFilesInDiff, lintExportedPatch, } from '../core/patch-lint.js';
7
+ import { loadPatchesManifest } from '../core/patch-manifest.js';
7
8
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
8
9
  import { pathExists, readText } from '../utils/fs.js';
9
10
  import { cancel, info, isCancel, warn } from '../utils/logger.js';
@@ -222,4 +223,79 @@ export async function autoFixLicenseHeaders(engineDir, diffContent, config, isIn
222
223
  }
223
224
  return true;
224
225
  }
226
+ /**
227
+ * Maps every file in `filesAffected` to the existing patches that already
228
+ * claim ownership of it, excluding the caller's own patch (when `newFilename`
229
+ * is provided) and any patches that the caller intends to fully supersede.
230
+ *
231
+ * Returns an empty map when no overlap exists. Used by the overlap gate in
232
+ * `export` and `export-all` to refuse a default-mode export that would
233
+ * silently create cross-patch ownership conflicts — the same class of
234
+ * conflict `verify` immediately fails with.
235
+ */
236
+ export function findPartialOwnershipOverlap(manifest, filesAffected, excludeFilenames) {
237
+ const overlap = new Map();
238
+ const targetSet = new Set(filesAffected);
239
+ for (const patch of manifest.patches) {
240
+ if (excludeFilenames.has(patch.filename))
241
+ continue;
242
+ for (const file of patch.filesAffected) {
243
+ if (!targetSet.has(file))
244
+ continue;
245
+ const owners = overlap.get(file) ?? [];
246
+ owners.push(patch.filename);
247
+ overlap.set(file, owners);
248
+ }
249
+ }
250
+ return overlap;
251
+ }
252
+ /**
253
+ * Gate that refuses the default export path when the new patch would
254
+ * silently claim files that are already tracked by other non-superseded
255
+ * patches. `findAllPatchesForFiles` already catches the full-coverage
256
+ * supersede case — this helper fills the gap for partial overlap, which
257
+ * was the eval finding #12 scenario (two patches both claiming
258
+ * `browser/themes/shared/jar.inc.mn` after a second export with
259
+ * `--before`).
260
+ *
261
+ * Proceeds silently when there is no overlap, or when the caller passed
262
+ * `--allow-overlap`. In interactive mode the caller is prompted to
263
+ * acknowledge the overlap (the proper fix path is `re-export --files` to
264
+ * repartition ownership, so the prompt surfaces that pointer). In
265
+ * non-interactive mode the function throws — better to fail fast than
266
+ * let the queue fall out of sync with verify.
267
+ */
268
+ export async function guardOwnershipOverlap(args) {
269
+ const { patchesDir, filesAffected, supersedingFilenames, allowOverlap, isInteractive, s } = args;
270
+ if (allowOverlap)
271
+ return true;
272
+ const manifest = await loadPatchesManifest(patchesDir);
273
+ if (!manifest)
274
+ return true;
275
+ const overlap = findPartialOwnershipOverlap(manifest, filesAffected, supersedingFilenames);
276
+ if (overlap.size === 0)
277
+ return true;
278
+ s.stop();
279
+ const entries = [...overlap.entries()].sort(([a], [b]) => a.localeCompare(b));
280
+ warn(`This export would create cross-patch ownership overlap on ${String(entries.length)} file${entries.length === 1 ? '' : 's'}:`);
281
+ for (const [file, owners] of entries) {
282
+ warn(` - ${file} already claimed by: ${owners.join(', ')}`);
283
+ }
284
+ warn('The queue would fail `fireforge verify` immediately after this export. ' +
285
+ 'To repartition ownership safely, run `fireforge re-export --files <paths> <existing-patch>` ' +
286
+ 'on the overlapping patches first, then re-run the export.');
287
+ if (!isInteractive) {
288
+ throw new GeneralError('Refusing to export a queue with cross-patch ownership overlap in non-interactive mode. ' +
289
+ 'Pass --allow-overlap to acknowledge the conflict, or repartition ownership via `fireforge re-export --files`.');
290
+ }
291
+ const confirmed = await confirm({
292
+ message: 'Proceed with overlapping ownership? This will leave the queue in a verify-failing state.',
293
+ initialValue: false,
294
+ });
295
+ if (isCancel(confirmed) || !confirmed) {
296
+ cancel('Export cancelled');
297
+ return false;
298
+ }
299
+ return true;
300
+ }
225
301
  //# sourceMappingURL=export-shared.js.map
@@ -10,7 +10,7 @@ import { generateBinaryFilePatch, generateFullFilePatch } from '../core/git-diff
10
10
  import { isBinaryFile } from '../core/git-file-ops.js';
11
11
  import { getModifiedFilesInDir, getUntrackedFiles, getUntrackedFilesInDir, } from '../core/git-status.js';
12
12
  import { extractAffectedFiles } from '../core/patch-apply.js';
13
- import { commitExportedPatch } from '../core/patch-export.js';
13
+ import { commitExportedPatch, findAllPatchesForFiles } from '../core/patch-export.js';
14
14
  import { GeneralError, InvalidArgumentError } from '../errors/base.js';
15
15
  import { toError } from '../utils/errors.js';
16
16
  import { ensureDir, pathExists } from '../utils/fs.js';
@@ -19,7 +19,7 @@ import { pickDefined } from '../utils/options.js';
19
19
  import { stripEnginePrefix } from '../utils/paths.js';
20
20
  import { parsePositiveIntegerFlag, PATCH_CATEGORIES } from '../utils/validation.js';
21
21
  import { commitPlacementExport, placementSummary, projectPlacementForLint, renderDryRunPreview, resolvePlacementPlan, } from './export-flow.js';
22
- import { autoFixLicenseHeaders, confirmSupersedePatches, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
22
+ import { autoFixLicenseHeaders, confirmSupersedePatches, guardOwnershipOverlap, promptExportPatchMetadata, runPatchLint, } from './export-shared.js';
23
23
  async function collectExportFiles(paths, files) {
24
24
  const collectedFiles = new Set();
25
25
  let fileStatuses;
@@ -286,6 +286,26 @@ export async function exportCommand(projectRoot, files, options) {
286
286
  const shouldProceed = await confirmSupersedePatches(paths.patches, filesAffected, options.supersede, isInteractive, s);
287
287
  if (!shouldProceed)
288
288
  return;
289
+ // Overlap gate: pre-0.16.0 `export` only caught FULL-coverage
290
+ // supersedes, so a second export targeting a shared file like
291
+ // `browser/themes/shared/jar.inc.mn` happily created a queue where
292
+ // two patches both listed the same file in `filesAffected`. `verify`
293
+ // then failed immediately on "cross-patch filesAffected conflicts".
294
+ // `confirmSupersedePatches` might already have confirmed full
295
+ // supersedes above; pass their filenames through so we do not flag
296
+ // a file claimed by a patch that is about to be removed.
297
+ const willSupersede = await findAllPatchesForFiles(paths.patches, filesAffected);
298
+ const supersedingFilenames = new Set(willSupersede.map((p) => p.filename));
299
+ const shouldProceedPastOverlap = await guardOwnershipOverlap({
300
+ patchesDir: paths.patches,
301
+ filesAffected,
302
+ supersedingFilenames,
303
+ allowOverlap: options.allowOverlap === true,
304
+ isInteractive,
305
+ s,
306
+ });
307
+ if (!shouldProceedPastOverlap)
308
+ return;
289
309
  const { patchFilename, superseded } = await commitExportedPatch({
290
310
  patchesDir: paths.patches,
291
311
  category: selectedCategory,
@@ -327,6 +347,7 @@ export function registerExport(program, { getProjectRoot, withErrorHandling }) {
327
347
  .option('-y, --yes', 'Skip confirmation for placement renumbers (required for non-TTY)')
328
348
  .option('--force-unsafe', 'Bypass cross-patch lint refusal on projected placement')
329
349
  .option('--exclude-furnace', 'Exclude furnace-managed file paths from the export')
350
+ .option('--allow-overlap', 'Acknowledge cross-patch ownership overlap (default mode only; the resulting queue fails verify)')
330
351
  .action(withErrorHandling(async (paths, options) => {
331
352
  const { category, ...rest } = options;
332
353
  await exportCommand(getProjectRoot(), paths, {
@@ -56,11 +56,29 @@ export declare function generateChromeDocCss(name: string, withTitlebar: boolean
56
56
  export declare function generateChromeDocFtl(name: string, licenseHeader: string): string;
57
57
  /**
58
58
  * Single-line jar.mn entry that registers an xhtml + js pair under
59
- * `content/browser/`. Emits the `*` preprocessor flag so both files flow
60
- * through `#filter substitution` for FireForge brand-name substitution.
59
+ * `content/browser/`.
60
+ *
61
+ * Neither emitted line carries the `*` preprocessor flag. The scaffolded
62
+ * XHTML and JS contain no `#filter` / `#expand` / `#include` directives,
63
+ * and mach's `process_install_manifest.py` fails the whole package step
64
+ * with "no preprocessor directives found" when a preprocessed entry has
65
+ * nothing for the preprocessor to do. A fork that later needs brand
66
+ * substitution can re-introduce `*` and add a top-of-file
67
+ * `#filter substitution` directive itself.
61
68
  */
62
69
  export declare function jarMnEntriesForChromeDoc(name: string): string[];
63
- /** jar.inc.mn entry that registers the scoped CSS under `content/browser/`. */
70
+ /**
71
+ * jar.inc.mn entry that registers the scoped CSS under `content/browser/`.
72
+ *
73
+ * The source path is `../shared/<name>-chrome.css` because `jar.inc.mn`
74
+ * is included from each theme-specific manifest (`browser/themes/osx/jar.mn`,
75
+ * `browser/themes/linux/jar.mn`, `browser/themes/windows/jar.mn`), and every
76
+ * existing entry in those manifests resolves paths relative to the including
77
+ * manifest's directory. A bare `(shared/…)` path produced
78
+ * `obj-.../browser/themes/osx/shared/<name>-chrome.css` which does not exist;
79
+ * `(../shared/…)` matches the upstream pattern and resolves under
80
+ * `browser/themes/shared/`.
81
+ */
64
82
  export declare function jarIncMnEntryForChromeDoc(name: string): string;
65
83
  /**
66
84
  * locales/jar.mn entry that registers the `.ftl` under the browser locale
@@ -149,18 +149,36 @@ ${name}-window-title = ${name}
149
149
  }
150
150
  /**
151
151
  * Single-line jar.mn entry that registers an xhtml + js pair under
152
- * `content/browser/`. Emits the `*` preprocessor flag so both files flow
153
- * through `#filter substitution` for FireForge brand-name substitution.
152
+ * `content/browser/`.
153
+ *
154
+ * Neither emitted line carries the `*` preprocessor flag. The scaffolded
155
+ * XHTML and JS contain no `#filter` / `#expand` / `#include` directives,
156
+ * and mach's `process_install_manifest.py` fails the whole package step
157
+ * with "no preprocessor directives found" when a preprocessed entry has
158
+ * nothing for the preprocessor to do. A fork that later needs brand
159
+ * substitution can re-introduce `*` and add a top-of-file
160
+ * `#filter substitution` directive itself.
154
161
  */
155
162
  export function jarMnEntriesForChromeDoc(name) {
156
163
  return [
157
- `* content/browser/${name}.xhtml (content/${name}.xhtml)`,
164
+ ` content/browser/${name}.xhtml (content/${name}.xhtml)`,
158
165
  ` content/browser/${name}.js (content/${name}.js)`,
159
166
  ];
160
167
  }
161
- /** jar.inc.mn entry that registers the scoped CSS under `content/browser/`. */
168
+ /**
169
+ * jar.inc.mn entry that registers the scoped CSS under `content/browser/`.
170
+ *
171
+ * The source path is `../shared/<name>-chrome.css` because `jar.inc.mn`
172
+ * is included from each theme-specific manifest (`browser/themes/osx/jar.mn`,
173
+ * `browser/themes/linux/jar.mn`, `browser/themes/windows/jar.mn`), and every
174
+ * existing entry in those manifests resolves paths relative to the including
175
+ * manifest's directory. A bare `(shared/…)` path produced
176
+ * `obj-.../browser/themes/osx/shared/<name>-chrome.css` which does not exist;
177
+ * `(../shared/…)` matches the upstream pattern and resolves under
178
+ * `browser/themes/shared/`.
179
+ */
162
180
  export function jarIncMnEntryForChromeDoc(name) {
163
- return ` content/browser/${name}-chrome.css (shared/${name}-chrome.css)`;
181
+ return ` content/browser/${name}-chrome.css (../shared/${name}-chrome.css)`;
164
182
  }
165
183
  /**
166
184
  * locales/jar.mn entry that registers the `.ftl` under the browser locale
@@ -69,32 +69,57 @@ export function generateChromeDocPackagingTest(name, header) {
69
69
  add_task(async function test_${taskSuffix}_files_packaged() {
70
70
  const appDir = Services.dirsvc.get("XCurProcD", Ci.nsIFile);
71
71
 
72
- function probe(segments, description) {
73
- const file = appDir.clone();
74
- for (const segment of segments) {
75
- file.append(segment);
72
+ // Probes a pair of candidate layouts for the same packaged file:
73
+ // 1) \`<AppDir>/chrome/browser/…\` the unpacked layout when
74
+ // XCurProcD honours \`firefox-appdir = "browser"\` and resolves
75
+ // into \`dist/bin/browser/\`.
76
+ // 2) \`<AppDir>/browser/chrome/browser/…\` — the macOS .app bundle
77
+ // layout and some ESR configurations, where XCurProcD sits one
78
+ // level above \`browser/\` even when the appdir directive is set.
79
+ // If either path exists the file is packaged; the assertion only fails
80
+ // when BOTH layouts miss, which is the actual stale-build / missing
81
+ // jar.mn entry case. Before this dual probe, the eval on macOS
82
+ // consistently failed against layout (2) even though the file was
83
+ // packaged correctly.
84
+ function probeEither(primary, fallback, description) {
85
+ const primaryFile = appDir.clone();
86
+ for (const segment of primary) {
87
+ primaryFile.append(segment);
76
88
  }
89
+ const fallbackFile = appDir.clone();
90
+ for (const segment of fallback) {
91
+ fallbackFile.append(segment);
92
+ }
93
+ const found = primaryFile.exists() ? primaryFile : fallbackFile.exists() ? fallbackFile : null;
77
94
  Assert.ok(
78
- file.exists(),
79
- description + " missing at " + file.path +
80
- ' run "fireforge build --ui" and retry. If the file is present under ' +
81
- "obj-*/dist/ but absent from this path, the xpcshell harness is probing " +
82
- "a stale build tree; the post-build audit should flag the same miss.",
83
- );
84
- Assert.greater(
85
- file.fileSize,
86
- 0,
87
- description + " is zero-length at " + file.path +
88
- " — packaging copied an empty file, check the source template.",
95
+ found !== null,
96
+ description +
97
+ " missing at both " +
98
+ primaryFile.path +
99
+ " and " +
100
+ fallbackFile.path +
101
+ ' — run "fireforge build --ui" and retry. If one of those paths IS populated, the xpcshell harness is probing a stale build tree; the post-build audit should flag the same miss.',
89
102
  );
103
+ if (found !== null) {
104
+ Assert.greater(
105
+ found.fileSize,
106
+ 0,
107
+ description +
108
+ " is zero-length at " +
109
+ found.path +
110
+ " — packaging copied an empty file, check the source template.",
111
+ );
112
+ }
90
113
  }
91
114
 
92
- probe(
115
+ probeEither(
93
116
  ["chrome", "browser", "content", "browser", "${name}.xhtml"],
117
+ ["browser", "chrome", "browser", "content", "browser", "${name}.xhtml"],
94
118
  "${name}.xhtml",
95
119
  );
96
- probe(
120
+ probeEither(
97
121
  ["chrome", "browser", "skin", "classic", "browser", "${name}-chrome.css"],
122
+ ["browser", "chrome", "browser", "skin", "classic", "browser", "${name}-chrome.css"],
98
123
  "${name}-chrome.css",
99
124
  );
100
125
  });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Defensive read-back helper for `furnace create`. Extracted from
3
+ * `create.ts` so the authoring command stays under the per-file LOC
4
+ * budget.
5
+ */
6
+ /**
7
+ * Asserts that the just-written furnace.json contains the expected
8
+ * custom component entry. The eval run's finding #9 observed a
9
+ * scenario where `furnace create --allow-prefix-mismatch` reported
10
+ * success and wrote the component files, but the subsequent
11
+ * `furnace status` found `custom: {}` in furnace.json — an invariant
12
+ * violation with no clear smoking gun in the code path. Local repros
13
+ * do not trigger it, so the defensive readback is the safest recovery
14
+ * contract we can offer: if the new entry is not visible on the next
15
+ * load, throw a `FurnaceError` so the rollback journal restores the
16
+ * pre-command state and the operator sees the failure instead of a
17
+ * phantom success.
18
+ *
19
+ * @param projectRoot - Root of the FireForge project
20
+ * @param componentName - Custom-element tag name that must be present
21
+ * in `config.custom` after the write. Throws when absent.
22
+ */
23
+ export declare function assertCustomEntryPersisted(projectRoot: string, componentName: string): Promise<void>;
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Defensive read-back helper for `furnace create`. Extracted from
4
+ * `create.ts` so the authoring command stays under the per-file LOC
5
+ * budget.
6
+ */
7
+ import { loadFurnaceConfig } from '../../core/furnace-config.js';
8
+ import { FurnaceError } from '../../errors/furnace.js';
9
+ /**
10
+ * Asserts that the just-written furnace.json contains the expected
11
+ * custom component entry. The eval run's finding #9 observed a
12
+ * scenario where `furnace create --allow-prefix-mismatch` reported
13
+ * success and wrote the component files, but the subsequent
14
+ * `furnace status` found `custom: {}` in furnace.json — an invariant
15
+ * violation with no clear smoking gun in the code path. Local repros
16
+ * do not trigger it, so the defensive readback is the safest recovery
17
+ * contract we can offer: if the new entry is not visible on the next
18
+ * load, throw a `FurnaceError` so the rollback journal restores the
19
+ * pre-command state and the operator sees the failure instead of a
20
+ * phantom success.
21
+ *
22
+ * @param projectRoot - Root of the FireForge project
23
+ * @param componentName - Custom-element tag name that must be present
24
+ * in `config.custom` after the write. Throws when absent.
25
+ */
26
+ export async function assertCustomEntryPersisted(projectRoot, componentName) {
27
+ const persisted = await loadFurnaceConfig(projectRoot);
28
+ if (!(componentName in persisted.custom)) {
29
+ throw new FurnaceError(`Wrote furnace.json but "${componentName}" is missing from config.custom on read-back. ` +
30
+ 'This should not happen — please report the issue. As a workaround, ' +
31
+ 're-run the command, or add the entry to furnace.json by hand.', componentName);
32
+ }
33
+ }
34
+ //# sourceMappingURL=create-readback.js.map
@@ -33,13 +33,23 @@ export declare function xpcshellTestFileName(name: string): string;
33
33
  /**
34
34
  * Generates an xpcshell test file for a custom component.
35
35
  *
36
- * xpcshell tests run headless without a `tabbrowser`, so they suit
37
- * storage/observer/module-loading code in forks that do not mount the
38
- * upstream browser chrome (and therefore lack `openLinkIn`
39
- * `URILoadingHelper`). The scaffold imports the component module via
40
- * `ChromeUtils.importESModule` and asserts the module resolves enough
41
- * to catch registration regressions without touching DOM rendering paths
42
- * that xpcshell cannot execute.
36
+ * xpcshell cannot execute a component module that imports
37
+ * `chrome://global/content/vendor/lit.all.mjs` the Lit bundle touches
38
+ * `window` at module-load time and the xpcshell harness has no `window`
39
+ * global. Before 0.16.0 the scaffold called `ChromeUtils.importESModule`
40
+ * on the component's MJS, which reliably failed with
41
+ * `ReferenceError: window is not defined` for every Lit-based fork
42
+ * component. FireForge's diagnostics then misrouted the failure to the
43
+ * "stale build artifacts" branch, sending operators on a rebuild loop
44
+ * that couldn't fix a runtime-environment incompatibility.
45
+ *
46
+ * The rewrite here mirrors the chrome-doc packaging test: XCurProcD is
47
+ * probed at a pair of candidate layouts (dist/bin/browser and the macOS
48
+ * .app-bundle / ESR layout) to confirm the `.mjs` and `.css` files
49
+ * landed where jar.mn promised. That's the assertion xpcshell CAN make.
50
+ * Functional tests that need DOM/shadow-root/keyboard behaviour belong
51
+ * in a browser-chrome mochitest — scaffolded via
52
+ * `fireforge furnace create --test-style browser-chrome`.
43
53
  */
44
54
  export declare function generateXpcshellTestContent(name: string, header: string): string;
45
55
  /**
@@ -93,48 +93,102 @@ export function generateFtlContent(name, header) {
93
93
  }
94
94
  /** Returns the canonical xpcshell test file basename for a component. */
95
95
  export function xpcshellTestFileName(name) {
96
- return `test_${name.replace(/-/g, '_')}_module_loads.js`;
96
+ return `test_${name.replace(/-/g, '_')}_packaged.js`;
97
97
  }
98
98
  /**
99
99
  * Generates an xpcshell test file for a custom component.
100
100
  *
101
- * xpcshell tests run headless without a `tabbrowser`, so they suit
102
- * storage/observer/module-loading code in forks that do not mount the
103
- * upstream browser chrome (and therefore lack `openLinkIn`
104
- * `URILoadingHelper`). The scaffold imports the component module via
105
- * `ChromeUtils.importESModule` and asserts the module resolves enough
106
- * to catch registration regressions without touching DOM rendering paths
107
- * that xpcshell cannot execute.
101
+ * xpcshell cannot execute a component module that imports
102
+ * `chrome://global/content/vendor/lit.all.mjs` the Lit bundle touches
103
+ * `window` at module-load time and the xpcshell harness has no `window`
104
+ * global. Before 0.16.0 the scaffold called `ChromeUtils.importESModule`
105
+ * on the component's MJS, which reliably failed with
106
+ * `ReferenceError: window is not defined` for every Lit-based fork
107
+ * component. FireForge's diagnostics then misrouted the failure to the
108
+ * "stale build artifacts" branch, sending operators on a rebuild loop
109
+ * that couldn't fix a runtime-environment incompatibility.
110
+ *
111
+ * The rewrite here mirrors the chrome-doc packaging test: XCurProcD is
112
+ * probed at a pair of candidate layouts (dist/bin/browser and the macOS
113
+ * .app-bundle / ESR layout) to confirm the `.mjs` and `.css` files
114
+ * landed where jar.mn promised. That's the assertion xpcshell CAN make.
115
+ * Functional tests that need DOM/shadow-root/keyboard behaviour belong
116
+ * in a browser-chrome mochitest — scaffolded via
117
+ * `fireforge furnace create --test-style browser-chrome`.
108
118
  */
109
119
  export function generateXpcshellTestContent(name, header) {
120
+ const taskSuffix = name.replace(/-/g, '_');
110
121
  return `${header}
111
122
 
112
123
  "use strict";
113
124
 
114
- // Chrome-URI access from xpcshell:
115
- // Toolkit chrome (chrome://global/*) IS registered and resolvable from
116
- // this harness that is what the smoke assertion below uses.
125
+ // Packaging verification for the "${name}" custom component.
126
+ //
127
+ // Why this is not a module-load test:
128
+ // ChromeUtils.importESModule("chrome://global/content/elements/${name}.mjs")
129
+ // pulls in \`chrome://global/content/vendor/lit.all.mjs\`, which
130
+ // references \`window\` during its module body — there is no \`window\`
131
+ // global in xpcshell, so every attempt throws
132
+ // \`ReferenceError: window is not defined\`. For Lit-based components,
133
+ // xpcshell can only verify that the files reached the packaged tree;
134
+ // functional UI assertions belong in a browser-chrome mochitest
135
+ // (see \`fireforge furnace create --test-style browser-chrome\`).
117
136
  //
118
- // Browser chrome (chrome://browser/*) is NOT registered unless the
119
- // xpcshell.toml sets firefox-appdir = "browser" AND the built app bundle
120
- // has landed every packaged chrome manifest. Even then, the set of
121
- // manifests xpcshell loads lags what the real browser loads, so
122
- // NetUtil.asyncFetch("chrome://browser/content/…") can still fail with
123
- // NS_ERROR_FILE_NOT_FOUND against an artifact that IS present in
124
- // obj-*/dist/. Assertions that need browser chrome URIs belong in a
125
- // browser-chrome mochitest (fireforge furnace create --test-style=browser-chrome),
126
- // not xpcshell.
127
-
128
- add_task(async function test_${name.replace(/-/g, '_')}_module_loads() {
129
- // Module-load smoke check: resolves the ESM at its registered chrome URI.
130
- // Replace or extend with storage-layer assertions as the component grows
131
- // (Services.storage, observer topics, JSONFile, etc. are all available
132
- // here without a tabbrowser).
133
- const moduleUri = "chrome://global/content/elements/${name}.mjs";
134
- const module = await ChromeUtils.importESModule(moduleUri);
135
- Assert.ok(
136
- module,
137
- "${name}.mjs should load under xpcshell (storage-layer code path).",
137
+ // Out of scope: builds that pack omni.ja (MOZ_CHROME_MULTILOCALE, some
138
+ // release configs). The probe assumes an unpacked tree, which is what
139
+ // \`mach build\` produces by default. A packed build would need to unzip
140
+ // omni.ja to verify the same files.
141
+
142
+ add_task(async function test_${taskSuffix}_files_packaged() {
143
+ const appDir = Services.dirsvc.get("XCurProcD", Ci.nsIFile);
144
+
145
+ // Two candidate layouts are probed per asset:
146
+ // 1) \`<AppDir>/chrome/global/elements/…\` — unpacked layout when
147
+ // XCurProcD honours \`firefox-appdir = "browser"\` and resolves
148
+ // into \`dist/bin/browser/\`.
149
+ // 2) \`<AppDir>/browser/chrome/global/elements/…\` macOS .app
150
+ // bundle and some ESR layouts where XCurProcD sits one level
151
+ // above \`browser/\`.
152
+ function probeEither(primary, fallback, description) {
153
+ const primaryFile = appDir.clone();
154
+ for (const segment of primary) {
155
+ primaryFile.append(segment);
156
+ }
157
+ const fallbackFile = appDir.clone();
158
+ for (const segment of fallback) {
159
+ fallbackFile.append(segment);
160
+ }
161
+ const found = primaryFile.exists() ? primaryFile : fallbackFile.exists() ? fallbackFile : null;
162
+ Assert.ok(
163
+ found !== null,
164
+ description +
165
+ " missing at both " +
166
+ primaryFile.path +
167
+ " and " +
168
+ fallbackFile.path +
169
+ ' — run "fireforge build --ui" and retry. If the file IS present at one of those paths, xpcshell is probing a stale build tree.',
170
+ );
171
+ if (found !== null) {
172
+ Assert.greater(
173
+ found.fileSize,
174
+ 0,
175
+ description +
176
+ " is zero-length at " +
177
+ found.path +
178
+ " — packaging copied an empty file, check the source template.",
179
+ );
180
+ }
181
+ }
182
+
183
+ probeEither(
184
+ ["chrome", "global", "elements", "${name}.mjs"],
185
+ ["browser", "chrome", "global", "elements", "${name}.mjs"],
186
+ "${name}.mjs",
187
+ );
188
+ probeEither(
189
+ ["chrome", "global", "elements", "${name}.css"],
190
+ ["browser", "chrome", "global", "elements", "${name}.css"],
191
+ "${name}.css",
138
192
  );
139
193
  });
140
194
  `;
@@ -13,7 +13,7 @@ import type { ProjectLicense } from '../../types/config.js';
13
13
  * chrome mochitests require tabbrowser; xpcshell does not, so storage,
14
14
  * observers, and ESM-loading logic can be covered headless.
15
15
  *
16
- * Writes `test_<name>_module_loads.js` and an `xpcshell.toml` manifest
16
+ * Writes `test_<name>_packaged.js` and an `xpcshell.toml` manifest
17
17
  * into `engine/browser/base/content/test/<binary-name>-xpcshell/
18
18
  * <component-name>/`. moz.build registration is intentionally left to the
19
19
  * operator — wiring an `XPCSHELL_TESTS_MANIFESTS` entry requires a
@@ -18,7 +18,7 @@ import { generateXpcshellManifestContent, generateXpcshellTestContent, xpcshellT
18
18
  * chrome mochitests require tabbrowser; xpcshell does not, so storage,
19
19
  * observers, and ESM-loading logic can be covered headless.
20
20
  *
21
- * Writes `test_<name>_module_loads.js` and an `xpcshell.toml` manifest
21
+ * Writes `test_<name>_packaged.js` and an `xpcshell.toml` manifest
22
22
  * into `engine/browser/base/content/test/<binary-name>-xpcshell/
23
23
  * <component-name>/`. moz.build registration is intentionally left to the
24
24
  * operator — wiring an `XPCSHELL_TESTS_MANIFESTS` entry requires a
@@ -19,6 +19,7 @@ import { cancel, intro, isCancel, note, outro, success, warn } from '../../utils
19
19
  import { formatDryRunPlan, formatSuccessNote } from './create-dry-run.js';
20
20
  import { resolveCreateFeatures } from './create-features.js';
21
21
  import { scaffoldMochikitTestFiles } from './create-mochikit.js';
22
+ import { assertCustomEntryPersisted } from './create-readback.js';
22
23
  import { generateCssContent, generateFtlContent, generateMjsContent } from './create-templates.js';
23
24
  import { scaffoldXpcshellTestFiles } from './create-xpcshell.js';
24
25
  async function loadAuthoringFurnaceConfig(projectRoot) {
@@ -266,6 +267,7 @@ async function performCreateMutations(args) {
266
267
  args.config.custom[args.componentName] = customEntry;
267
268
  await snapshotFile(journal, args.furnacePaths.furnaceConfig);
268
269
  await writeFurnaceConfig(args.projectRoot, args.config);
270
+ await assertCustomEntryPersisted(args.projectRoot, args.componentName);
269
271
  if (args.testStyle === 'browser-chrome') {
270
272
  const scafFiles = await scaffoldTestFiles(args.componentName, args.license, args.forgeConfig, args.paths, journal);
271
273
  testFiles.push(...scafFiles);
@@ -1,4 +1,16 @@
1
1
  import type { FurnacePreviewOptions } from '../../types/commands/index.js';
2
+ /**
3
+ * Builds a targeted Storybook failure message from captured mach output.
4
+ *
5
+ * Exported for the test suite: the heuristic has three branches (backend
6
+ * artifact missing, Storybook dep missing, generic) and regression
7
+ * testing each is easier when the classifier is addressable directly.
8
+ *
9
+ * @param output - Combined stdout and stderr from the Storybook command
10
+ * @param installRequested - Whether the caller requested a dependency reinstall first
11
+ * @returns User-facing guidance for the specific failure mode
12
+ */
13
+ export declare function buildStorybookFailureMessage(output: string, installRequested: boolean): string;
2
14
  /**
3
15
  * Runs the furnace preview command to start Storybook for component preview.
4
16
  * @param projectRoot - Root directory of the project
@@ -72,17 +72,49 @@ function reportPreviewStagingFailures(stageResult) {
72
72
  const totalFailures = stageResult.errors.length + appliedWithStepErrorsCount;
73
73
  throw new FurnaceError(`${totalFailures} component${totalFailures === 1 ? '' : 's'} failed to stage for preview`);
74
74
  }
75
+ /**
76
+ * Filenames emitted by the Firefox build backend (not by Storybook's npm
77
+ * package set) — their absence means `mach build` has not produced its
78
+ * post-configure artifacts, which is a different failure mode from a
79
+ * missing Storybook workspace dependency tree. The eval log for finding
80
+ * #11 reported `FileNotFoundError: [...] chrome-map.json` *after* a
81
+ * successful Storybook `npm install`, and the pre-0.16 heuristic
82
+ * misdiagnosed it as a dep failure and sent the operator back to
83
+ * `--install`. Pattern list is narrow on purpose so we only surface the
84
+ * backend-rebuild hint when we are confident.
85
+ */
86
+ const BACKEND_ARTIFACT_PATTERNS = [
87
+ /chrome-map\.json/i,
88
+ /config\.status/i,
89
+ /obj-[^\s/]+\/dist\/bin\/\.lldbinit/i,
90
+ ];
75
91
  /**
76
92
  * Builds a targeted Storybook failure message from captured mach output.
93
+ *
94
+ * Exported for the test suite: the heuristic has three branches (backend
95
+ * artifact missing, Storybook dep missing, generic) and regression
96
+ * testing each is easier when the classifier is addressable directly.
97
+ *
77
98
  * @param output - Combined stdout and stderr from the Storybook command
78
99
  * @param installRequested - Whether the caller requested a dependency reinstall first
79
100
  * @returns User-facing guidance for the specific failure mode
80
101
  */
81
- function buildStorybookFailureMessage(output, installRequested) {
102
+ export function buildStorybookFailureMessage(output, installRequested) {
82
103
  const installHint = installRequested
83
104
  ? 'Try running "python3 ./mach storybook upgrade" manually in the engine directory.'
84
105
  : 'Run "fireforge furnace preview --install" to bootstrap Storybook dependencies, or run "python3 ./mach storybook upgrade" manually in engine/.';
85
- if (/(ENOENT|No such file or directory)/i.test(output) && /storybook|backend/i.test(output)) {
106
+ const hasFileNotFoundSignal = /(ENOENT|No such file or directory|FileNotFoundError)/i.test(output);
107
+ // Check backend-artifact signal first — a missing chrome-map.json looks
108
+ // like any other "No such file" error to a naïve regex, but the fix is
109
+ // to rerun `fireforge build`, not to reinstall Storybook dependencies.
110
+ if (hasFileNotFoundSignal && BACKEND_ARTIFACT_PATTERNS.some((p) => p.test(output))) {
111
+ return ('Storybook failed because the Firefox build backend artifacts are missing or stale ' +
112
+ '(chrome-map.json / config.status / obj-*/dist/bin/.lldbinit). ' +
113
+ 'This is a Firefox-build completeness issue, not a Storybook dependency issue.\n\n' +
114
+ 'Rerun "fireforge build" and let it finish, then retry "fireforge furnace preview". ' +
115
+ 'A full rebuild regenerates the backend artifacts Storybook reads.');
116
+ }
117
+ if (hasFileNotFoundSignal && /storybook|backend/i.test(output)) {
86
118
  return ('Storybook failed because the Firefox checkout appears to be missing Storybook workspace files or backend dependencies.\n\n' +
87
119
  installHint);
88
120
  }