@hominis/fireforge 0.14.0 → 0.15.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.
- package/CHANGELOG.md +31 -0
- package/README.md +20 -1
- package/dist/src/commands/furnace/create-templates.d.ts +26 -0
- package/dist/src/commands/furnace/create-templates.js +86 -0
- package/dist/src/commands/furnace/create.js +64 -101
- package/dist/src/commands/furnace/deploy.js +3 -3
- package/dist/src/commands/test.js +20 -0
- package/dist/src/core/config-paths.d.ts +2 -2
- package/dist/src/core/config-paths.js +2 -0
- package/dist/src/core/config-validate.js +32 -0
- package/dist/src/core/furnace-apply-ftl.d.ts +33 -0
- package/dist/src/core/furnace-apply-ftl.js +102 -0
- package/dist/src/core/furnace-apply-helpers.d.ts +10 -1
- package/dist/src/core/furnace-apply-helpers.js +16 -12
- package/dist/src/core/furnace-apply.js +7 -4
- package/dist/src/core/furnace-config-tokens.d.ts +11 -0
- package/dist/src/core/furnace-config-tokens.js +28 -0
- package/dist/src/core/furnace-config.d.ts +6 -0
- package/dist/src/core/furnace-config.js +8 -1
- package/dist/src/core/furnace-constants.d.ts +20 -0
- package/dist/src/core/furnace-constants.js +32 -0
- package/dist/src/core/furnace-registration-ast.d.ts +13 -1
- package/dist/src/core/furnace-registration-ast.js +58 -25
- package/dist/src/core/furnace-registration.d.ts +27 -0
- package/dist/src/core/furnace-registration.js +96 -0
- package/dist/src/core/furnace-validate-accessibility.js +8 -2
- package/dist/src/core/furnace-validate-helpers.d.ts +8 -0
- package/dist/src/core/furnace-validate-helpers.js +81 -0
- package/dist/src/core/furnace-validate-registration.d.ts +8 -2
- package/dist/src/core/furnace-validate-registration.js +34 -9
- package/dist/src/core/furnace-validate.js +2 -2
- package/dist/src/core/marionette-preflight.d.ts +39 -0
- package/dist/src/core/marionette-preflight.js +210 -0
- package/dist/src/types/commands/options.d.ts +6 -0
- package/dist/src/types/config.d.ts +7 -0
- package/dist/src/types/furnace.d.ts +8 -0
- package/dist/src/utils/process.d.ts +15 -2
- package/dist/src/utils/process.js +73 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.15.0
|
|
4
|
+
|
|
5
|
+
### Furnace registration
|
|
6
|
+
|
|
7
|
+
- `furnace apply` idempotency check is marker-comment-tolerant. Previously the single-line substring match (`content.includes('["tag",')`) missed multi-line entries, and the standalone-line regex anchored on `\s*$`, which did not allow trailing `// <marker>:` comments an operator may have appended to a previously-written entry. A duplicate tag was then inserted on every re-apply, and the second `setElementCreationCallback` invocation threw `NotSupportedError: Operation is not supported` at every window-load. The idempotency check now matches on tag-name column 0 (both single- and multi-line array shapes) and tolerates trailing `//` comments on the line.
|
|
8
|
+
- New optional fireforge.json field `markerComment` (e.g. `"HOMINIS"`) is appended as a ` // HOMINIS:` suffix to every line FireForge writes into `customElements.js`. Keeps fork modifications discoverable and re-applies idempotent without hand-tagging after each apply. The field is threaded through `applyCustomComponent` and `furnace deploy`, not just `furnace create`.
|
|
9
|
+
- `addCustomElementRegistration` and its regex fallback both accept the new marker as an optional parameter; the AST idempotency check and the regex-fallback idempotency check share a single helper (`isTagAlreadyRegistered`).
|
|
10
|
+
|
|
11
|
+
### Furnace `--localized`
|
|
12
|
+
|
|
13
|
+
- `furnace create --localized` now emits the Mozilla-idiomatic `MozLitElement` l10n pattern: a module-level `window.MozXULElement?.insertFTLIfNeeded("<chrome-uri>")` call and `this.ownerDocument.l10n?.connectRoot(this.shadowRoot)` / `disconnectRoot` in `connectedCallback` / `disconnectedCallback`. Previously the template called `this.insertFTLIfNeeded(...)` directly on a `MozLitElement` instance, which throws `TypeError: this.insertFTLIfNeeded is not a function` at every connect because that method lives on `MozXULElement`, not `MozLitElement`. The `--localized` path was silently non-functional.
|
|
14
|
+
- `furnace apply` now registers the scaffolded `.ftl` in the locale jar.mn (default `toolkit/locales/jar.mn`) so the chrome URI `insertFTLIfNeeded` expects actually resolves at runtime. Previously only the `.ftl` file itself was copied into the FTL tree, with no chrome registration. `furnace remove` and the workspace-delete codepath (`undeployCustomFiles`) drop the jar.mn entry symmetrically.
|
|
15
|
+
- The locale jar.mn write degrades gracefully — a missing target (non-standard fork tree) surfaces a structured step error rather than aborting apply, so a well-formed `.mjs`/`.css` is never blocked by a broken locale path.
|
|
16
|
+
- FTL chrome URIs are now derived from `furnace.json.ftlBasePath` via a pair of helpers (`resolveFtlChromeSubPath`, `resolveFtlLocaleJarMnPath`) so forks that customise the FTL tree get matching `insertFTLIfNeeded` and jar.mn output.
|
|
17
|
+
|
|
18
|
+
### Furnace validate
|
|
19
|
+
|
|
20
|
+
- `missing-token-link` now reads `tokenHostDocuments` from furnace.json and scans every configured chrome document for the tokens CSS link. Warning fires only when NONE of them link the tokens CSS; the warning enumerates the documents it actually checked. Previously the check was hardcoded to `browser/base/content/browser.xhtml`, which false-positived on forks that mount components in a different chrome document (e.g. `hominis.xhtml`). Defaults to `["browser/base/content/browser.xhtml"]` when omitted — behaviour is unchanged for projects that never set the field.
|
|
21
|
+
- `no-keyboard-handler` no longer warns when `@click` sits on a native interactive element (`<button>`, `<a href>`, `<input>`, `<select>`, `<textarea>`, `<summary>`, `<details>`, or the Firefox `moz-button`/`moz-toggle`/`moz-checkbox`/`moz-radio`/`moz-menulist` widgets). Those elements dispatch `click` on Enter and Space via the platform, so a duplicate `@keydown`/`@keypress` handler would double-fire. The rule still fires for synthetic interactive markup (e.g. `<div @click>`) and for bare `<a>` without an `href` attribute, which are the real keyboard-a11y hazards.
|
|
22
|
+
|
|
23
|
+
### Run / test
|
|
24
|
+
|
|
25
|
+
- `fireforge run`, `fireforge watch`, `fireforge build`, and every other `mach` invocation launched with inherited stdio now forward parent `SIGINT`/`SIGTERM` to the child as `SIGTERM` and wait ~1.5 s before escalating to `SIGKILL`. A second Ctrl-C during the grace window escalates immediately (matches the usual "hit Ctrl-C twice to force-quit" UX). Previously the parent could exit before Gecko's `AsyncShutdown` / `profileBeforeChange` blockers finished flushing in-memory state, losing the last few seconds of edits. The grace window is configurable via a new `shutdownGraceMs` option on `execInherit` / `execInheritCapture`.
|
|
26
|
+
- New `fireforge test --doctor` runs a short marionette handshake preflight before (optionally) invoking `mach test`. Spawns the built browser headless, opens a TCP socket to `127.0.0.1:2828`, waits for the handshake bytes, and reports PASS/FAIL with the tail of stderr on FAIL. When `--doctor` is supplied with no test paths, it exits after the preflight — a sub-minute way to tell "marionette wedged" apart from "test failed to discover" when `mach test` hangs for the full 360 s marionette timeout. When supplied with test paths, a FAIL preflight short-circuits before `mach test` runs.
|
|
27
|
+
|
|
28
|
+
### Internal
|
|
29
|
+
|
|
30
|
+
- Extracted `furnace-apply-ftl.ts`, `furnace-config-tokens.ts`, and `create-templates.ts` to keep apply / config / scaffolding files under the per-file LOC budget after the new features landed. `parseStringArray` is now exported from `furnace-config.ts` for cross-module reuse.
|
|
31
|
+
- New `src/core/marionette-preflight.ts` owns the `--doctor` probe and its teardown semantics.
|
|
32
|
+
- Test mocks for `furnace-registration.js` now cover the new `addLocaleFtlJarMnEntry` / `removeLocaleFtlJarMnEntry` exports; `config.js` mocks in apply-batch tests now cover `loadConfig` because the apply path reads `markerComment` from fireforge.json.
|
|
33
|
+
|
|
3
34
|
## 0.14.0
|
|
4
35
|
|
|
5
36
|
### Concurrency and atomicity
|
package/README.md
CHANGED
|
@@ -368,10 +368,29 @@ fireforge token --name "--my-color" --value "light-dark(#fff, #000)"
|
|
|
368
368
|
"patchLint": {
|
|
369
369
|
"checkJs": true,
|
|
370
370
|
"rawColorAllowlist": ["hominis-tokens.css"]
|
|
371
|
-
}
|
|
371
|
+
},
|
|
372
|
+
"markerComment": "MYBROWSER"
|
|
372
373
|
}
|
|
373
374
|
```
|
|
374
375
|
|
|
376
|
+
**`markerComment`** (optional). Appended as a ` // <marker>:` suffix to every line FireForge writes into upstream Firefox source files (starting with `customElements.js`). Keeps fork modifications discoverable and makes re-apply idempotent without hand-tagging entries after each `furnace apply`. Reject list: empty strings, leading/trailing whitespace, newlines, `*/` (would close an enclosing block comment), control characters.
|
|
377
|
+
|
|
378
|
+
**`furnace.json.tokenHostDocuments`** (optional). List of chrome XHTML documents the `missing-token-link` validator scans for the tokens CSS link. Forks with a second chrome host (e.g. `hominis.xhtml` alongside `browser.xhtml`) should list every document that may own the link — the rule fires only when NONE of them link the tokens CSS. Defaults to `["browser/base/content/browser.xhtml"]` when omitted.
|
|
379
|
+
|
|
380
|
+
### `furnace create --localized` for `MozLitElement`
|
|
381
|
+
|
|
382
|
+
`fireforge furnace create <tag> --localized` scaffolds a Fluent-ready component. The generated `.mjs` uses the Mozilla-idiomatic `MozLitElement` pattern: a module-level `window.MozXULElement?.insertFTLIfNeeded("<chrome-uri>")` plus `this.ownerDocument.l10n?.connectRoot(this.shadowRoot)` / `disconnectRoot` in `connectedCallback` / `disconnectedCallback`. The chrome URI derives from `furnace.json.ftlBasePath` (default `toolkit/locales/en-US/toolkit/global` → `toolkit/global/<tag>.ftl`). `furnace apply` registers the `.ftl` in the matching locale jar.mn (default `toolkit/locales/jar.mn`) so the chrome URI resolves at runtime. If the locale jar.mn is missing in your fork (non-standard tree), apply surfaces a structured step error instead of aborting — the `.mjs`/`.css` still ship.
|
|
383
|
+
|
|
384
|
+
### `fireforge test --doctor`
|
|
385
|
+
|
|
386
|
+
```bash
|
|
387
|
+
# Sub-minute marionette handshake probe; bails out of mach test on FAIL
|
|
388
|
+
fireforge test --doctor
|
|
389
|
+
fireforge test --doctor browser/base/content/test/foo/browser_bar.js
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Spawns the built browser headless, waits for a marionette handshake on `127.0.0.1:2828`, and reports PASS/FAIL with the tail of the browser's stderr on FAIL. Distinguishes "marionette wedged" (socket silent) from "mach test discovery failed" — both otherwise surface as a silent 360-second hang followed by `Passed: 0, Failed: 0`. Useful as a prefix on routine `fireforge test` invocations when marionette has been flaky.
|
|
393
|
+
|
|
375
394
|
## Roadmap
|
|
376
395
|
|
|
377
396
|
Planned but not yet implemented:
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-content templates for `fireforge furnace create`. Extracted from the
|
|
3
|
+
* command entrypoint so the generator is unit-testable in isolation and the
|
|
4
|
+
* command file stays under the per-file LOC budget.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Generates the .mjs file content for a custom component.
|
|
8
|
+
*
|
|
9
|
+
* `MozLitElement` does NOT expose `insertFTLIfNeeded` — that method lives on
|
|
10
|
+
* `MozXULElement`. Calling it from `connectedCallback` on a Lit-based
|
|
11
|
+
* component throws `TypeError: this.insertFTLIfNeeded is not a function` at
|
|
12
|
+
* every connect. Upstream Firefox components (e.g. `moz-input-folder.mjs`)
|
|
13
|
+
* solve this with a module-level guarded call on `window.MozXULElement` and
|
|
14
|
+
* per-instance shadow-DOM Fluent attachment via `l10n.connectRoot`. We mirror
|
|
15
|
+
* that pattern here so `--localized` produces functional code.
|
|
16
|
+
*
|
|
17
|
+
* The FTL path mirrors the locale jar.mn entry that `furnace apply` writes:
|
|
18
|
+
* `<ftlChromeSubPath>/<name>.ftl`. For the default `toolkit/global` tree this
|
|
19
|
+
* yields `toolkit/global/<name>.ftl`, which matches the URI upstream toolkit
|
|
20
|
+
* widgets ship.
|
|
21
|
+
*/
|
|
22
|
+
export declare function generateMjsContent(name: string, className: string, description: string, localized: boolean, header: string, ftlChromeSubPath: string | undefined): string;
|
|
23
|
+
/** Generates the .css file content for a custom component. */
|
|
24
|
+
export declare function generateCssContent(header: string): string;
|
|
25
|
+
/** Generates the .ftl file content for a custom component. */
|
|
26
|
+
export declare function generateFtlContent(name: string, header: string): string;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* File-content templates for `fireforge furnace create`. Extracted from the
|
|
4
|
+
* command entrypoint so the generator is unit-testable in isolation and the
|
|
5
|
+
* command file stays under the per-file LOC budget.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Generates the .mjs file content for a custom component.
|
|
9
|
+
*
|
|
10
|
+
* `MozLitElement` does NOT expose `insertFTLIfNeeded` — that method lives on
|
|
11
|
+
* `MozXULElement`. Calling it from `connectedCallback` on a Lit-based
|
|
12
|
+
* component throws `TypeError: this.insertFTLIfNeeded is not a function` at
|
|
13
|
+
* every connect. Upstream Firefox components (e.g. `moz-input-folder.mjs`)
|
|
14
|
+
* solve this with a module-level guarded call on `window.MozXULElement` and
|
|
15
|
+
* per-instance shadow-DOM Fluent attachment via `l10n.connectRoot`. We mirror
|
|
16
|
+
* that pattern here so `--localized` produces functional code.
|
|
17
|
+
*
|
|
18
|
+
* The FTL path mirrors the locale jar.mn entry that `furnace apply` writes:
|
|
19
|
+
* `<ftlChromeSubPath>/<name>.ftl`. For the default `toolkit/global` tree this
|
|
20
|
+
* yields `toolkit/global/<name>.ftl`, which matches the URI upstream toolkit
|
|
21
|
+
* widgets ship.
|
|
22
|
+
*/
|
|
23
|
+
export function generateMjsContent(name, className, description, localized, header, ftlChromeSubPath) {
|
|
24
|
+
const ftlPath = ftlChromeSubPath !== undefined ? `${ftlChromeSubPath}/${name}.ftl` : `${name}.ftl`;
|
|
25
|
+
const ftlModulePreamble = localized
|
|
26
|
+
? `
|
|
27
|
+
window.MozXULElement?.insertFTLIfNeeded("${ftlPath}");
|
|
28
|
+
`
|
|
29
|
+
: '';
|
|
30
|
+
const lifecycleHooks = localized
|
|
31
|
+
? `
|
|
32
|
+
connectedCallback() {
|
|
33
|
+
super.connectedCallback();
|
|
34
|
+
this.ownerDocument.l10n?.connectRoot(this.shadowRoot);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
disconnectedCallback() {
|
|
38
|
+
super.disconnectedCallback();
|
|
39
|
+
this.ownerDocument.l10n?.disconnectRoot(this.shadowRoot);
|
|
40
|
+
}
|
|
41
|
+
`
|
|
42
|
+
: '';
|
|
43
|
+
return `${header}
|
|
44
|
+
|
|
45
|
+
import { html } from "chrome://global/content/vendor/lit.all.mjs";
|
|
46
|
+
import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
|
|
47
|
+
${ftlModulePreamble}
|
|
48
|
+
/**
|
|
49
|
+
* ${description || name}
|
|
50
|
+
*
|
|
51
|
+
* @tagname ${name}
|
|
52
|
+
*/
|
|
53
|
+
class ${className} extends MozLitElement {
|
|
54
|
+
static properties = {};
|
|
55
|
+
|
|
56
|
+
constructor() {
|
|
57
|
+
super();
|
|
58
|
+
}
|
|
59
|
+
${lifecycleHooks}
|
|
60
|
+
render() {
|
|
61
|
+
return html\`
|
|
62
|
+
<link rel="stylesheet" href="chrome://global/content/elements/${name}.css" />
|
|
63
|
+
<slot></slot>
|
|
64
|
+
\`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
customElements.define("${name}", ${className});
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
70
|
+
/** Generates the .css file content for a custom component. */
|
|
71
|
+
export function generateCssContent(header) {
|
|
72
|
+
return `${header}
|
|
73
|
+
|
|
74
|
+
:host {
|
|
75
|
+
display: block;
|
|
76
|
+
}
|
|
77
|
+
`;
|
|
78
|
+
}
|
|
79
|
+
/** Generates the .ftl file content for a custom component. */
|
|
80
|
+
export function generateFtlContent(name, header) {
|
|
81
|
+
return `${header}
|
|
82
|
+
|
|
83
|
+
## Strings for the ${name} component
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=create-templates.js.map
|
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { multiselect, text } from '@clack/prompts';
|
|
4
4
|
import { getProjectPaths, loadConfig } from '../../core/config.js';
|
|
5
5
|
import { createDefaultFurnaceConfig, detectComposesCycles, furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, writeFurnaceConfig, } from '../../core/furnace-config.js';
|
|
6
|
-
import { tagNameToClassName } from '../../core/furnace-constants.js';
|
|
6
|
+
import { resolveFtlChromeSubPath, tagNameToClassName } from '../../core/furnace-constants.js';
|
|
7
7
|
import { recordFurnaceRollbackFailure, runFurnaceMutation, } from '../../core/furnace-operation.js';
|
|
8
8
|
import { CUSTOM_ELEMENT_TAG_PATTERN, CUSTOM_ELEMENT_TAG_RULES, } from '../../core/furnace-registration-validate.js';
|
|
9
9
|
import { createRollbackJournal, recordCreatedDir, restoreRollbackJournalOrThrow, snapshotFile, } from '../../core/furnace-rollback.js';
|
|
@@ -15,6 +15,7 @@ import { FurnaceError } from '../../errors/furnace.js';
|
|
|
15
15
|
import { toError } from '../../utils/errors.js';
|
|
16
16
|
import { ensureDir, pathExists, readText, writeText } from '../../utils/fs.js';
|
|
17
17
|
import { cancel, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
|
|
18
|
+
import { generateCssContent, generateFtlContent, generateMjsContent } from './create-templates.js';
|
|
18
19
|
async function loadAuthoringFurnaceConfig(projectRoot) {
|
|
19
20
|
if (await furnaceConfigExists(projectRoot)) {
|
|
20
21
|
return loadFurnaceConfig(projectRoot);
|
|
@@ -46,65 +47,6 @@ function checkNameConflict(config, name) {
|
|
|
46
47
|
}
|
|
47
48
|
return undefined;
|
|
48
49
|
}
|
|
49
|
-
/**
|
|
50
|
-
* Generates the .mjs file content for a custom component.
|
|
51
|
-
*/
|
|
52
|
-
function generateMjsContent(name, className, description, localized, header) {
|
|
53
|
-
const connectedCallback = localized
|
|
54
|
-
? `
|
|
55
|
-
connectedCallback() {
|
|
56
|
-
super.connectedCallback();
|
|
57
|
-
this.insertFTLIfNeeded("${name}.ftl");
|
|
58
|
-
}
|
|
59
|
-
`
|
|
60
|
-
: '';
|
|
61
|
-
return `${header}
|
|
62
|
-
|
|
63
|
-
import { html } from "chrome://global/content/vendor/lit.all.mjs";
|
|
64
|
-
import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* ${description || name}
|
|
68
|
-
*
|
|
69
|
-
* @tagname ${name}
|
|
70
|
-
*/
|
|
71
|
-
class ${className} extends MozLitElement {
|
|
72
|
-
static properties = {};
|
|
73
|
-
|
|
74
|
-
constructor() {
|
|
75
|
-
super();
|
|
76
|
-
}
|
|
77
|
-
${connectedCallback}
|
|
78
|
-
render() {
|
|
79
|
-
return html\`
|
|
80
|
-
<link rel="stylesheet" href="chrome://global/content/elements/${name}.css" />
|
|
81
|
-
<slot></slot>
|
|
82
|
-
\`;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
customElements.define("${name}", ${className});
|
|
86
|
-
`;
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* Generates the .css file content for a custom component.
|
|
90
|
-
*/
|
|
91
|
-
function generateCssContent(header) {
|
|
92
|
-
return `${header}
|
|
93
|
-
|
|
94
|
-
:host {
|
|
95
|
-
display: block;
|
|
96
|
-
}
|
|
97
|
-
`;
|
|
98
|
-
}
|
|
99
|
-
/**
|
|
100
|
-
* Generates the .ftl file content for a custom component.
|
|
101
|
-
*/
|
|
102
|
-
function generateFtlContent(name, header) {
|
|
103
|
-
return `${header}
|
|
104
|
-
|
|
105
|
-
## Strings for the ${name} component
|
|
106
|
-
`;
|
|
107
|
-
}
|
|
108
50
|
/**
|
|
109
51
|
* Scaffolds browser mochitest files for a newly created custom component.
|
|
110
52
|
* @param componentName - Custom element tag name
|
|
@@ -258,13 +200,13 @@ async function resolveCreateFeatures(isInteractive, options) {
|
|
|
258
200
|
* @param journal - Optional rollback journal that snapshots files before writes
|
|
259
201
|
* @returns Relative filenames written for the component
|
|
260
202
|
*/
|
|
261
|
-
async function writeComponentFiles(componentDir, componentName, className, description, localized, license, journal) {
|
|
203
|
+
async function writeComponentFiles(componentDir, componentName, className, description, localized, license, ftlChromeSubPath, journal) {
|
|
262
204
|
await ensureDir(componentDir);
|
|
263
205
|
const files = [`${componentName}.mjs`, `${componentName}.css`];
|
|
264
206
|
const mjsPath = join(componentDir, `${componentName}.mjs`);
|
|
265
207
|
if (journal)
|
|
266
208
|
await snapshotFile(journal, mjsPath);
|
|
267
|
-
const mjsContent = generateMjsContent(componentName, className, description, localized, getLicenseHeader(license, 'js'));
|
|
209
|
+
const mjsContent = generateMjsContent(componentName, className, description, localized, getLicenseHeader(license, 'js'), ftlChromeSubPath);
|
|
268
210
|
await writeText(mjsPath, mjsContent);
|
|
269
211
|
const cssPath = join(componentDir, `${componentName}.css`);
|
|
270
212
|
if (journal)
|
|
@@ -303,7 +245,7 @@ async function performCreateMutations(args) {
|
|
|
303
245
|
// so signal-driven rollback can clean it up even if writeComponentFiles
|
|
304
246
|
// is interrupted mid-ensureDir.
|
|
305
247
|
recordCreatedDir(journal, args.componentDir);
|
|
306
|
-
files = await writeComponentFiles(args.componentDir, args.componentName, args.className, args.description, args.localized, args.license, journal);
|
|
248
|
+
files = await writeComponentFiles(args.componentDir, args.componentName, args.className, args.description, args.localized, args.license, args.ftlChromeSubPath, journal);
|
|
307
249
|
const customEntry = {
|
|
308
250
|
description: args.description,
|
|
309
251
|
targetPath: `toolkit/content/widgets/${args.componentName}`,
|
|
@@ -333,6 +275,58 @@ async function performCreateMutations(args) {
|
|
|
333
275
|
}
|
|
334
276
|
return { files, testFiles };
|
|
335
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Prompts the operator for a description when the command is interactive and
|
|
280
|
+
* the operator did not pass `-d`. Returns the resolved description string.
|
|
281
|
+
*/
|
|
282
|
+
async function resolveDescription(isInteractive, options) {
|
|
283
|
+
let description = options.description ?? '';
|
|
284
|
+
if (!description && isInteractive) {
|
|
285
|
+
const descResult = await text({
|
|
286
|
+
message: 'Description (optional):',
|
|
287
|
+
placeholder: 'A brief description of the component',
|
|
288
|
+
});
|
|
289
|
+
if (!isCancel(descResult)) {
|
|
290
|
+
description = String(descResult);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return description;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Validates the `--compose` targets against registered components and runs
|
|
297
|
+
* cycle detection if the new component is introduced into the graph. Throws
|
|
298
|
+
* on any failure; returns when the graph is clean.
|
|
299
|
+
*/
|
|
300
|
+
function validateComposesTargets(config, componentName, composes) {
|
|
301
|
+
if (!composes || composes.length === 0)
|
|
302
|
+
return;
|
|
303
|
+
const known = new Set([
|
|
304
|
+
...config.stock,
|
|
305
|
+
...Object.keys(config.overrides),
|
|
306
|
+
...Object.keys(config.custom),
|
|
307
|
+
]);
|
|
308
|
+
for (const tag of composes) {
|
|
309
|
+
if (tag === componentName) {
|
|
310
|
+
throw new FurnaceError(`Component "${componentName}" cannot compose itself.`);
|
|
311
|
+
}
|
|
312
|
+
if (!known.has(tag)) {
|
|
313
|
+
throw new FurnaceError(`Cannot compose unknown component "${tag}". ` +
|
|
314
|
+
'The referenced component must be registered as stock, override, or custom.');
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// Check for cycles that would be introduced by adding this component.
|
|
318
|
+
const tempCustom = {
|
|
319
|
+
...config.custom,
|
|
320
|
+
[componentName]: {
|
|
321
|
+
description: '',
|
|
322
|
+
targetPath: `toolkit/content/widgets/${componentName}`,
|
|
323
|
+
register: true,
|
|
324
|
+
localized: false,
|
|
325
|
+
composes,
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
detectComposesCycles(tempCustom);
|
|
329
|
+
}
|
|
336
330
|
/**
|
|
337
331
|
* Runs the furnace create command to scaffold a new custom component.
|
|
338
332
|
* @param projectRoot - Root directory of the project
|
|
@@ -394,16 +388,7 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
|
|
|
394
388
|
warn(`Name "${componentName}" does not start with the configured prefix "${config.componentPrefix}".`);
|
|
395
389
|
}
|
|
396
390
|
// --- Resolve description ---
|
|
397
|
-
|
|
398
|
-
if (!description && isInteractive) {
|
|
399
|
-
const descResult = await text({
|
|
400
|
-
message: 'Description (optional):',
|
|
401
|
-
placeholder: 'A brief description of the component',
|
|
402
|
-
});
|
|
403
|
-
if (!isCancel(descResult)) {
|
|
404
|
-
description = String(descResult);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
391
|
+
const description = await resolveDescription(isInteractive, options);
|
|
407
392
|
// --- Resolve features ---
|
|
408
393
|
const featureSelection = await resolveCreateFeatures(isInteractive, options);
|
|
409
394
|
if (!featureSelection) {
|
|
@@ -428,39 +413,16 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
|
|
|
428
413
|
// --- Validate --compose targets BEFORE any writes so a failed validation
|
|
429
414
|
// does not strand component files behind.
|
|
430
415
|
const composes = options.compose;
|
|
431
|
-
|
|
432
|
-
const known = new Set([
|
|
433
|
-
...config.stock,
|
|
434
|
-
...Object.keys(config.overrides),
|
|
435
|
-
...Object.keys(config.custom),
|
|
436
|
-
]);
|
|
437
|
-
for (const tag of composes) {
|
|
438
|
-
if (tag === componentName) {
|
|
439
|
-
throw new FurnaceError(`Component "${componentName}" cannot compose itself.`);
|
|
440
|
-
}
|
|
441
|
-
if (!known.has(tag)) {
|
|
442
|
-
throw new FurnaceError(`Cannot compose unknown component "${tag}". ` +
|
|
443
|
-
'The referenced component must be registered as stock, override, or custom.');
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
// Check for cycles that would be introduced by adding this component.
|
|
447
|
-
const tempCustom = {
|
|
448
|
-
...config.custom,
|
|
449
|
-
[componentName]: {
|
|
450
|
-
description: '',
|
|
451
|
-
targetPath: `toolkit/content/widgets/${componentName}`,
|
|
452
|
-
register: true,
|
|
453
|
-
localized: false,
|
|
454
|
-
composes,
|
|
455
|
-
},
|
|
456
|
-
};
|
|
457
|
-
detectComposesCycles(tempCustom);
|
|
458
|
-
}
|
|
416
|
+
validateComposesTargets(config, componentName, composes);
|
|
459
417
|
// All validation is done. Hand off to the transactional mutation helper
|
|
460
418
|
// so any failure restores the workspace and engine to their pre-command
|
|
461
419
|
// state via the shared rollback journal. The mutation runs under the
|
|
462
420
|
// furnace-wide lock and is registered with the global SIGINT/SIGTERM
|
|
463
421
|
// rollback pathway.
|
|
422
|
+
// Derive the FTL chrome sub-path from the configured ftlBasePath so the
|
|
423
|
+
// generated `.mjs` calls `insertFTLIfNeeded` at a URI that actually matches
|
|
424
|
+
// the locale jar.mn entry `furnace apply` will write.
|
|
425
|
+
const ftlChromeSubPath = resolveFtlChromeSubPath(config.ftlBasePath);
|
|
464
426
|
const { files, testFiles } = await runFurnaceMutation(projectRoot, 'create-rollback', (ctx) => performCreateMutations({
|
|
465
427
|
projectRoot,
|
|
466
428
|
componentName,
|
|
@@ -476,6 +438,7 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
|
|
|
476
438
|
paths,
|
|
477
439
|
license,
|
|
478
440
|
withTests,
|
|
441
|
+
ftlChromeSubPath,
|
|
479
442
|
operationContext: ctx,
|
|
480
443
|
}));
|
|
481
444
|
// --- Success ---
|
|
@@ -162,7 +162,7 @@ async function restoreNamedDeployRollback(rollbackJournal, name, projectRoot) {
|
|
|
162
162
|
* @param isDryRun - Whether file writes should be skipped
|
|
163
163
|
* @returns Apply result for the named component, or `stock` for stock-only entries
|
|
164
164
|
*/
|
|
165
|
-
async function applyNamedComponent(name, engineDir, furnacePaths, config, ftlDir, isDryRun, operationContext, projectRoot) {
|
|
165
|
+
async function applyNamedComponent(name, engineDir, furnacePaths, config, ftlDir, isDryRun, operationContext, projectRoot, markerComment) {
|
|
166
166
|
const rollbackJournal = isDryRun ? undefined : createRollbackJournal();
|
|
167
167
|
if (rollbackJournal && operationContext) {
|
|
168
168
|
operationContext.registerJournal(rollbackJournal);
|
|
@@ -201,7 +201,7 @@ async function applyNamedComponent(name, engineDir, furnacePaths, config, ftlDir
|
|
|
201
201
|
throw new FurnaceError(`Component directory not found: components/custom/${name}`, name);
|
|
202
202
|
}
|
|
203
203
|
try {
|
|
204
|
-
const { affectedPaths: filesAffected, stepErrors, actions, } = await applyCustomComponent(engineDir, name, componentDir, customConfig, ftlDir, isDryRun, rollbackJournal);
|
|
204
|
+
const { affectedPaths: filesAffected, stepErrors, actions, } = await applyCustomComponent(engineDir, name, componentDir, customConfig, ftlDir, isDryRun, rollbackJournal, markerComment !== undefined ? { markerComment } : {});
|
|
205
205
|
if (isDryRun && actions) {
|
|
206
206
|
result.actions = actions;
|
|
207
207
|
}
|
|
@@ -317,7 +317,7 @@ export async function furnaceDeployCommand(projectRoot, name, options = {}) {
|
|
|
317
317
|
// `furnace deploy` runs only contend on the actual mutation.
|
|
318
318
|
const applyOutcome = await runFurnaceMutation(projectRoot, 'deploy-rollback', async (ctx) => {
|
|
319
319
|
if (name) {
|
|
320
|
-
const namedApplyResult = await applyNamedComponent(name, paths.engine, furnacePaths, config, ftlDir, isDryRun, ctx, projectRoot);
|
|
320
|
+
const namedApplyResult = await applyNamedComponent(name, paths.engine, furnacePaths, config, ftlDir, isDryRun, ctx, projectRoot, forgeConfig.markerComment);
|
|
321
321
|
if (namedApplyResult === 'stock') {
|
|
322
322
|
return { kind: 'stock' };
|
|
323
323
|
}
|
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { prepareBuildEnvironment } from '../core/build-prepare.js';
|
|
4
4
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
5
5
|
import { buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, testWithOutput, } from '../core/mach.js';
|
|
6
|
+
import { reportMarionettePreflight, runMarionettePreflight } from '../core/marionette-preflight.js';
|
|
6
7
|
import { GeneralError } from '../errors/base.js';
|
|
7
8
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
8
9
|
import { pathExists } from '../utils/fs.js';
|
|
@@ -117,6 +118,24 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
117
118
|
s.stop('Build complete');
|
|
118
119
|
info('');
|
|
119
120
|
}
|
|
121
|
+
// `--doctor` runs a short marionette handshake probe. When test paths are
|
|
122
|
+
// supplied the probe gates the mach test invocation (a FAIL bails out). When
|
|
123
|
+
// no paths are supplied this is the only step — it's the fastest way to tell
|
|
124
|
+
// marionette-wedged apart from test-discovery-failure.
|
|
125
|
+
if (options.doctor) {
|
|
126
|
+
info('Running marionette preflight...');
|
|
127
|
+
const preflight = await runMarionettePreflight(paths.engine);
|
|
128
|
+
reportMarionettePreflight(preflight);
|
|
129
|
+
if (testPaths.length === 0) {
|
|
130
|
+
if (!preflight.ok) {
|
|
131
|
+
throw new GeneralError('Marionette preflight reported FAIL — see output above.');
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!preflight.ok) {
|
|
136
|
+
throw new GeneralError('Marionette preflight reported FAIL — see output above. Aborting before mach test runs.');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
120
139
|
// Normalize test paths (strip engine/ prefix if present)
|
|
121
140
|
const normalizedPaths = testPaths.map(normalizeTestPath);
|
|
122
141
|
await assertTestPathsExist(paths.engine, normalizedPaths);
|
|
@@ -149,6 +168,7 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
|
|
|
149
168
|
.description('Run tests via mach test')
|
|
150
169
|
.option('--headless', 'Run tests in headless mode')
|
|
151
170
|
.option('--build', 'Run incremental UI build before testing')
|
|
171
|
+
.option('--doctor', 'Run a marionette handshake preflight before tests (exit 1 on FAIL). With no paths, runs the preflight only.')
|
|
152
172
|
.action(withErrorHandling(async (paths, options) => {
|
|
153
173
|
await testCommand(getProjectRoot(), paths, pickDefined(options));
|
|
154
174
|
}));
|
|
@@ -17,9 +17,9 @@ export declare const CONFIGS_DIR = "configs";
|
|
|
17
17
|
/** Name of the source directory */
|
|
18
18
|
export declare const SRC_DIR = "src";
|
|
19
19
|
/** Supported top-level fireforge.json keys backed by the current schema. */
|
|
20
|
-
export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire", "patchLint"];
|
|
20
|
+
export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire", "patchLint", "markerComment"];
|
|
21
21
|
/** Supported config paths that can be read or set without --force. */
|
|
22
|
-
export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.rawColorAllowlist"];
|
|
22
|
+
export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.rawColorAllowlist", "markerComment"];
|
|
23
23
|
/**
|
|
24
24
|
* Gets all project paths based on a root directory.
|
|
25
25
|
* @param root - Root directory of the project
|
|
@@ -28,6 +28,7 @@ export const SUPPORTED_CONFIG_ROOT_KEYS = [
|
|
|
28
28
|
'license',
|
|
29
29
|
'wire',
|
|
30
30
|
'patchLint',
|
|
31
|
+
'markerComment',
|
|
31
32
|
];
|
|
32
33
|
/** Supported config paths that can be read or set without --force. */
|
|
33
34
|
export const SUPPORTED_CONFIG_PATHS = [
|
|
@@ -46,6 +47,7 @@ export const SUPPORTED_CONFIG_PATHS = [
|
|
|
46
47
|
'patchLint',
|
|
47
48
|
'patchLint.checkJs',
|
|
48
49
|
'patchLint.rawColorAllowlist',
|
|
50
|
+
'markerComment',
|
|
49
51
|
];
|
|
50
52
|
/**
|
|
51
53
|
* Gets all project paths based on a root directory.
|
|
@@ -121,6 +121,11 @@ export function validateConfig(data) {
|
|
|
121
121
|
}
|
|
122
122
|
config.license = licenseRaw;
|
|
123
123
|
}
|
|
124
|
+
// Marker comment — appended to lines FireForge writes into upstream files.
|
|
125
|
+
const markerComment = parseMarkerComment(rec.raw('markerComment'));
|
|
126
|
+
if (markerComment !== undefined) {
|
|
127
|
+
config.markerComment = markerComment;
|
|
128
|
+
}
|
|
124
129
|
// PatchLint
|
|
125
130
|
const patchLintRec = optionalConfigObject(rec, 'patchLint');
|
|
126
131
|
if (patchLintRec) {
|
|
@@ -167,6 +172,33 @@ function optionalConfigString(rec, key, label) {
|
|
|
167
172
|
}
|
|
168
173
|
return value;
|
|
169
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Validates a raw `markerComment` value. Rejected values: non-strings, empty
|
|
177
|
+
* strings, surrounding whitespace (ambiguous format), newlines (would break
|
|
178
|
+
* source formatting), and `*/` (would terminate an enclosing block comment
|
|
179
|
+
* downstream). Control characters are rejected for the same reason.
|
|
180
|
+
*/
|
|
181
|
+
function parseMarkerComment(raw) {
|
|
182
|
+
if (raw === undefined)
|
|
183
|
+
return undefined;
|
|
184
|
+
if (typeof raw !== 'string') {
|
|
185
|
+
throw new ConfigError('Config field "markerComment" must be a string');
|
|
186
|
+
}
|
|
187
|
+
if (raw.trim() === '') {
|
|
188
|
+
throw new ConfigError('Config field "markerComment" must not be empty');
|
|
189
|
+
}
|
|
190
|
+
if (raw !== raw.trim()) {
|
|
191
|
+
throw new ConfigError('Config field "markerComment" must not have leading or trailing whitespace');
|
|
192
|
+
}
|
|
193
|
+
if (/[\n\r]/.test(raw) || raw.includes('*/')) {
|
|
194
|
+
throw new ConfigError('Config field "markerComment" must not contain newlines or "*/"');
|
|
195
|
+
}
|
|
196
|
+
// eslint-disable-next-line no-control-regex -- intentionally rejecting control chars
|
|
197
|
+
if (/[\x00-\x1f]/.test(raw)) {
|
|
198
|
+
throw new ConfigError('Config field "markerComment" must not contain control characters');
|
|
199
|
+
}
|
|
200
|
+
return raw;
|
|
201
|
+
}
|
|
170
202
|
function optionalConfigObject(rec, key) {
|
|
171
203
|
const value = rec.raw(key);
|
|
172
204
|
if (value === undefined)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.ftl` apply/undeploy helpers for custom components. Extracted from
|
|
3
|
+
* `furnace-apply-helpers.ts` so the main helper module stays under the
|
|
4
|
+
* per-file LOC budget.
|
|
5
|
+
*
|
|
6
|
+
* Every helper here degrades gracefully: if the locale jar.mn is missing or
|
|
7
|
+
* the FTL tree is non-standard, apply logs a `stepError` rather than
|
|
8
|
+
* aborting the whole command. Missing jar.mn on a fork without a locale
|
|
9
|
+
* package should not block a working `.mjs`/`.css` from shipping.
|
|
10
|
+
*/
|
|
11
|
+
import type { DryRunAction, StepError } from '../types/furnace.js';
|
|
12
|
+
import { type RollbackJournal } from './furnace-rollback.js';
|
|
13
|
+
/**
|
|
14
|
+
* Copies a component's `.ftl` into the FTL tree and registers the chrome URI
|
|
15
|
+
* in the locale jar.mn.
|
|
16
|
+
*
|
|
17
|
+
* Failure modes (missing jar.mn, regex write error) are captured as
|
|
18
|
+
* stepErrors rather than thrown — a well-formed `.mjs`/`.css` must never be
|
|
19
|
+
* blocked by a broken locale path.
|
|
20
|
+
*/
|
|
21
|
+
export declare function applyCustomFtlFile(engineDir: string, name: string, componentDir: string, ftlDir: string, affectedPaths: string[], stepErrors: StepError[], rollbackJournal?: RollbackJournal): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Returns a dry-run action for registering a locale jar.mn entry for the
|
|
24
|
+
* `.ftl` that `applyCustomFtlFile` would write. `undefined` when the FTL
|
|
25
|
+
* tree does not expose a locale jar.mn we can confidently name.
|
|
26
|
+
*/
|
|
27
|
+
export declare function describeLocaleFtlJarMnRegistration(name: string, ftlDir: string, ftlFile: string): DryRunAction | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Drops the locale jar.mn entry for `fileName` when it's a `.ftl` whose
|
|
30
|
+
* source workspace file has been deleted. Idempotent — absent entries are a
|
|
31
|
+
* no-op.
|
|
32
|
+
*/
|
|
33
|
+
export declare function removeCustomFtlJarMnEntry(engineDir: string, fileName: string, ftlDir: string, rollbackJournal?: RollbackJournal): Promise<void>;
|