@hominis/fireforge 0.16.5 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +5 -3
- package/dist/src/commands/build.js +16 -7
- package/dist/src/commands/config.js +32 -20
- package/dist/src/commands/doctor.js +14 -1
- package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
- package/dist/src/commands/furnace/create-templates.d.ts +11 -0
- package/dist/src/commands/furnace/create-templates.js +11 -2
- package/dist/src/commands/furnace/init.js +97 -9
- package/dist/src/commands/furnace/rename.js +110 -0
- package/dist/src/commands/lint.js +55 -4
- package/dist/src/commands/resolve.d.ts +25 -1
- package/dist/src/commands/resolve.js +25 -15
- package/dist/src/commands/status.js +100 -122
- package/dist/src/commands/test.js +15 -2
- package/dist/src/commands/wire.js +34 -8
- package/dist/src/core/config.d.ts +33 -0
- package/dist/src/core/config.js +43 -0
- package/dist/src/core/furnace-config.d.ts +23 -2
- package/dist/src/core/furnace-config.js +26 -3
- package/dist/src/core/mach.d.ts +31 -0
- package/dist/src/core/mach.js +45 -1
- package/dist/src/core/marionette-port.d.ts +50 -0
- package/dist/src/core/marionette-port.js +215 -0
- package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
- package/dist/src/core/patch-manifest-consistency.js +16 -1
- package/dist/src/core/status-classify.d.ts +54 -0
- package/dist/src/core/status-classify.js +134 -0
- package/dist/src/core/token-dark-mode.d.ts +49 -0
- package/dist/src/core/token-dark-mode.js +182 -0
- package/dist/src/core/token-manager.js +17 -33
- package/dist/src/core/wire-dom-fragment.d.ts +17 -0
- package/dist/src/core/wire-dom-fragment.js +40 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.0
|
|
4
|
+
|
|
5
|
+
### Eval-driven hardening
|
|
6
|
+
|
|
7
|
+
- **`fireforge config` — serialised writes behind a sidecar lock.** The 2026-04-21 eval reproduced silent data loss by running two `fireforge config` invocations in parallel against the same `fireforge.json`: both commands exited `0`, but only one key survived. Atomic-rename writes (`writeJson`'s temp file + rename) prevented torn files but not lost updates: each writer read the pre-state, mutated its own copy, and the second rename clobbered the first writer's change. A new `withConfigFileLock(projectRoot, operation)` helper in `src/core/config.ts` wraps the read-modify-write cycle in `src/commands/config.ts:configCommand` — both the strict-validated and `--force` write branches now take the lock. Reads (`loadConfig`, `loadRawConfigDocument`) stay lock-free: atomic rename means readers always see either the pre- or post-state, so only writers need to be serialised. Stale-lock recovery reuses the existing PID-alive probe from `withFileLock` so a crashed `fireforge config` does not wedge the next command.
|
|
8
|
+
- **`fireforge token add --mode override` — dark values land inside the nested `:root { }`.** The previous `insertDarkModeOverride` in `src/core/token-manager.ts` found the outer `@media (prefers-color-scheme: dark) { }` block's closing `}` and spliced the dark-value declaration before that line — which is _after_ the nested `:root { }` had already closed, producing a declaration outside any rule block. The generated tokens CSS was syntactically malformed after every legitimate `token add --mode override` call, and `token coverage` no longer recognised the added tokens. The fix walks the comment-stripped line array to find the `:root {` opener _inside_ the `@media` block, then depth-counts to its own closing `}`, and inserts there. A fallback path (warn + synthesise a fresh nested `:root` before the outer close) handles malformed scaffolds where the nested `:root` was removed, so we never silently drop a dark value or emit a top-level declaration. The test coverage in `src/core/__tests__/token-manager.test.ts` now pins the invariant that the dark entry's line index must be _less_ than the inner `:root`'s closing `}`, so a future refactor cannot regress to the outer-block landing.
|
|
9
|
+
- **`fireforge furnace rename` — cleans up deployed widgets, renames mochikit test + chrome.toml.** The previous `renameTestFiles` helper in `src/commands/furnace/rename.ts` handled the `browser/base/content/test/<binaryName>/` browser-chrome layout but not the `toolkit/content/tests/widgets/` mochikit layout, and `performRenameMutations` made no attempt to clear `engine/<oldTargetPath>/` after deployment. The 2026-04-21 eval renamed `ff-chip-row` → `ff-chip-stack` and ended up with `engine/toolkit/content/widgets/ff-chip-row/` still deployed, `test_ff-chip-row.html` still importing `chrome://global/content/elements/ff-chip-row.mjs`, and the `chrome.toml` entry still naming the old file. Two new helpers pick up the slack: `removeStaleDeployedComponentDir` snapshots + removes `engine/<oldTargetPath>/` so the next `furnace apply` is the sole writer of the new deployment; `renameMochikitTestFiles` snapshots the old scaffold, rewrites the chrome URI + class-test identifiers to the new name, and updates the widgets `chrome.toml` entry. Both run under the same rollback journal as the rest of the rename, so a later failure restores every touched path.
|
|
10
|
+
- **`fireforge furnace create --with-tests` — scaffolded mochikit test runs to completion.** `generateMochikitTestContent` in `src/commands/furnace/create-templates.ts` previously emitted `SimpleTest.waitForExplicitFinish()` alongside an `add_task(...)` and no explicit `SimpleTest.finish()`. The test harness waits forever: `waitForExplicitFinish()` tells the harness not to finish on script end, and the `add_task`-managed finish never fires because the scaffold has no explicit `finish()` body. The 2026-04-21 eval's `fireforge test --headless toolkit/content/tests/widgets/test_ff-chip-row.html` hung until the operator SIGINT'd it. The fix removes the `waitForExplicitFinish()` call — `add_task` already calls `SimpleTest.finish()` when every queued task resolves, matching the convention upstream widget tests (`toolkit/content/tests/widgets/test_moz-button.html` and siblings) use. A regression test in `src/commands/furnace/__tests__/create-mochikit.test.ts` pins the contract that the generated content must not contain `waitForExplicitFinish`.
|
|
11
|
+
- **`fireforge furnace chrome-doc create --with-tests` — packaging test probes the correct packaged CSS path.** `generateChromeDocPackagingTest` in `src/commands/furnace/chrome-doc-tests.ts` probed `<AppDir>/chrome/browser/skin/classic/browser/<name>-chrome.css`, but the `jar.inc.mn` entry in `chrome-doc-templates.ts:chromeDocJarIncMnCssEntry` registers the file at `content/browser/<name>-chrome.css` — so the packaged location is `.../chrome/browser/content/browser/<name>-chrome.css`, not the skin layout. The 2026-04-21 eval's `fireforge test --build` against a scaffolded chrome-doc failed with a spurious "missing" assertion even though the file was correctly packaged. Both the primary probe and the macOS `.app`-bundle fallback now name the `content/browser/` layout, and a negative-match test guards against anyone pinning it back to `skin/classic/browser/` in a future refactor.
|
|
12
|
+
- **`fireforge status --json` — cross-patch ownership conflicts surface as `conflict` with `claimedBy`.** `classifyFiles` in `src/commands/status.ts` tracked patch ownership as a `Set<string>` and collapsed multi-owner paths into the single-owner branch, where the content-compare then routed them into `unmanaged` when the engine content didn't match any single patch's expected result. The 2026-04-21 eval's `status --json` run reported `"classification": "unmanaged"` on two files that `status --ownership` correctly labelled `CONFLICT` — scripts built on the JSON view mis-diagnosed the drift and could have taken the wrong corrective action. The classifier now builds a `Map<string, string[]>` (filename → claiming patch filenames), emits `classification: "conflict"` for entries claimed by two or more patches, and attaches `claimedBy: string[]` to those entries so machine consumers can read the ownership set directly. The human default-mode output now surfaces a `Cross-patch ownership conflicts` section at the top pointing at `status --ownership` and `re-export --files` for recovery. Single-claim entries stay byte-identical to the pre-0.16.0 JSON shape (no unconditional `claimedBy` field) so parsers unaware of the new classification continue to work.
|
|
13
|
+
- **`fireforge furnace init --ftl-base-path` — rejects file-shaped values up-front.** `validateFtlBasePath` in `src/commands/furnace/init.ts` only enforced syntactic safety (no absolute paths, no `..`, no null bytes). A plausible-but-invalid value like `browser/forgefresh.ftl` passed the gate, and the next localized `furnace create` scaffolded a component whose generated `.mjs` referenced `insertFTLIfNeeded("<name>.ftl")` while `furnace.json` never got the component entry — the scaffold was orphaned, every follow-up command failed with "not found in furnace.json", and the 2026-04-21 eval recorded this as Finding #5 (apparent non-registration) whose real root cause was Finding #6 (bad `ftlBasePath`). The validator now refuses any value whose basename carries `.ftl`, `.properties`, or `.dtd` with a message that names the file and points at a locale directory (`toolkit/locales/en-US/toolkit/global` or `browser/locales/en-US/browser`). When the engine directory is present on disk, it additionally probes whether the resolved path is a directory and warns (non-blocking) if the path does not yet exist — a fresh project that has not `fireforge download`-ed yet is legitimate.
|
|
14
|
+
- **`fireforge furnace init` — defaults `tokenPrefix` from `fireforge.json`'s `binaryName`.** `createDefaultFurnaceConfig` previously accepted no arguments and omitted `tokenPrefix` entirely, so every fresh project that ran `furnace init` → `token add` → `token coverage` got `0 tokens` and "all unknown" reports until the operator discovered the missing key and hand-edited `furnace.json`. The helper now accepts `{ binaryName }` and, when passed, seeds `tokenPrefix: \`--${binaryName}-\``so the coverage scan has a prefix to key off immediately.`furnaceInitCommand`best-effort-loads`fireforge.json`and threads the binaryName through — a project that initialises Furnace before`fireforge setup`completes still gets a valid prefix-less default (and`token coverage` continues to warn when invoked without a prefix set).
|
|
15
|
+
- **`fireforge build` + `fireforge build --ui` — per-project build lock prevents overlap.** A second `fireforge build --ui` launched while a full `fireforge build` was still running against the same engine tree raced the `obj-*` directory and failed immediately with `No rule to make target 'XUL'` — mach's downstream consequence of an incomplete backend, not a clue that the overlap was the root cause. A new `withBuildLock(projectRoot, operation)` in `src/core/mach.ts` backs onto `withFileLock` at `.fireforge-build.lock` beside the project root; `buildCommand` in `src/commands/build.ts` wraps both `build()` and `buildUI()` call paths in the lock. The refusal message names the holder PID and points at the stale-lock recovery path. Timeouts are bumped to 24h (a slow full build legitimately exceeds the default 30s) but the lock releases on process exit or via PID-alive stale recovery so a crashed build cannot permanently wedge the next invocation. A dedicated integration test in `src/core/__tests__/build-lock.integration.test.ts` pins the serialisation, throw-propagation, and stale-recovery contracts.
|
|
16
|
+
- **`fireforge wire --dry-run` — insertion-point probe runs in both modes.** The dry-run branch previously skipped the `pathExists(join(paths.engine, domTargetPath))` check that the real-run branch ran, and even with existence confirmed the real run could still throw `Could not find insertion point in chrome document` from deep inside `addDomFragment` when the resolved chrome doc offered neither `#include browser-sets.inc` nor `<html:body>`. The 2026-04-21 eval's `wire ... --dom ... --dry-run` previewed a plausible plan targeting `tokenHostDocuments[0]`, then the same command without `--dry-run` failed against a `furnace chrome-doc create`-scaffolded document that lacked both anchors. A new `probeDomFragmentInsertionPoint` helper in `src/core/wire-dom-fragment.ts` reads the chrome doc and runs the same tokenised + legacy insertion-point scan the real run uses; `wireCommand` now calls it in both modes and surfaces the `Could not find insertion point` error before printing the plan. The dry-run preview now refuses the same cases the real run would refuse, before any operator commits to executing.
|
|
17
|
+
- **`fireforge resolve` — `--yes` escape hatch + clearer two-step messaging.** The command refused any non-interactive invocation even after a CI-assisted manual merge was complete, because the TTY guard ran unconditionally — scripted recovery flows could complete the merge but could not then record the refreshed patch body. A new `--yes` / `-y` flag in `src/commands/resolve.ts` skips the interactive `confirm(...)` prompt and passes through in non-interactive mode; the unconditional TTY refusal fires only when the flag is absent. The command description changes from `Update a broken patch with manual fixes and continue` to `Update a broken patch with manual fixes (then run "fireforge import" to resume the queue)`, and the post-success info line now names the second-step command explicitly — the old copy implied a one-step flow where operators sometimes believed resolve continued the queue itself. Help snapshot under `src/__tests__/__snapshots__/help.test.ts.snap` is refreshed to match; resolve tests cover both the `--yes`-in-non-TTY path and the continuation messaging.
|
|
18
|
+
- **`fireforge test` — marionette port probe catches stale browsers before mach launches.** An interrupted `fireforge test --headless` run can leave a `<binaryName> -marionette` child listening on port `2828` with parent PID `1`. The next `fireforge test` run — potentially in a sibling FireForge project — fails immediately with a mach Marionette bind error that points nowhere near the real cause, and the generic "delete obj-\* and rebuild" guidance wastes operator time. A new `probeMarionettePort` / `assertMarionettePortAvailable` pair in `src/core/marionette-port.ts` runs `lsof -i tcp:2828 -sTCP:LISTEN` (POSIX) or `Get-NetTCPConnection` (Windows) before every test launch; when the holder's basename or command line identifies a Firefox-family browser (including `binaryName` from `fireforge.json` for branded forks), the probe raises a targeted `GeneralError` naming the PID and the exact `kill` command. Unrelated listeners produce a softer "this is not a FireForge-launched browser" error so the operator can tell the two cases apart. The probe is best-effort: missing `lsof`/PowerShell falls back to `{ inUse: false }` rather than failing the test run itself.
|
|
19
|
+
- **`fireforge lint` (default) — aggregate-mode skips tool-managed branding.** `resolveLintDiff` in `src/commands/lint.ts` passed the full `getAllDiff(engineDir)` to `lintExportedPatch` when no file list was supplied, so a fresh-setup workspace with 63 modified branding files fired `large-patch-lines`, `large-patch-files`, and `missing-license-header` on tool-managed content the operator never authored. The 2026-04-21 eval's first `fireforge lint` on a newly-built `fresh/` tree failed with blocking patch-lint errors despite the project being minimal-customisation. The aggregate-mode branch now loads `fireforge.json`'s `binaryName`, partitions the dirty tree with `isBrandingManagedPath`, and passes only the non-branding paths into `getDiffForFilesAgainstHead`. The operator sees a one-line `info()` naming the excluded count so the exclusion is visible, not silent. Explicit-path mode (`fireforge lint <path>`) preserves the previous behaviour — passing a branding path explicitly still lints it, so operators who need to audit generated branding content can do so.
|
|
20
|
+
- **`fireforge doctor --repair-patches-manifest` — names every reconstructed entry.** `rebuildPatchesManifest` previously returned only the rebuilt manifest, silently overwriting `description` / `createdAt` with generic fallback values when the existing entry was missing. FireForge patch files do not carry header metadata that could carry human-written descriptions forward, so full fidelity is impossible — but at least visibility is. The helper now returns `{ manifest, recoveredFilenames }` in `src/core/patch-manifest-consistency.ts`; the doctor repair path prints a per-filename `warn(...)` telling the operator exactly which manifest entry was reconstructed from generic defaults and suggesting they edit `patches.json` if they have the original description backed up. The summary row count now reports both the total patches rebuilt and the subset that needed reconstruction.
|
|
21
|
+
|
|
3
22
|
## 0.16.0
|
|
4
23
|
|
|
5
24
|
### UX, correctness, and consistency
|
package/README.md
CHANGED
|
@@ -174,7 +174,7 @@ When `fireforge import` fails on a patch, fix the `.rej` files in `engine/`, the
|
|
|
174
174
|
fireforge resolve
|
|
175
175
|
```
|
|
176
176
|
|
|
177
|
-
This re-exports the fixed patch and
|
|
177
|
+
This re-exports the fixed patch and clears the conflict state. The command is deliberately a single-patch refresh — to continue applying the remainder of the queue, run `fireforge import` afterwards. For scripted or CI-driven recovery, pass `--yes` (or `-y`) to skip the interactive "are you done?" prompt; the flag is the explicit opt-in for non-interactive use once the manual merge is complete.
|
|
178
178
|
|
|
179
179
|
<details>
|
|
180
180
|
<summary>Patch manifest format</summary>
|
|
@@ -211,7 +211,7 @@ If the manifest drifts after an interrupted export or manual edits, `fireforge i
|
|
|
211
211
|
|
|
212
212
|
`fireforge lint` runs automatically during export, export-all and re-export. Use `--skip-lint` to downgrade errors to warnings. Errors block the export; warnings are printed but do not block.
|
|
213
213
|
|
|
214
|
-
By default, a standalone `fireforge lint` (no arguments) lints the **aggregate** `git diff HEAD` — i.e. every applied patch summed. On a repo where `fireforge import` or `fireforge rebase` has just applied the full queue, the patch-size rules (`large-patch-lines`, `large-patch-files`) fire against the sum, which reads as "my queue is broken" when it is really an artefact of aggregation. Use `fireforge lint --per-patch` to rescope the diff to each patch's own `filesAffected`, honouring the patch's own `lintIgnore`. Cross-patch rules (`duplicate-new-file-creation`, `forward-import`) still run once over the whole queue either way. Pass explicit file paths to narrow the scope further; the three modes (aggregate, file-scoped, per-patch) are mutually exclusive.
|
|
214
|
+
By default, a standalone `fireforge lint` (no arguments) lints the **aggregate** `git diff HEAD` — i.e. every applied patch summed — with tool-managed branding paths (`browser/branding/<binaryName>/`) excluded. A fresh-setup workspace carries a large generated branding diff that operators did not author directly, and letting it through tripped the patch-size and license-header rules on content that matches the `branding` bucket in `fireforge status`. When the exclusion fires the command prints a one-line note naming the excluded count so the filter is visible. On a repo where `fireforge import` or `fireforge rebase` has just applied the full queue, the patch-size rules (`large-patch-lines`, `large-patch-files`) fire against the sum, which reads as "my queue is broken" when it is really an artefact of aggregation. Use `fireforge lint --per-patch` to rescope the diff to each patch's own `filesAffected`, honouring the patch's own `lintIgnore`. Cross-patch rules (`duplicate-new-file-creation`, `forward-import`) still run once over the whole queue either way. Pass explicit file paths to narrow the scope further — explicit-path mode does lint branding files (the operator's explicit request wins over the branding exclusion); the three modes (aggregate, file-scoped, per-patch) are mutually exclusive.
|
|
215
215
|
|
|
216
216
|
| Check | Scope | Severity |
|
|
217
217
|
| ------------------------------ | ------------------------------------------------------------------------- | ------------------------ |
|
|
@@ -409,6 +409,8 @@ fireforge config firefox.version 145.0.0esr
|
|
|
409
409
|
fireforge config customKey "value" --force
|
|
410
410
|
```
|
|
411
411
|
|
|
412
|
+
Writes are serialised behind a sidecar lock — two concurrent `fireforge config` invocations against the same `fireforge.json` (for example, parallel automation steps) queue instead of racing the read-modify-write. The lock is released automatically on process exit; stale locks from a crashed earlier command are reclaimed on the next invocation via the PID-alive probe.
|
|
413
|
+
|
|
412
414
|
### Patch queue management
|
|
413
415
|
|
|
414
416
|
```bash
|
|
@@ -440,7 +442,7 @@ fireforge watch
|
|
|
440
442
|
fireforge token add --category 'Colors — General' -- --my-color 'light-dark(#fff, #000)'
|
|
441
443
|
```
|
|
442
444
|
|
|
443
|
-
Tokens live in the Furnace-managed tokens CSS file (`engine/browser/themes/shared/<binaryName>-tokens.css`), scaffolded by `fireforge furnace init` alongside `furnace.json`. The scaffold seeds a default set of categories (`Colors — General`, `Colors — Canvas`, `Colors — Experiment`, `Spacing`); add a category by hand as a `/* = My Category = */` comment inside the `:root { … }` block if you need another. `fireforge furnace init` also registers the tokens CSS path in `patchLint.rawColorAllowlist` so raw color literals inside it are not flagged by `fireforge lint
|
|
445
|
+
Tokens live in the Furnace-managed tokens CSS file (`engine/browser/themes/shared/<binaryName>-tokens.css`), scaffolded by `fireforge furnace init` alongside `furnace.json`. The scaffold seeds a default set of categories (`Colors — General`, `Colors — Canvas`, `Colors — Experiment`, `Spacing`); add a category by hand as a `/* = My Category = */` comment inside the `:root { … }` block if you need another. `fireforge furnace init` also registers the tokens CSS path in `patchLint.rawColorAllowlist` so raw color literals inside it are not flagged by `fireforge lint`, and derives `tokenPrefix: --<binaryName>-` from `fireforge.json`'s `binaryName` so `fireforge token coverage` has a prefix to key off on the very first run. Projects that prefer a different prefix can override it in `furnace.json` after init.
|
|
444
446
|
|
|
445
447
|
### Diff-scoped lint (`lint --since`)
|
|
446
448
|
|
|
@@ -5,7 +5,7 @@ import { auditBuildArtifacts } from '../core/build-audit.js';
|
|
|
5
5
|
import { readBuildBaseline, writeBuildBaseline } from '../core/build-baseline.js';
|
|
6
6
|
import { prepareBuildEnvironment } from '../core/build-prepare.js';
|
|
7
7
|
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
8
|
-
import { attemptMozinfoRewrite, build, buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, runMach, } from '../core/mach.js';
|
|
8
|
+
import { attemptMozinfoRewrite, build, buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, runMach, withBuildLock, } from '../core/mach.js';
|
|
9
9
|
import { GeneralError } from '../errors/base.js';
|
|
10
10
|
import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
|
|
11
11
|
import { toError } from '../utils/errors.js';
|
|
@@ -129,12 +129,21 @@ export async function buildCommand(projectRoot, options) {
|
|
|
129
129
|
const startTime = Date.now();
|
|
130
130
|
let exitCode;
|
|
131
131
|
try {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
132
|
+
// Hold the per-project build lock across the mach invocation so two
|
|
133
|
+
// overlapping `fireforge build` / `fireforge build --ui` commands
|
|
134
|
+
// against the same engine tree serialise instead of racing through
|
|
135
|
+
// the same obj-*. 2026-04-21 eval: a `build --ui` launched during
|
|
136
|
+
// an in-progress full build hit `No rule to make target 'XUL'` in
|
|
137
|
+
// mach, which is the downstream consequence of an incomplete
|
|
138
|
+
// backend — not a clue that a concurrent build was the cause. The
|
|
139
|
+
// lock turns the second invocation's failure into an explicit
|
|
140
|
+
// refusal naming the holder PID.
|
|
141
|
+
exitCode = await withBuildLock(projectRoot, async () => {
|
|
142
|
+
if (options.ui) {
|
|
143
|
+
return buildUI(paths.engine);
|
|
144
|
+
}
|
|
145
|
+
return build(paths.engine, jobs);
|
|
146
|
+
});
|
|
138
147
|
}
|
|
139
148
|
catch (error) {
|
|
140
149
|
throw new BuildError('Build process failed to start', options.ui ? 'mach build faster' : 'mach build', error instanceof Error ? error : undefined);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { configExists, loadConfig, loadRawConfigDocument, mutateConfig, SUPPORTED_CONFIG_PATHS, SUPPORTED_CONFIG_ROOT_KEYS, writeConfig, writeConfigDocument, } from '../core/config.js';
|
|
1
|
+
import { configExists, loadConfig, loadRawConfigDocument, mutateConfig, SUPPORTED_CONFIG_PATHS, SUPPORTED_CONFIG_ROOT_KEYS, withConfigFileLock, writeConfig, writeConfigDocument, } from '../core/config.js';
|
|
2
2
|
import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
3
3
|
import { toError } from '../utils/errors.js';
|
|
4
4
|
import { info, intro, outro, success, warn } from '../utils/logger.js';
|
|
@@ -112,25 +112,37 @@ export async function configCommand(projectRoot, key, value, options = {}) {
|
|
|
112
112
|
const parsedValue = parseValue(value, key);
|
|
113
113
|
const keyIsKnown = SUPPORTED_CONFIG_PATHS.includes(key);
|
|
114
114
|
try {
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
115
|
+
// Serialise the read → mutate → write round-trip behind the sidecar
|
|
116
|
+
// config lock so two concurrent `fireforge config` invocations can't
|
|
117
|
+
// each read the pre-state, mutate their own copy, and clobber each
|
|
118
|
+
// other on write. Before the lock, the 2026-04-21 eval reproduced
|
|
119
|
+
// silent data loss with two parallel `fireforge config <key>
|
|
120
|
+
// <value>` commands writing different keys: both exited 0, one key
|
|
121
|
+
// survived, the other vanished. Atomic file writes (temp + rename)
|
|
122
|
+
// were never enough on their own — the lost update happens before
|
|
123
|
+
// the rename, inside the read-modify step. Readers stay lock-free
|
|
124
|
+
// (see `withConfigFileLock` docstring).
|
|
125
|
+
await withConfigFileLock(projectRoot, async () => {
|
|
126
|
+
// `--force` is intended as an escape hatch for *unknown* keys; it
|
|
127
|
+
// should not also let the user write a structurally invalid value
|
|
128
|
+
// for a *known* key. Apply strict validation whenever the key is
|
|
129
|
+
// listed in SUPPORTED_CONFIG_PATHS, regardless of --force, and only
|
|
130
|
+
// skip validation for genuinely unknown key paths.
|
|
131
|
+
if (options.force && !keyIsKnown) {
|
|
132
|
+
// Seed mutation from the raw on-disk document so previously-forced
|
|
133
|
+
// keys (which `validateConfig` would strip) survive the round-trip.
|
|
134
|
+
// Without this, writing a second --force key would silently drop
|
|
135
|
+
// every earlier forced key from fireforge.json.
|
|
136
|
+
const rawConfig = await loadRawConfigDocument(projectRoot);
|
|
137
|
+
const updatedConfig = mutateConfig(rawConfig, key, parsedValue, true);
|
|
138
|
+
await writeConfigDocument(projectRoot, updatedConfig);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const config = await loadConfig(projectRoot);
|
|
142
|
+
const updatedConfig = mutateConfig(config, key, parsedValue);
|
|
143
|
+
await writeConfig(projectRoot, updatedConfig);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
134
146
|
}
|
|
135
147
|
catch (error) {
|
|
136
148
|
throw new InvalidArgumentError(`Invalid value for "${key}": ${toError(error).message}`, key);
|
|
@@ -314,7 +314,20 @@ const DOCTOR_CHECKS = [
|
|
|
314
314
|
}
|
|
315
315
|
try {
|
|
316
316
|
const repaired = await rebuildPatchesManifest(ctx.paths.patches, ctx.config.firefox.version);
|
|
317
|
-
|
|
317
|
+
// 2026-04-21 eval (Finding #17): the repair path silently
|
|
318
|
+
// overwrote useful human-written descriptions on recovered
|
|
319
|
+
// entries, leaving the queue less trustworthy as an audit
|
|
320
|
+
// trail. The rebuilder now returns the list of filenames
|
|
321
|
+
// whose metadata was entirely invented, and we name them
|
|
322
|
+
// explicitly here so the operator knows exactly which
|
|
323
|
+
// patches to review. Names that DID have a preserved entry
|
|
324
|
+
// (only `filesAffected` / ordering drifted) are not flagged.
|
|
325
|
+
if (repaired.recoveredFilenames.length > 0) {
|
|
326
|
+
for (const filename of repaired.recoveredFilenames) {
|
|
327
|
+
warn(`Recovered manifest entry for ${filename} with generic description and mtime-based createdAt. Edit patches.json to restore the original description if you have it backed up.`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return warning('Patch manifest consistency', `Rebuilt patches.json from ${repaired.manifest.patches.length} patch${repaired.manifest.patches.length === 1 ? '' : 'es'}${repaired.recoveredFilenames.length > 0 ? ` (${repaired.recoveredFilenames.length} with reconstructed metadata — see warnings above)` : ''}. Review recovered metadata before release.`);
|
|
318
331
|
}
|
|
319
332
|
catch (err) {
|
|
320
333
|
return failure('Patch manifest consistency', toError(err).message, 'Repair failed. Fix the underlying patch metadata issue and retry the doctor command.');
|
|
@@ -117,9 +117,16 @@ add_task(async function test_${taskSuffix}_files_packaged() {
|
|
|
117
117
|
["browser", "chrome", "browser", "content", "browser", "${name}.xhtml"],
|
|
118
118
|
"${name}.xhtml",
|
|
119
119
|
);
|
|
120
|
+
// The scoped CSS is registered through jar.inc.mn under
|
|
121
|
+
// \`content/browser/<name>-chrome.css\` (see \`chromeDocJarIncMnCssEntry\`
|
|
122
|
+
// in \`src/commands/furnace/chrome-doc-templates.ts\`), so the packaged
|
|
123
|
+
// file lands under \`chrome/browser/content/browser/\`, not under
|
|
124
|
+
// \`skin/classic/browser/\`. The 2026-04-21 eval's first
|
|
125
|
+
// \`fireforge test --build\` against a scaffolded chrome-doc reported
|
|
126
|
+
// a false failure because the probe was looking at the skin layout.
|
|
120
127
|
probeEither(
|
|
121
|
-
["chrome", "browser", "
|
|
122
|
-
["browser", "chrome", "browser", "
|
|
128
|
+
["chrome", "browser", "content", "browser", "${name}-chrome.css"],
|
|
129
|
+
["browser", "chrome", "browser", "content", "browser", "${name}-chrome.css"],
|
|
123
130
|
"${name}-chrome.css",
|
|
124
131
|
);
|
|
125
132
|
});
|
|
@@ -76,6 +76,17 @@ export declare function mochikitTestFileName(name: string): string;
|
|
|
76
76
|
* depend on the component's shape; operators can extend the test using
|
|
77
77
|
* the same SimpleTest APIs upstream toolkit widgets (moz-button, etc.)
|
|
78
78
|
* rely on.
|
|
79
|
+
*
|
|
80
|
+
* The template deliberately omits `SimpleTest.waitForExplicitFinish()`.
|
|
81
|
+
* `add_task` owns the test lifecycle: when every queued task resolves,
|
|
82
|
+
* the task harness calls `SimpleTest.finish()` on its own. Combining
|
|
83
|
+
* `waitForExplicitFinish()` with `add_task` *and* no explicit
|
|
84
|
+
* `SimpleTest.finish()` inside the task body makes the harness wait
|
|
85
|
+
* forever, which the 2026-04-21 eval run tripped into as an indefinite
|
|
86
|
+
* hang on a `fireforge test --headless` against a scaffolded widget
|
|
87
|
+
* test. Leaving `waitForExplicitFinish()` out matches the convention
|
|
88
|
+
* upstream toolkit widget tests use (see `test_moz-button.html` and
|
|
89
|
+
* siblings under `toolkit/content/tests/widgets/`).
|
|
79
90
|
*/
|
|
80
91
|
export declare function generateMochikitTestContent(name: string): string;
|
|
81
92
|
/**
|
|
@@ -227,6 +227,17 @@ export function mochikitTestFileName(name) {
|
|
|
227
227
|
* depend on the component's shape; operators can extend the test using
|
|
228
228
|
* the same SimpleTest APIs upstream toolkit widgets (moz-button, etc.)
|
|
229
229
|
* rely on.
|
|
230
|
+
*
|
|
231
|
+
* The template deliberately omits `SimpleTest.waitForExplicitFinish()`.
|
|
232
|
+
* `add_task` owns the test lifecycle: when every queued task resolves,
|
|
233
|
+
* the task harness calls `SimpleTest.finish()` on its own. Combining
|
|
234
|
+
* `waitForExplicitFinish()` with `add_task` *and* no explicit
|
|
235
|
+
* `SimpleTest.finish()` inside the task body makes the harness wait
|
|
236
|
+
* forever, which the 2026-04-21 eval run tripped into as an indefinite
|
|
237
|
+
* hang on a `fireforge test --headless` against a scaffolded widget
|
|
238
|
+
* test. Leaving `waitForExplicitFinish()` out matches the convention
|
|
239
|
+
* upstream toolkit widget tests use (see `test_moz-button.html` and
|
|
240
|
+
* siblings under `toolkit/content/tests/widgets/`).
|
|
230
241
|
*/
|
|
231
242
|
export function generateMochikitTestContent(name) {
|
|
232
243
|
return `<!DOCTYPE html>
|
|
@@ -244,8 +255,6 @@ export function generateMochikitTestContent(name) {
|
|
|
244
255
|
<script type="module">
|
|
245
256
|
import "chrome://global/content/elements/${name}.mjs";
|
|
246
257
|
|
|
247
|
-
SimpleTest.waitForExplicitFinish();
|
|
248
|
-
|
|
249
258
|
add_task(async function test_${name.replace(/-/g, '_')}_defined() {
|
|
250
259
|
const ctor = await customElements.whenDefined("${name}");
|
|
251
260
|
ok(ctor, "${name} custom element should be defined");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
-
import {
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, normalize } from 'node:path';
|
|
3
4
|
import { text } from '@clack/prompts';
|
|
4
5
|
import { getProjectPaths, loadConfig, mutateConfig, writeConfig } from '../../core/config.js';
|
|
5
6
|
import { createDefaultFurnaceConfig, furnaceConfigExists, writeFurnaceConfig, } from '../../core/furnace-config.js';
|
|
@@ -11,12 +12,46 @@ import { toError } from '../../utils/errors.js';
|
|
|
11
12
|
import { ensureDir, pathExists, writeText } from '../../utils/fs.js';
|
|
12
13
|
import { cancel, info, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
|
|
13
14
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* File extensions that are definitely FTL resources (not locale
|
|
16
|
+
* directories). A value ending in one of these is almost certainly the
|
|
17
|
+
* result of the operator pointing at a single FTL file instead of the
|
|
18
|
+
* locale directory that contains it.
|
|
19
|
+
*
|
|
20
|
+
* 2026-04-21 eval: `furnace init --ftl-base-path browser/forgefresh.ftl`
|
|
21
|
+
* produced a misleading success path — the subsequent
|
|
22
|
+
* `furnace create --localized` scaffolded an `.mjs` referencing
|
|
23
|
+
* `insertFTLIfNeeded("<name>.ftl")` while furnace.json had no component
|
|
24
|
+
* entry, leaving the scaffold orphaned. Switching to a locale directory
|
|
25
|
+
* (`toolkit/locales/en-US/toolkit/global`) fixed the downstream path.
|
|
26
|
+
* Rejecting file-shaped values up-front keeps the operator on the
|
|
27
|
+
* correct path before any partial state is written.
|
|
28
|
+
*/
|
|
29
|
+
const FTL_FILE_EXTENSIONS = new Set(['.ftl', '.properties', '.dtd']);
|
|
30
|
+
function hasFtlFileExtension(value) {
|
|
31
|
+
const lower = value.toLowerCase();
|
|
32
|
+
const dotIdx = lower.lastIndexOf('.');
|
|
33
|
+
const slashIdx = Math.max(lower.lastIndexOf('/'), lower.lastIndexOf('\\'));
|
|
34
|
+
if (dotIdx <= slashIdx)
|
|
35
|
+
return false; // No extension in the basename.
|
|
36
|
+
return FTL_FILE_EXTENSIONS.has(lower.slice(dotIdx));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Validates an FTL base path before writing it to furnace.json.
|
|
40
|
+
* Rejects:
|
|
41
|
+
* - empty values and null bytes;
|
|
42
|
+
* - absolute paths (POSIX or Windows-drive) that escape the engine;
|
|
43
|
+
* - `..` segments that escape the engine;
|
|
44
|
+
* - file-shaped values ending in `.ftl` / `.properties` / `.dtd`
|
|
45
|
+
* (these are locale resources, not directories — the operator
|
|
46
|
+
* almost certainly meant to name the parent directory).
|
|
47
|
+
*
|
|
48
|
+
* When {@link engineDir} is provided and exists on disk, the resolved
|
|
49
|
+
* `engine/${value}` path is probed: if it exists but is not a
|
|
50
|
+
* directory, the same file-shape error fires; if it does not exist yet,
|
|
51
|
+
* a non-blocking warning is logged (a fresh project that has not
|
|
52
|
+
* `fireforge download`-ed yet is the legitimate pre-existence case).
|
|
18
53
|
*/
|
|
19
|
-
function validateFtlBasePath(value) {
|
|
54
|
+
async function validateFtlBasePath(value, engineDir) {
|
|
20
55
|
if (value.length === 0) {
|
|
21
56
|
throw new FurnaceError('ftlBasePath must not be empty.');
|
|
22
57
|
}
|
|
@@ -30,6 +65,40 @@ function validateFtlBasePath(value) {
|
|
|
30
65
|
if (normalized === '..' || normalized.startsWith('../')) {
|
|
31
66
|
throw new FurnaceError(`ftlBasePath "${value}" must not escape the engine checkout via parent-directory segments.`);
|
|
32
67
|
}
|
|
68
|
+
if (hasFtlFileExtension(value)) {
|
|
69
|
+
throw new FurnaceError(`ftlBasePath "${value}" looks like a file (basename "${basename(value)}" ends in .ftl/.properties/.dtd), but FireForge expects a locale directory such as toolkit/locales/en-US/toolkit/global or browser/locales/en-US/browser. Use the parent directory instead.`);
|
|
70
|
+
}
|
|
71
|
+
// Shape probe against the real filesystem when we have an engine
|
|
72
|
+
// directory to anchor against. The probe is best-effort: a missing
|
|
73
|
+
// engine directory or a not-yet-extracted locale tree is
|
|
74
|
+
// legitimate (an operator may `furnace init` before `fireforge
|
|
75
|
+
// download`), so we emit a warning rather than refusing.
|
|
76
|
+
if (engineDir) {
|
|
77
|
+
const resolved = join(engineDir, value);
|
|
78
|
+
try {
|
|
79
|
+
const info = await stat(resolved);
|
|
80
|
+
if (!info.isDirectory()) {
|
|
81
|
+
throw new FurnaceError(`ftlBasePath "${value}" resolves to a non-directory at ${resolved}. FireForge expects a locale directory (for example toolkit/locales/en-US/toolkit/global or browser/locales/en-US/browser).`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
// FurnaceError (from the `isDirectory()` branch above) is a real
|
|
86
|
+
// shape failure — re-throw so the operator sees it.
|
|
87
|
+
if (error instanceof FurnaceError)
|
|
88
|
+
throw error;
|
|
89
|
+
// ENOENT is expected on a fresh project before `fireforge
|
|
90
|
+
// download` has populated engine/; only warn.
|
|
91
|
+
const code = typeof error === 'object' && error !== null && 'code' in error
|
|
92
|
+
? error.code
|
|
93
|
+
: undefined;
|
|
94
|
+
if (code === 'ENOENT') {
|
|
95
|
+
warn(`ftlBasePath "${value}" does not yet exist at ${resolved}. This is fine if you have not run "fireforge download" yet; rerun "fireforge furnace init --force" after the engine is extracted to re-validate.`);
|
|
96
|
+
}
|
|
97
|
+
// Any other stat error is also best-effort ignored here — a
|
|
98
|
+
// permission issue or malformed engine checkout will surface on
|
|
99
|
+
// the next command that actually reads the FTL tree.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
33
102
|
}
|
|
34
103
|
/**
|
|
35
104
|
* Runs the furnace init command to create a default furnace.json with
|
|
@@ -42,8 +111,27 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
|
|
|
42
111
|
if ((await furnaceConfigExists(projectRoot)) && !options.force) {
|
|
43
112
|
throw new FurnaceError('furnace.json already exists. Use --force to overwrite it.');
|
|
44
113
|
}
|
|
45
|
-
const
|
|
114
|
+
const paths = getProjectPaths(projectRoot);
|
|
115
|
+
// Seed the default furnace config with a tokenPrefix derived from
|
|
116
|
+
// fireforge.json's binaryName so `token coverage` sees real tokens on
|
|
117
|
+
// the very first run. The 2026-04-21 eval initialised Furnace, added
|
|
118
|
+
// tokens, ran coverage, and got `0 tokens / N unknown` — the prefix
|
|
119
|
+
// default was absent and the scan had nothing to key off. Loading
|
|
120
|
+
// fireforge.json here is best-effort: a project without one (e.g.
|
|
121
|
+
// mid-setup) falls through to the prefix-less default, and
|
|
122
|
+
// `token coverage` emits the existing "no tokenPrefix" warning.
|
|
123
|
+
let derivedBinaryName;
|
|
124
|
+
try {
|
|
125
|
+
const fireForgeConfig = await loadConfig(projectRoot);
|
|
126
|
+
derivedBinaryName = fireForgeConfig.binaryName;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Best-effort only: initialising furnace without a fireforge.json is
|
|
130
|
+
// rare but not forbidden. Skip the prefix default in that case.
|
|
131
|
+
}
|
|
132
|
+
const config = createDefaultFurnaceConfig(derivedBinaryName ? { binaryName: derivedBinaryName } : {});
|
|
46
133
|
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
|
|
134
|
+
const engineForValidation = (await pathExists(paths.engine)) ? paths.engine : undefined;
|
|
47
135
|
// Resolve componentPrefix
|
|
48
136
|
if (options.prefix !== undefined) {
|
|
49
137
|
config.componentPrefix = options.prefix;
|
|
@@ -66,7 +154,7 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
|
|
|
66
154
|
}
|
|
67
155
|
// Resolve ftlBasePath
|
|
68
156
|
if (options.ftlBasePath !== undefined) {
|
|
69
|
-
validateFtlBasePath(options.ftlBasePath);
|
|
157
|
+
await validateFtlBasePath(options.ftlBasePath, engineForValidation);
|
|
70
158
|
config.ftlBasePath = options.ftlBasePath;
|
|
71
159
|
}
|
|
72
160
|
else if (isInteractive) {
|
|
@@ -80,7 +168,7 @@ export async function furnaceInitCommand(projectRoot, options = {}) {
|
|
|
80
168
|
}
|
|
81
169
|
const ftlValue = ftlResult.trim();
|
|
82
170
|
if (ftlValue) {
|
|
83
|
-
validateFtlBasePath(ftlValue);
|
|
171
|
+
await validateFtlBasePath(ftlValue, engineForValidation);
|
|
84
172
|
config.ftlBasePath = ftlValue;
|
|
85
173
|
}
|
|
86
174
|
}
|
|
@@ -122,6 +122,94 @@ async function renameTestFiles(engineDir, projectRoot, oldName, newName, journal
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Removes the deployed custom-widget directory at the old target path so
|
|
127
|
+
* a subsequent `furnace apply` is the single writer of the new name's
|
|
128
|
+
* deployment. Best-effort: logs a warning but never blocks the rename.
|
|
129
|
+
*
|
|
130
|
+
* 2026-04-21 eval: renaming `ff-chip-row` → `ff-chip-stack` registered
|
|
131
|
+
* and deployed the new name correctly but left `engine/toolkit/content/
|
|
132
|
+
* widgets/ff-chip-row/` in place. Subsequent `furnace sync` runs could
|
|
133
|
+
* not clear the stale widget, and packaging would have pulled in both
|
|
134
|
+
* copies. The snapshot is taken before the remove so the rollback
|
|
135
|
+
* journal restores the old directory if any later step in
|
|
136
|
+
* `performRenameMutations` fails.
|
|
137
|
+
*/
|
|
138
|
+
async function removeStaleDeployedComponentDir(engineDir, oldTargetPath, journal) {
|
|
139
|
+
const oldDeployed = join(engineDir, oldTargetPath);
|
|
140
|
+
if (!(await pathExists(oldDeployed)))
|
|
141
|
+
return;
|
|
142
|
+
try {
|
|
143
|
+
await snapshotDir(journal, oldDeployed);
|
|
144
|
+
await removeDir(oldDeployed);
|
|
145
|
+
info(`Removed stale deployed widget directory: ${oldTargetPath}`);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
warn(`Could not remove stale deployed widget directory at ${oldTargetPath}: ${toError(error).message}. Remove it manually if needed.`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Renames the mochikit test scaffold produced by `furnace create
|
|
153
|
+
* --with-tests` when the default test style is used. The scaffold lives
|
|
154
|
+
* at `engine/toolkit/content/tests/widgets/test_<name>.html`, and the
|
|
155
|
+
* accompanying `chrome.toml` entry names the same file. Neither piece
|
|
156
|
+
* was handled by the pre-0.16.0 rename, so operators were left with a
|
|
157
|
+
* `test_<old>.html` file that still imported `chrome://global/content/
|
|
158
|
+
* elements/<old>.mjs` and referenced `customElements.whenDefined("<old>")`
|
|
159
|
+
* — the test ran against a component that no longer existed under that
|
|
160
|
+
* name and either failed or (if the old component was still deployed)
|
|
161
|
+
* passed for the wrong reason.
|
|
162
|
+
*
|
|
163
|
+
* Best-effort: individual failures log a warning. The same journal used
|
|
164
|
+
* for the rest of the rename snapshots every touched file so a later
|
|
165
|
+
* failure rolls the pair back together.
|
|
166
|
+
*/
|
|
167
|
+
async function renameMochikitTestFiles(engineDir, oldName, newName, journal) {
|
|
168
|
+
const testDir = join(engineDir, 'toolkit/content/tests/widgets');
|
|
169
|
+
if (!(await pathExists(testDir)))
|
|
170
|
+
return;
|
|
171
|
+
const oldTestFileName = `test_${oldName}.html`;
|
|
172
|
+
const newTestFileName = `test_${newName}.html`;
|
|
173
|
+
const oldTestPath = join(testDir, oldTestFileName);
|
|
174
|
+
const newTestPath = join(testDir, newTestFileName);
|
|
175
|
+
if (await pathExists(oldTestPath)) {
|
|
176
|
+
try {
|
|
177
|
+
await snapshotFile(journal, oldTestPath);
|
|
178
|
+
const content = await readText(oldTestPath);
|
|
179
|
+
const updatedContent = content
|
|
180
|
+
.replace(new RegExp(`chrome://global/content/elements/${escapeRegex(oldName)}\\.mjs`, 'g'), `chrome://global/content/elements/${newName}.mjs`)
|
|
181
|
+
.replace(new RegExp(`customElements\\.whenDefined\\("${escapeRegex(oldName)}"\\)`, 'g'), `customElements.whenDefined("${newName}")`)
|
|
182
|
+
.replace(new RegExp(`Test the ${escapeRegex(oldName)} `, 'g'), `Test the ${newName} `)
|
|
183
|
+
.replace(new RegExp(`add_task\\(async function test_${escapeRegex(oldName.replace(/-/g, '_'))}_defined\\(`, 'g'), `add_task(async function test_${newName.replace(/-/g, '_')}_defined(`)
|
|
184
|
+
.replace(new RegExp(`"${escapeRegex(oldName)} custom element`, 'g'), `"${newName} custom element`);
|
|
185
|
+
await writeText(newTestPath, updatedContent);
|
|
186
|
+
await removeFile(oldTestPath);
|
|
187
|
+
info(`Renamed mochikit test: ${oldTestFileName} → ${newTestFileName}`);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
warn(`Could not rename mochikit test file — ${toError(error).message}. Rename it manually if needed.`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Update `chrome.toml` entry if present. The file may live in the
|
|
194
|
+
// same widgets/tests directory as the test file itself; upstream
|
|
195
|
+
// convention places exactly one `chrome.toml` there for all widget
|
|
196
|
+
// scaffolds.
|
|
197
|
+
const chromeTomlPath = join(testDir, 'chrome.toml');
|
|
198
|
+
if (await pathExists(chromeTomlPath)) {
|
|
199
|
+
try {
|
|
200
|
+
const toml = await readText(chromeTomlPath);
|
|
201
|
+
if (toml.includes(`["${oldTestFileName}"]`)) {
|
|
202
|
+
await snapshotFile(journal, chromeTomlPath);
|
|
203
|
+
const updated = toml.replace(`["${oldTestFileName}"]`, `["${newTestFileName}"]`);
|
|
204
|
+
await writeText(chromeTomlPath, updated);
|
|
205
|
+
info(`Updated chrome.toml: ${oldTestFileName} → ${newTestFileName}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
warn(`Could not update widgets chrome.toml — ${toError(error).message}. Update it manually if needed.`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
125
213
|
/**
|
|
126
214
|
* Performs the transactional rename mutation inside a furnace lock.
|
|
127
215
|
*/
|
|
@@ -129,6 +217,11 @@ async function performRenameMutations(args) {
|
|
|
129
217
|
const { projectRoot, oldName, newName, oldDir, newDir, isCustom, componentType, config } = args;
|
|
130
218
|
const oldClassName = tagNameToClassName(oldName);
|
|
131
219
|
const newClassName = tagNameToClassName(newName);
|
|
220
|
+
// Capture the pre-rename deployed target path so we know what to
|
|
221
|
+
// clean up in the engine tree. `updateConfigForCustomRename` rewrites
|
|
222
|
+
// `targetPath` in-place once the mutation enters phase 2, so we read
|
|
223
|
+
// it here while it still points at the old name's deployment.
|
|
224
|
+
const oldCustomTargetPath = isCustom ? config.custom[oldName]?.targetPath : undefined;
|
|
132
225
|
await runFurnaceMutation(projectRoot, 'rename-rollback', async (ctx) => {
|
|
133
226
|
const journal = createRollbackJournal();
|
|
134
227
|
ctx.registerJournal(journal);
|
|
@@ -197,6 +290,23 @@ async function performRenameMutations(args) {
|
|
|
197
290
|
// 7. Rename test files created by `furnace create --with-tests` (custom only).
|
|
198
291
|
if (isCustom && (await pathExists(args.engineDir))) {
|
|
199
292
|
await renameTestFiles(args.engineDir, projectRoot, oldName, newName, journal);
|
|
293
|
+
// Mochikit scaffold + widgets/chrome.toml live in a different
|
|
294
|
+
// tree than browser.toml-registered browser-chrome tests, so
|
|
295
|
+
// renameTestFiles doesn't reach them. 2026-04-21 eval: a rename
|
|
296
|
+
// left `engine/toolkit/content/tests/widgets/test_<old>.html`
|
|
297
|
+
// and its `chrome.toml` entry pointing at the old name, which
|
|
298
|
+
// either failed the test run outright or (worse) passed for the
|
|
299
|
+
// wrong component.
|
|
300
|
+
await renameMochikitTestFiles(args.engineDir, oldName, newName, journal);
|
|
301
|
+
// Clear the stale deployed component directory so the next
|
|
302
|
+
// `furnace apply` is the single writer of the new name's
|
|
303
|
+
// deployment. Without this, eval runs showed the old widget
|
|
304
|
+
// still living at `engine/toolkit/content/widgets/<old>/`
|
|
305
|
+
// alongside the newly-deployed `engine/toolkit/content/
|
|
306
|
+
// widgets/<new>/`, with no signal to `status` / `verify`.
|
|
307
|
+
if (oldCustomTargetPath) {
|
|
308
|
+
await removeStaleDeployedComponentDir(args.engineDir, oldCustomTargetPath, journal);
|
|
309
|
+
}
|
|
200
310
|
}
|
|
201
311
|
info(`Renamed ${componentType} component: ${oldName} → ${newName}`);
|
|
202
312
|
}
|