@hominis/fireforge 0.33.0 → 0.34.1

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 (55) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/src/commands/build.js +9 -1
  3. package/dist/src/commands/doctor-furnace-jar.d.ts +16 -0
  4. package/dist/src/commands/doctor-furnace-jar.js +47 -0
  5. package/dist/src/commands/doctor-furnace.js +2 -0
  6. package/dist/src/commands/export-flow.js +4 -1
  7. package/dist/src/commands/export-placement-gate.js +4 -1
  8. package/dist/src/commands/export.js +61 -4
  9. package/dist/src/commands/furnace/chrome-doc-templates.d.ts +20 -0
  10. package/dist/src/commands/furnace/chrome-doc-templates.js +52 -0
  11. package/dist/src/commands/furnace/chrome-doc.d.ts +10 -0
  12. package/dist/src/commands/furnace/chrome-doc.js +31 -4
  13. package/dist/src/commands/furnace/create-browser-test.d.ts +30 -0
  14. package/dist/src/commands/furnace/create-browser-test.js +180 -0
  15. package/dist/src/commands/furnace/create-mochikit.js +11 -4
  16. package/dist/src/commands/furnace/create-xpcshell.d.ts +1 -1
  17. package/dist/src/commands/furnace/create-xpcshell.js +38 -12
  18. package/dist/src/commands/furnace/create.js +7 -114
  19. package/dist/src/commands/furnace/index.js +6 -1
  20. package/dist/src/commands/furnace/scan.d.ts +1 -0
  21. package/dist/src/commands/furnace/scan.js +57 -27
  22. package/dist/src/commands/furnace/validate.js +17 -1
  23. package/dist/src/commands/re-export-options.d.ts +26 -0
  24. package/dist/src/commands/re-export-options.js +48 -1
  25. package/dist/src/commands/re-export.js +4 -1
  26. package/dist/src/commands/register.js +10 -1
  27. package/dist/src/commands/test-diagnose.js +12 -0
  28. package/dist/src/commands/test.js +29 -22
  29. package/dist/src/core/furnace-registration.d.ts +24 -0
  30. package/dist/src/core/furnace-registration.js +56 -3
  31. package/dist/src/core/furnace-validate-registration.js +18 -0
  32. package/dist/src/core/mach-resource-shim.d.ts +70 -16
  33. package/dist/src/core/mach-resource-shim.js +310 -52
  34. package/dist/src/core/mach.d.ts +51 -10
  35. package/dist/src/core/mach.js +76 -32
  36. package/dist/src/core/manifest-helpers.d.ts +13 -0
  37. package/dist/src/core/manifest-helpers.js +1 -1
  38. package/dist/src/core/manifest-rules.d.ts +13 -3
  39. package/dist/src/core/manifest-rules.js +44 -17
  40. package/dist/src/core/patch-export.d.ts +10 -0
  41. package/dist/src/core/patch-export.js +23 -2
  42. package/dist/src/core/register-module.d.ts +1 -1
  43. package/dist/src/core/register-module.js +15 -2
  44. package/dist/src/core/register-result.d.ts +9 -0
  45. package/dist/src/core/register-scaffold.d.ts +55 -0
  46. package/dist/src/core/register-scaffold.js +146 -0
  47. package/dist/src/core/register-test-manifest.js +3 -1
  48. package/dist/src/core/register-xpcshell-test.d.ts +30 -0
  49. package/dist/src/core/register-xpcshell-test.js +96 -0
  50. package/dist/src/core/test-harness-crash.d.ts +26 -1
  51. package/dist/src/core/test-harness-crash.js +149 -11
  52. package/dist/src/core/xpcshell-appdir.d.ts +7 -0
  53. package/dist/src/core/xpcshell-appdir.js +12 -3
  54. package/dist/src/types/commands/options.d.ts +12 -0
  55. package/package.json +3 -3
@@ -0,0 +1,180 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Browser-chrome test scaffolding for `furnace create --with-tests`,
4
+ * including the 0.34.0 `--test-dir` redirect and collision safety
5
+ * (existing manifests are appended to; head.js and test implementations
6
+ * are never overwritten). Split out of `create.ts` to keep that command
7
+ * file within the per-file line budget.
8
+ */
9
+ import { join } from 'node:path';
10
+ import { recordCreatedDir, snapshotFile, } from '../../core/furnace-rollback.js';
11
+ import { getLicenseHeader } from '../../core/license-headers.js';
12
+ import { registerTestManifest } from '../../core/manifest-register.js';
13
+ import { InvalidArgumentError } from '../../errors/base.js';
14
+ import { toError } from '../../utils/errors.js';
15
+ import { ensureDir, pathExists, readText, writeText } from '../../utils/fs.js';
16
+ import { info, success, warn } from '../../utils/logger.js';
17
+ import { stripEnginePrefix } from '../../utils/paths.js';
18
+ /**
19
+ * Validates the `--test-dir` option against the resolved test style before
20
+ * any writes. Mochikit lives in the upstream toolkit/content/tests/widgets
21
+ * tree, so a redirect makes no sense there.
22
+ */
23
+ export function resolveValidatedTestDir(rawTestDir, testStyle) {
24
+ if (rawTestDir === undefined)
25
+ return undefined;
26
+ if (testStyle === 'none') {
27
+ throw new InvalidArgumentError('--test-dir requires --with-tests / --test-style.', 'testDir');
28
+ }
29
+ if (testStyle === 'mochikit') {
30
+ throw new InvalidArgumentError('--test-dir is not supported with --test-style=mochikit (the mochikit harness lives in toolkit/content/tests/widgets).', 'testDir');
31
+ }
32
+ return resolveTestDirOverride(rawTestDir);
33
+ }
34
+ /**
35
+ * Scaffolds browser mochitest files for a newly created custom component.
36
+ * @param componentName - Custom element tag name
37
+ * @param license - Project license used for generated headers
38
+ * @param forgeConfig - Project config fields needed for test naming
39
+ * @param paths - Resolved project paths used to place test files
40
+ * @param journal - Optional rollback journal that snapshots files before writes
41
+ * @returns Relative test filenames created or updated for the component
42
+ */
43
+ const TEST_SCAFFOLD_ROOT = 'browser/base/content/test/';
44
+ /**
45
+ * Normalizes and validates a `--test-dir` override: engine-relative,
46
+ * under `browser/base/content/test/` (so manifest registration keeps
47
+ * working). Returns the normalized engine-relative directory.
48
+ */
49
+ export function resolveTestDirOverride(raw) {
50
+ const normalized = stripEnginePrefix(raw).replace(/\/+$/, '');
51
+ if (!normalized.startsWith(TEST_SCAFFOLD_ROOT) ||
52
+ normalized === TEST_SCAFFOLD_ROOT.slice(0, -1)) {
53
+ throw new InvalidArgumentError(`--test-dir must be an engine-relative directory under ${TEST_SCAFFOLD_ROOT} (got "${raw}").`, 'testDir');
54
+ }
55
+ return normalized;
56
+ }
57
+ // Exported for direct unit testing of the --test-dir / collision-safety
58
+ // behaviour (the full create command needs a furnace project fixture).
59
+ /**
60
+ *
61
+ */
62
+ export async function scaffoldTestFiles(componentName, license, forgeConfig, paths, journal, testDirOverride) {
63
+ const strippedName = componentName.startsWith('moz-') ? componentName.slice(4) : componentName;
64
+ // Avoid double-prefixing: strip binaryName prefix since the default test
65
+ // dir name already uses it
66
+ const binaryName = forgeConfig.binaryName;
67
+ const withoutBinaryPrefix = strippedName.startsWith(binaryName + '-')
68
+ ? strippedName.slice(binaryName.length + 1)
69
+ : strippedName;
70
+ const underscored = withoutBinaryPrefix.replace(/-/g, '_');
71
+ const testFileName = `browser_${binaryName}_${underscored}.js`;
72
+ // --test-dir redirects the scaffold (0.34.0 field report: the hardcoded
73
+ // .../test/<binaryName>/ target collided with a test suite owned by a
74
+ // different patch). The manifest-registration name is the path below
75
+ // browser/base/content/test/ (nested manifests are supported).
76
+ const testDirRel = testDirOverride ?? `${TEST_SCAFFOLD_ROOT}${binaryName}`;
77
+ const testDirName = testDirRel.slice(TEST_SCAFFOLD_ROOT.length);
78
+ const testDir = join(paths.engine, testDirRel);
79
+ if (journal && !(await pathExists(testDir))) {
80
+ recordCreatedDir(journal, testDir);
81
+ }
82
+ await ensureDir(testDir);
83
+ const jsHeader = getLicenseHeader(license, 'js');
84
+ const hashHeader = getLicenseHeader(license, 'hash');
85
+ const testFiles = [];
86
+ // browser.toml — create if missing, append entry if existing
87
+ const tomlPath = join(testDir, 'browser.toml');
88
+ if (await pathExists(tomlPath)) {
89
+ // Defensive guard: only append if the entry is not already present.
90
+ // With a fresh journal per create, the same test file name cannot be
91
+ // appended twice in a single run — but retaining the check protects
92
+ // against accidental re-entrance or a future refactor that reuses the
93
+ // helper with a stale test directory.
94
+ const existingToml = await readText(tomlPath);
95
+ if (!existingToml.includes(`["${testFileName}"]`)) {
96
+ if (journal)
97
+ await snapshotFile(journal, tomlPath);
98
+ await writeText(tomlPath, existingToml.trimEnd() + `\n\n["${testFileName}"]\n`);
99
+ info(`Appended ["${testFileName}"] to the existing ${testDirRel}/browser.toml — the manifest ` +
100
+ 'is shared; existing entries and support-files were left untouched.');
101
+ }
102
+ }
103
+ else {
104
+ if (journal)
105
+ await snapshotFile(journal, tomlPath);
106
+ const browserToml = `${hashHeader}
107
+
108
+ [DEFAULT]
109
+ support-files = ["head.js"]
110
+
111
+ ["${testFileName}"]
112
+ `;
113
+ await writeText(tomlPath, browserToml);
114
+ }
115
+ testFiles.push('browser.toml');
116
+ // head.js — only create if it doesn't exist (shared across components)
117
+ const headPath = join(testDir, 'head.js');
118
+ if (!(await pathExists(headPath))) {
119
+ if (journal)
120
+ await snapshotFile(journal, headPath);
121
+ const headJs = `${jsHeader}
122
+
123
+ "use strict";
124
+
125
+ /**
126
+ * Wait for a custom element to be defined.
127
+ * @param {string} tag - Custom element tag name
128
+ * @returns {Promise<CustomElementConstructor>}
129
+ */
130
+ async function waitForElement(tag) {
131
+ document.createElement(tag);
132
+ return customElements.whenDefined(tag);
133
+ }
134
+ `;
135
+ await writeText(headPath, headJs);
136
+ testFiles.push('head.js');
137
+ }
138
+ // browser_{binaryName}_{stripped}.js
139
+ const testJs = `${jsHeader}
140
+
141
+ "use strict";
142
+
143
+ add_task(async function test_${underscored}_defined() {
144
+ const ctor = await waitForElement("${componentName}");
145
+ Assert.ok(ctor, "${componentName} custom element should be defined");
146
+ Assert.equal(typeof ctor, "function", "Constructor should be a function");
147
+ });
148
+ `;
149
+ const testFilePath = join(testDir, testFileName);
150
+ if (await pathExists(testFilePath)) {
151
+ // Never clobber an existing test implementation (it may be owned by a
152
+ // different patch). The manifest entry above is idempotent, so the
153
+ // existing file simply stays authoritative.
154
+ warn(`${testDirRel}/${testFileName} already exists — keeping the existing file. ` +
155
+ 'Pass --test-dir to scaffold into a different directory if you wanted a fresh test.');
156
+ }
157
+ else {
158
+ if (journal)
159
+ await snapshotFile(journal, testFilePath);
160
+ await writeText(testFilePath, testJs);
161
+ testFiles.push(testFileName);
162
+ }
163
+ // Register in moz.build. The registration helper edits browser/base/moz.build,
164
+ // so snapshot it first when a journal is supplied. The existing warn-and-continue
165
+ // contract is preserved so a missing/unparseable moz.build never trips rollback.
166
+ try {
167
+ const mozBuildPath = join(paths.engine, 'browser/base/moz.build');
168
+ if (journal)
169
+ await snapshotFile(journal, mozBuildPath);
170
+ const registerResult = await registerTestManifest(paths.engine, testDirName);
171
+ if (!registerResult.skipped) {
172
+ success(`Registered test manifest in ${registerResult.manifest}`);
173
+ }
174
+ }
175
+ catch (error) {
176
+ warn(`Could not register test manifest in moz.build — ${toError(error).message}. Register manually with "fireforge register".`);
177
+ }
178
+ return testFiles;
179
+ }
180
+ //# sourceMappingURL=create-browser-test.js.map
@@ -40,10 +40,17 @@ export async function scaffoldMochikitTestFiles(componentName, license, paths, j
40
40
  const writtenFiles = [];
41
41
  const testFileName = mochikitTestFileName(componentName);
42
42
  const testFilePath = join(testDir, testFileName);
43
- if (journal)
44
- await snapshotFile(journal, testFilePath);
45
- await writeText(testFilePath, generateMochikitTestContent(componentName));
46
- writtenFiles.push(testFileName);
43
+ if (await pathExists(testFilePath)) {
44
+ // Never clobber an existing test implementation (0.34.0 collision
45
+ // safety, matching the browser-chrome and xpcshell scaffolds).
46
+ warn(`toolkit/content/tests/widgets/${testFileName} already exists — keeping the existing file.`);
47
+ }
48
+ else {
49
+ if (journal)
50
+ await snapshotFile(journal, testFilePath);
51
+ await writeText(testFilePath, generateMochikitTestContent(componentName));
52
+ writtenFiles.push(testFileName);
53
+ }
47
54
  // chrome.toml — append entry if the file already exists, otherwise write
48
55
  // a fresh skeleton + entry. Idempotency: if the entry is already present
49
56
  // the manifest is left untouched so re-runs don't double-register.
@@ -24,4 +24,4 @@ export declare function scaffoldXpcshellTestFiles(componentName: string, license
24
24
  binaryName: string;
25
25
  }, paths: {
26
26
  engine: string;
27
- }, journal?: RollbackJournal): Promise<string[]>;
27
+ }, journal?: RollbackJournal, testDirOverride?: string): Promise<string[]>;
@@ -8,7 +8,7 @@ import { join } from 'node:path';
8
8
  import { xpcshellTestParentDir } from '../../core/furnace-constants.js';
9
9
  import { recordCreatedDir, snapshotFile, } from '../../core/furnace-rollback.js';
10
10
  import { getLicenseHeader } from '../../core/license-headers.js';
11
- import { ensureDir, pathExists, writeText } from '../../utils/fs.js';
11
+ import { ensureDir, pathExists, readText, writeText } from '../../utils/fs.js';
12
12
  import { warn } from '../../utils/logger.js';
13
13
  import { generateXpcshellManifestContent, generateXpcshellTestContent, xpcshellTestFileName, } from './create-templates.js';
14
14
  /**
@@ -26,10 +26,13 @@ import { generateXpcshellManifestContent, generateXpcshellTestContent, xpcshellT
26
26
  * deliberate choice about which moz.build should own it, and an
27
27
  * auto-insertion that guessed wrong would be worse than a note.
28
28
  */
29
- export async function scaffoldXpcshellTestFiles(componentName, license, forgeConfig, paths, journal) {
29
+ export async function scaffoldXpcshellTestFiles(componentName, license, forgeConfig, paths, journal, testDirOverride) {
30
30
  const parentRelDir = xpcshellTestParentDir(forgeConfig.binaryName);
31
31
  const parentDirName = parentRelDir.split('/').slice(-1)[0] ?? `${forgeConfig.binaryName}-xpcshell`;
32
- const testDir = join(paths.engine, parentRelDir, componentName);
32
+ // --test-dir names the FINAL directory (no per-component segment is
33
+ // appended) so the operator controls the exact scaffold target.
34
+ const testDirRel = testDirOverride ?? `${parentRelDir}/${componentName}`;
35
+ const testDir = join(paths.engine, testDirRel);
33
36
  if (journal && !(await pathExists(testDir))) {
34
37
  recordCreatedDir(journal, testDir);
35
38
  }
@@ -39,16 +42,39 @@ export async function scaffoldXpcshellTestFiles(componentName, license, forgeCon
39
42
  const testFiles = [];
40
43
  const testFileName = xpcshellTestFileName(componentName);
41
44
  const testFilePath = join(testDir, testFileName);
42
- if (journal)
43
- await snapshotFile(journal, testFilePath);
44
- await writeText(testFilePath, generateXpcshellTestContent(componentName, jsHeader));
45
- testFiles.push(testFileName);
45
+ if (await pathExists(testFilePath)) {
46
+ // Never clobber an existing test implementation (0.34.0 field report:
47
+ // the scaffold overwrote files owned by a different patch).
48
+ warn(`${testDirRel}/${testFileName} already exists — keeping the existing file.`);
49
+ }
50
+ else {
51
+ if (journal)
52
+ await snapshotFile(journal, testFilePath);
53
+ await writeText(testFilePath, generateXpcshellTestContent(componentName, jsHeader));
54
+ testFiles.push(testFileName);
55
+ }
56
+ // xpcshell.toml — append the test entry to an existing manifest instead
57
+ // of scaffolding over it; write a fresh one only when absent.
46
58
  const manifestPath = join(testDir, 'xpcshell.toml');
47
- if (journal)
48
- await snapshotFile(journal, manifestPath);
49
- await writeText(manifestPath, generateXpcshellManifestContent(componentName, hashHeader));
50
- testFiles.push('xpcshell.toml');
51
- warn(`xpcshell scaffold written under browser/base/content/test/${parentDirName}/${componentName}/. ` +
59
+ if (await pathExists(manifestPath)) {
60
+ const existing = await readText(manifestPath);
61
+ if (!existing.includes(`["${testFileName}"]`)) {
62
+ if (journal)
63
+ await snapshotFile(journal, manifestPath);
64
+ await writeText(manifestPath, existing.trimEnd() + `\n\n["${testFileName}"]\n`);
65
+ warn(`Appended ["${testFileName}"] to the existing ${testDirRel}/xpcshell.toml — the manifest is shared; existing entries were left untouched.`);
66
+ }
67
+ }
68
+ else {
69
+ if (journal)
70
+ await snapshotFile(journal, manifestPath);
71
+ await writeText(manifestPath, generateXpcshellManifestContent(componentName, hashHeader));
72
+ testFiles.push('xpcshell.toml');
73
+ }
74
+ warn(`xpcshell scaffold written under ${testDirRel}/ ` +
75
+ '(default: browser/base/content/test/' +
76
+ parentDirName +
77
+ '/<component>/). ' +
52
78
  'Add the directory to XPCSHELL_TESTS_MANIFESTS in the nearest moz.build to run it via "fireforge test".');
53
79
  return testFiles;
54
80
  }
@@ -9,13 +9,13 @@ import { CUSTOM_ELEMENT_TAG_PATTERN, CUSTOM_ELEMENT_TAG_RULES, } from '../../cor
9
9
  import { createRollbackJournal, recordCreatedDir, restoreRollbackJournalOrThrow, snapshotFile, } from '../../core/furnace-rollback.js';
10
10
  import { isComponentInEngine, scanWidgetsDirectory } from '../../core/furnace-scanner.js';
11
11
  import { DEFAULT_LICENSE, getLicenseHeader } from '../../core/license-headers.js';
12
- import { registerTestManifest } from '../../core/manifest-register.js';
13
12
  import { validateSharedFtl } from '../../core/shared-ftl.js';
14
13
  import { InvalidArgumentError } from '../../errors/base.js';
15
14
  import { FurnaceError } from '../../errors/furnace.js';
16
15
  import { toError } from '../../utils/errors.js';
17
- import { ensureDir, pathExists, readText, writeText } from '../../utils/fs.js';
18
- import { cancel, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
16
+ import { ensureDir, pathExists, writeText } from '../../utils/fs.js';
17
+ import { cancel, intro, isCancel, note, outro } from '../../utils/logger.js';
18
+ import { resolveValidatedTestDir, scaffoldTestFiles } from './create-browser-test.js';
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';
@@ -62,115 +62,6 @@ function validateTagName(name) {
62
62
  return `Name ${CUSTOM_ELEMENT_TAG_RULES}`;
63
63
  return undefined;
64
64
  }
65
- /**
66
- * Scaffolds browser mochitest files for a newly created custom component.
67
- * @param componentName - Custom element tag name
68
- * @param license - Project license used for generated headers
69
- * @param forgeConfig - Project config fields needed for test naming
70
- * @param paths - Resolved project paths used to place test files
71
- * @param journal - Optional rollback journal that snapshots files before writes
72
- * @returns Relative test filenames created or updated for the component
73
- */
74
- async function scaffoldTestFiles(componentName, license, forgeConfig, paths, journal) {
75
- const strippedName = componentName.startsWith('moz-') ? componentName.slice(4) : componentName;
76
- // Avoid double-prefixing: strip binaryName prefix since testDirName already uses it
77
- const testDirName = forgeConfig.binaryName;
78
- const withoutBinaryPrefix = strippedName.startsWith(testDirName + '-')
79
- ? strippedName.slice(testDirName.length + 1)
80
- : strippedName;
81
- const underscored = withoutBinaryPrefix.replace(/-/g, '_');
82
- const testFileName = `browser_${testDirName}_${underscored}.js`;
83
- const testDir = join(paths.engine, 'browser/base/content/test', testDirName);
84
- if (journal && !(await pathExists(testDir))) {
85
- recordCreatedDir(journal, testDir);
86
- }
87
- await ensureDir(testDir);
88
- const jsHeader = getLicenseHeader(license, 'js');
89
- const hashHeader = getLicenseHeader(license, 'hash');
90
- const testFiles = [];
91
- // browser.toml — create if missing, append entry if existing
92
- const tomlPath = join(testDir, 'browser.toml');
93
- if (await pathExists(tomlPath)) {
94
- // Defensive guard: only append if the entry is not already present.
95
- // With a fresh journal per create, the same test file name cannot be
96
- // appended twice in a single run — but retaining the check protects
97
- // against accidental re-entrance or a future refactor that reuses the
98
- // helper with a stale test directory.
99
- const existingToml = await readText(tomlPath);
100
- if (!existingToml.includes(`["${testFileName}"]`)) {
101
- if (journal)
102
- await snapshotFile(journal, tomlPath);
103
- await writeText(tomlPath, existingToml.trimEnd() + `\n\n["${testFileName}"]\n`);
104
- }
105
- }
106
- else {
107
- if (journal)
108
- await snapshotFile(journal, tomlPath);
109
- const browserToml = `${hashHeader}
110
-
111
- [DEFAULT]
112
- support-files = ["head.js"]
113
-
114
- ["${testFileName}"]
115
- `;
116
- await writeText(tomlPath, browserToml);
117
- }
118
- testFiles.push('browser.toml');
119
- // head.js — only create if it doesn't exist (shared across components)
120
- const headPath = join(testDir, 'head.js');
121
- if (!(await pathExists(headPath))) {
122
- if (journal)
123
- await snapshotFile(journal, headPath);
124
- const headJs = `${jsHeader}
125
-
126
- "use strict";
127
-
128
- /**
129
- * Wait for a custom element to be defined.
130
- * @param {string} tag - Custom element tag name
131
- * @returns {Promise<CustomElementConstructor>}
132
- */
133
- async function waitForElement(tag) {
134
- document.createElement(tag);
135
- return customElements.whenDefined(tag);
136
- }
137
- `;
138
- await writeText(headPath, headJs);
139
- testFiles.push('head.js');
140
- }
141
- // browser_{binaryName}_{stripped}.js
142
- const testJs = `${jsHeader}
143
-
144
- "use strict";
145
-
146
- add_task(async function test_${underscored}_defined() {
147
- const ctor = await waitForElement("${componentName}");
148
- Assert.ok(ctor, "${componentName} custom element should be defined");
149
- Assert.equal(typeof ctor, "function", "Constructor should be a function");
150
- });
151
- `;
152
- const testFilePath = join(testDir, testFileName);
153
- if (journal)
154
- await snapshotFile(journal, testFilePath);
155
- await writeText(testFilePath, testJs);
156
- testFiles.push(testFileName);
157
- // Register in moz.build. The registration helper edits browser/base/moz.build,
158
- // so snapshot it first when a journal is supplied. The existing warn-and-continue
159
- // contract is preserved so a missing/unparseable moz.build never trips rollback.
160
- try {
161
- const mozBuildPath = join(paths.engine, 'browser/base/moz.build');
162
- if (journal)
163
- await snapshotFile(journal, mozBuildPath);
164
- const registerResult = await registerTestManifest(paths.engine, testDirName);
165
- if (!registerResult.skipped) {
166
- success(`Registered test manifest in ${registerResult.manifest}`);
167
- }
168
- }
169
- catch (error) {
170
- warn(`Could not register test manifest in moz.build — ${toError(error).message}. Register manually with "fireforge register".`);
171
- }
172
- return testFiles;
173
- }
174
65
  /**
175
66
  * Writes the scaffolded component source files to disk.
176
67
  * @param componentDir - Destination component directory
@@ -295,11 +186,11 @@ async function performCreateMutations(args) {
295
186
  await writeFurnaceConfig(args.projectRoot, freshConfig);
296
187
  await assertCustomEntryPersisted(args.projectRoot, args.componentName);
297
188
  if (args.testStyle === 'browser-chrome') {
298
- const scafFiles = await scaffoldTestFiles(args.componentName, args.license, args.forgeConfig, args.paths, journal);
189
+ const scafFiles = await scaffoldTestFiles(args.componentName, args.license, args.forgeConfig, args.paths, journal, args.testDir);
299
190
  testFiles.push(...scafFiles);
300
191
  }
301
192
  else if (args.testStyle === 'xpcshell') {
302
- const xpcshellFiles = await scaffoldXpcshellTestFiles(args.componentName, args.license, args.forgeConfig, args.paths, journal);
193
+ const xpcshellFiles = await scaffoldXpcshellTestFiles(args.componentName, args.license, args.forgeConfig, args.paths, journal, args.testDir);
303
194
  testFiles.push(...xpcshellFiles);
304
195
  }
305
196
  else if (args.testStyle === 'mochikit') {
@@ -409,6 +300,7 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
409
300
  // incompatible combinations up-front so a bad flag set never strands a
410
301
  // partial mutation behind.
411
302
  const testStyle = resolveTestStyle(options);
303
+ const testDir = resolveValidatedTestDir(options.testDir, testStyle);
412
304
  if (testStyle !== 'none' && !(await pathExists(paths.engine))) {
413
305
  throw new FurnaceError('Engine directory not found. Run "fireforge download" first to use --with-tests, --xpcshell, or --test-style.', componentName);
414
306
  }
@@ -477,6 +369,7 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
477
369
  paths,
478
370
  license,
479
371
  testStyle,
372
+ testDir,
480
373
  operationContext: ctx,
481
374
  }));
482
375
  note(formatSuccessNote({
@@ -52,8 +52,11 @@ function registerFurnaceInfoCommands(furnace, context) {
52
52
  }));
53
53
  furnace
54
54
  .command('scan')
55
- .description('Scan engine for available components')
55
+ .description('Scan engine for available components. Report-only by default: persist the discovered ' +
56
+ 'inventory into the stock section of furnace.json with --track (or the interactive ' +
57
+ 'prompt); deploy/validate consume the inventory from there.')
56
58
  .option('--deep', 'Search additional Firefox directories beyond the default widgets path')
59
+ .option('--track', 'Persist every discovered untracked component into the stock section of furnace.json (non-interactive)')
57
60
  .action(withErrorHandling(async (options) => {
58
61
  await furnaceScanCommand(getProjectRoot(), pickDefined(options));
59
62
  }));
@@ -80,6 +83,7 @@ function registerFurnaceInfoCommands(furnace, context) {
80
83
  }
81
84
  return value;
82
85
  })
86
+ .option('--test-dir <dir>', 'Redirect the --with-tests scaffold to this engine-relative directory under browser/base/content/test/ (browser-chrome and xpcshell styles) instead of the default <binaryName>-derived directory; existing manifests are appended to, never overwritten')
83
87
  .option('--compose <tags>', 'Record stock tags composed internally (metadata only, comma-separated)', (val) => val.split(',').map((s) => s.trim()))
84
88
  .option('--shared-ftl <path>', 'Participate in an existing feature-scoped .ftl at this path (e.g. "browser/mybrowser-dock.ftl"); skips the per-component .ftl scaffold (implies --localized)')
85
89
  .option('--dry-run', 'Show the planned file set and furnace.json changes without writing')
@@ -98,6 +102,7 @@ function registerChromeDocCommands(furnace, context) {
98
102
  .command('create <name>')
99
103
  .description('Scaffold a new top-level chrome document')
100
104
  .option('--no-titlebar', 'Frameless overlay-style document (omits titlebar-buttonbox)')
105
+ .option('--browser-window', 'Emit the browser.xhtml-like main-window skeleton (<html id="main-window"> with windowtype/chromehidden/persist root attributes platform C++ reads before scripts run) for the document configured as the fork\'s main browser window; implies the titlebar markup')
101
106
  .option('--with-tests', 'Scaffold an xpcshell packaging-verification test that probes XCurProcD/chrome/browser/... directly (bypasses the xpcshell chrome:// URI limitation).')
102
107
  .option('--dry-run', 'Show the chrome-doc scaffold plan without writing')
103
108
  .action(withErrorHandling(async (name, options) => {
@@ -5,4 +5,5 @@
5
5
  */
6
6
  export declare function furnaceScanCommand(projectRoot: string, options?: {
7
7
  deep?: boolean;
8
+ track?: boolean;
8
9
  }): Promise<void>;
@@ -45,12 +45,41 @@ async function promptAddComponents(components, tracked, projectRoot) {
45
45
  outro('Scan complete');
46
46
  return;
47
47
  }
48
- // Wrap the furnace.json mutation in the standard furnace lifecycle so the
49
- // write goes through the furnace-wide lock and is visible to the global
50
- // SIGINT/SIGTERM rollback pathway. The journal snapshots furnace.json
51
- // *before* `ensureFurnaceConfig` runs, so a failed run after the file is
52
- // auto-created cleans up after itself instead of leaving an unwanted
53
- // default config behind.
48
+ await persistStockComponents(projectRoot, selected);
49
+ const addedNames = selected;
50
+ success(`Added ${addedNames.length} component${addedNames.length === 1 ? '' : 's'} to furnace.json`);
51
+ // Offer to immediately override one of the just-added stock components.
52
+ const shouldOverride = await confirm({
53
+ message: 'Override any of the newly added components?',
54
+ });
55
+ if (isCancel(shouldOverride) || !shouldOverride) {
56
+ return;
57
+ }
58
+ const overrideTarget = await select({
59
+ message: 'Select a component to override',
60
+ options: addedNames.map((name) => ({ value: name, label: name })),
61
+ });
62
+ if (isCancel(overrideTarget)) {
63
+ cancel('Cancelled');
64
+ return;
65
+ }
66
+ await furnaceOverrideCommand(projectRoot, overrideTarget);
67
+ }
68
+ /**
69
+ * Persists discovered component tag names into the `stock` section of
70
+ * furnace.json. Shared by the interactive confirm flow and the
71
+ * non-interactive `--track` flag (0.34.0 field report: scan printed a full
72
+ * inventory but persisted nothing and said nothing about where the
73
+ * inventory goes).
74
+ *
75
+ * Wraps the furnace.json mutation in the standard furnace lifecycle so the
76
+ * write goes through the furnace-wide lock and is visible to the global
77
+ * SIGINT/SIGTERM rollback pathway. The journal snapshots furnace.json
78
+ * *before* `ensureFurnaceConfig` runs, so a failed run after the file is
79
+ * auto-created cleans up after itself instead of leaving an unwanted
80
+ * default config behind.
81
+ */
82
+ async function persistStockComponents(projectRoot, names) {
54
83
  await runFurnaceMutation(projectRoot, 'scan-rollback', async (ctx) => {
55
84
  const journal = createRollbackJournal();
56
85
  ctx.registerJournal(journal);
@@ -58,15 +87,14 @@ async function promptAddComponents(components, tracked, projectRoot) {
58
87
  await snapshotFile(journal, furnacePaths.furnaceConfig);
59
88
  try {
60
89
  const config = await ensureFurnaceConfig(projectRoot);
61
- // Defensive: `selected` is already filtered to exclude components
62
- // currently in config.stock (see untrackedComponents above). This
90
+ // Defensive: callers already filter to untracked components. This
63
91
  // re-filter catches the edge case where the config on disk changed
64
92
  // between the scan's read and the write (concurrent scan / manual
65
93
  // edit). Without it a duplicate scan would introduce duplicate
66
94
  // entries into stock; writeFurnaceConfig's validator would then
67
95
  // reject the write, but the error would be less actionable than
68
96
  // silently de-duplicating here.
69
- const toAdd = selected.filter((s) => !config.stock.includes(s));
97
+ const toAdd = names.filter((s) => !config.stock.includes(s));
70
98
  config.stock.push(...toAdd);
71
99
  await writeFurnaceConfig(projectRoot, config);
72
100
  }
@@ -81,24 +109,6 @@ async function promptAddComponents(components, tracked, projectRoot) {
81
109
  throw error;
82
110
  }
83
111
  });
84
- const addedNames = selected;
85
- success(`Added ${addedNames.length} component${addedNames.length === 1 ? '' : 's'} to furnace.json`);
86
- // Offer to immediately override one of the just-added stock components.
87
- const shouldOverride = await confirm({
88
- message: 'Override any of the newly added components?',
89
- });
90
- if (isCancel(shouldOverride) || !shouldOverride) {
91
- return;
92
- }
93
- const overrideTarget = await select({
94
- message: 'Select a component to override',
95
- options: addedNames.map((name) => ({ value: name, label: name })),
96
- });
97
- if (isCancel(overrideTarget)) {
98
- cancel('Cancelled');
99
- return;
100
- }
101
- await furnaceOverrideCommand(projectRoot, overrideTarget);
102
112
  }
103
113
  /**
104
114
  * Runs the furnace scan command to discover MozLitElement components.
@@ -171,11 +181,31 @@ export async function furnaceScanCommand(projectRoot, options = {}) {
171
181
  }
172
182
  const untrackedCount = components.length - trackedCount;
173
183
  note(`Total: ${components.length} Tracked: ${trackedCount} Untracked: ${untrackedCount}`, 'Summary');
184
+ // --track: persist the discovered untracked inventory into the `stock`
185
+ // section without prompting (works non-interactively). Without it, scan
186
+ // stays report-only; the interactive confirm flow below is the other
187
+ // persistence path.
188
+ if (options.track) {
189
+ if (untrackedCount === 0) {
190
+ info('Nothing to track: every discovered component is already in furnace.json.');
191
+ outro('Scan complete');
192
+ return;
193
+ }
194
+ const untrackedNames = components.filter((c) => !tracked.has(c.tagName)).map((c) => c.tagName);
195
+ await persistStockComponents(projectRoot, untrackedNames);
196
+ success(`Tracked ${untrackedNames.length} component${untrackedNames.length === 1 ? '' : 's'} in the stock section of furnace.json`);
197
+ outro('Scan complete');
198
+ return;
199
+ }
174
200
  const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
175
201
  if (isInteractive && untrackedCount > 0) {
176
202
  await promptAddComponents(components, tracked, projectRoot);
177
203
  return;
178
204
  }
205
+ if (untrackedCount > 0) {
206
+ info('Scan is report-only: re-run with --track to persist the untracked components into the ' +
207
+ 'stock section of furnace.json (deploy/validate consume them from there).');
208
+ }
179
209
  outro('Scan complete');
180
210
  }
181
211
  //# sourceMappingURL=scan.js.map
@@ -3,7 +3,7 @@ import { readdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { getProjectPaths } from '../../core/config.js';
5
5
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, } from '../../core/furnace-config.js';
6
- import { addCustomElementRegistration, addJarMnEntries } from '../../core/furnace-registration.js';
6
+ import { addCustomElementRegistration, addJarMnEntries, pruneStaleJarMnEntries, } from '../../core/furnace-registration.js';
7
7
  import { validateAllComponents, validateComponent } from '../../core/furnace-validate.js';
8
8
  import { FurnaceError } from '../../errors/furnace.js';
9
9
  import { pathExists } from '../../utils/fs.js';
@@ -14,6 +14,7 @@ const FIXABLE_CHECKS = new Set([
14
14
  'missing-jar-mn-mjs',
15
15
  'missing-jar-mn-css',
16
16
  'wrong-registration-pattern',
17
+ 'stale-jar-registration',
17
18
  ]);
18
19
  /**
19
20
  * Auto-fixes registration issues that have deterministic solutions.
@@ -40,6 +41,21 @@ async function autoFixIssues(projectRoot, issues) {
40
41
  jarMnFixesByComponent.set(issue.component, existing);
41
42
  }
42
43
  }
44
+ // Prune stale jar.mn registrations (lines pointing at removed/renamed
45
+ // component files — 0.34.0 field report: these broke packaging while
46
+ // validate --fix reported success without touching them).
47
+ if (issues.some((issue) => issue.check === 'stale-jar-registration')) {
48
+ try {
49
+ const pruned = await pruneStaleJarMnEntries(engineDir, furnacePaths.customDir, Object.keys(config.custom));
50
+ fixed += pruned.length;
51
+ for (const entry of pruned) {
52
+ info(`Fixed: pruned stale jar.mn line for ${entry.tagName}/${entry.fileName}`);
53
+ }
54
+ }
55
+ catch (err) {
56
+ warn(`Could not prune stale jar.mn lines: ${err instanceof Error ? err.message : String(err)}`);
57
+ }
58
+ }
43
59
  // Fix jar.mn entries
44
60
  for (const [componentName, files] of jarMnFixesByComponent) {
45
61
  try {