@hominis/fireforge 0.15.7 → 0.15.9

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 (51) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +103 -12
  3. package/dist/src/commands/export-shared.d.ts +6 -1
  4. package/dist/src/commands/export-shared.js +7 -2
  5. package/dist/src/commands/furnace/create-dry-run.d.ts +7 -0
  6. package/dist/src/commands/furnace/create-dry-run.js +7 -2
  7. package/dist/src/commands/furnace/create-features.d.ts +24 -0
  8. package/dist/src/commands/furnace/create-features.js +56 -0
  9. package/dist/src/commands/furnace/create-templates.d.ts +9 -5
  10. package/dist/src/commands/furnace/create-templates.js +14 -6
  11. package/dist/src/commands/furnace/create.js +34 -39
  12. package/dist/src/commands/furnace/index.js +1 -0
  13. package/dist/src/commands/lint.d.ts +20 -0
  14. package/dist/src/commands/lint.js +157 -44
  15. package/dist/src/commands/re-export-files.js +6 -2
  16. package/dist/src/commands/re-export.js +37 -4
  17. package/dist/src/commands/run.d.ts +15 -1
  18. package/dist/src/commands/run.js +202 -7
  19. package/dist/src/commands/test.js +97 -2
  20. package/dist/src/core/furnace-apply-ftl.d.ts +5 -3
  21. package/dist/src/core/furnace-apply-ftl.js +6 -2
  22. package/dist/src/core/furnace-apply-helpers.js +14 -4
  23. package/dist/src/core/furnace-config-custom.d.ts +14 -0
  24. package/dist/src/core/furnace-config-custom.js +64 -0
  25. package/dist/src/core/furnace-config.js +2 -39
  26. package/dist/src/core/furnace-validate-accessibility.d.ts +9 -2
  27. package/dist/src/core/furnace-validate-accessibility.js +17 -3
  28. package/dist/src/core/furnace-validate-helpers.d.ts +13 -1
  29. package/dist/src/core/furnace-validate-helpers.js +19 -0
  30. package/dist/src/core/furnace-validate-structure.js +6 -2
  31. package/dist/src/core/furnace-validate.js +6 -3
  32. package/dist/src/core/mach.d.ts +26 -0
  33. package/dist/src/core/mach.js +25 -1
  34. package/dist/src/core/patch-lint.d.ts +6 -1
  35. package/dist/src/core/patch-lint.js +14 -1
  36. package/dist/src/core/shared-ftl.d.ts +28 -0
  37. package/dist/src/core/shared-ftl.js +42 -0
  38. package/dist/src/core/smoke-patterns.d.ts +45 -0
  39. package/dist/src/core/smoke-patterns.js +100 -0
  40. package/dist/src/core/xpcshell-appdir.d.ts +143 -0
  41. package/dist/src/core/xpcshell-appdir.js +273 -0
  42. package/dist/src/errors/codes.d.ts +13 -0
  43. package/dist/src/errors/codes.js +13 -0
  44. package/dist/src/errors/run.d.ts +16 -0
  45. package/dist/src/errors/run.js +22 -0
  46. package/dist/src/types/commands/options.d.ts +58 -0
  47. package/dist/src/types/commands/patches.d.ts +22 -0
  48. package/dist/src/types/furnace.d.ts +39 -0
  49. package/dist/src/utils/process.d.ts +63 -0
  50. package/dist/src/utils/process.js +122 -0
  51. package/package.json +1 -1
@@ -1,12 +1,19 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
3
  import { pathExists, readText } from '../utils/fs.js';
4
- import { containsHardcodedTemplateText, createIssue, hasAriaRole, hasDelegatesFocusEnabled, hasGenericInteractiveElement, hasPositiveTabindex, hasTemplateClickHandler, hasTemplateClickOnSyntheticInteractive, hasTemplateKeyboardHandler, hasUnlabelledFormInput, } from './furnace-validate-helpers.js';
4
+ import { containsHardcodedTemplateText, createIssue, hasAriaRole, hasDelegatesFocusEnabled, hasGenericInteractiveElement, hasPositiveTabindex, hasTemplateClickHandler, hasTemplateClickOnSyntheticInteractive, hasTemplateKeyboardHandler, hasUnlabelledFormInput, isKeyboardCoveredByComposition, } from './furnace-validate-helpers.js';
5
5
  /**
6
6
  * Validates accessibility patterns in a component's .mjs file.
7
7
  * Checks for ARIA roles, keyboard handlers, l10n, and focus delegation.
8
+ *
9
+ * @param customConfig - When the component is custom, its matching entry
10
+ * from `furnace.json`. Used to skip the `no-keyboard-handler` warning
11
+ * when the component declares keyboard coverage either explicitly
12
+ * (`keyboardCovered: true`) or via `composes` naming a native-interactive
13
+ * inner element. Optional so stock/override callers and test fixtures
14
+ * without config in scope can continue to call without changes.
8
15
  */
9
- export async function validateAccessibility(componentDir, tagName) {
16
+ export async function validateAccessibility(componentDir, tagName, customConfig) {
10
17
  const mjsPath = join(componentDir, `${tagName}.mjs`);
11
18
  if (!(await pathExists(mjsPath)))
12
19
  return [];
@@ -22,8 +29,15 @@ export async function validateAccessibility(componentDir, tagName) {
22
29
  // platform, so a duplicate keyboard handler would usually double-fire.
23
30
  // Only flag synthetic markup (e.g. `<div @click>`) where the activation
24
31
  // path has to be wired manually.
32
+ //
33
+ // A wrapper component whose `composes` entry lists a native-interactive
34
+ // tag (or that sets `keyboardCovered: true`) is treated the same way:
35
+ // activation flows through the inner element and a duplicate handler on
36
+ // the wrapper would either no-op or fire twice alongside the child's
37
+ // built-in keyboard path.
25
38
  const hasClickOnSynthetic = hasTemplateClickOnSyntheticInteractive(content);
26
- if (hasClickOnSynthetic && !hasKeyboardHandler) {
39
+ const keyboardCovered = isKeyboardCoveredByComposition(customConfig);
40
+ if (hasClickOnSynthetic && !hasKeyboardHandler && !keyboardCovered) {
27
41
  issues.push(createIssue(tagName, 'warning', 'no-keyboard-handler', 'Interactive element has @click but no keyboard event handler (@keydown/@keypress/@keyup).'));
28
42
  }
29
43
  if (containsHardcodedTemplateText(content)) {
@@ -1,4 +1,4 @@
1
- import type { ComponentType, FurnaceConfig, ValidationIssue } from '../types/furnace.js';
1
+ import type { ComponentType, CustomComponentConfig, FurnaceConfig, ValidationIssue } from '../types/furnace.js';
2
2
  /** Creates a normalized validation issue object. */
3
3
  export declare function createIssue(component: string, severity: ValidationIssue['severity'], check: ValidationIssue['check'], message: string): ValidationIssue;
4
4
  /** Detects whether template or script content assigns an ARIA role. */
@@ -13,6 +13,18 @@ export declare function hasUnlabelledFormInput(content: string): boolean;
13
13
  export declare function hasTemplateClickHandler(content: string): boolean;
14
14
  /** Detects Lit-style template keyboard handlers. */
15
15
  export declare function hasTemplateKeyboardHandler(content: string): boolean;
16
+ /**
17
+ * Returns true when `customConfig` declares that the component's keyboard
18
+ * activation path is covered by a wrapped native-interactive inner element,
19
+ * either through an explicit opt-out (`keyboardCovered: true`) or by
20
+ * composing a tag that lives in {@link NATIVE_CLICK_INTERACTIVE_TAGS}.
21
+ *
22
+ * Uses `.some` rather than `.every` so that a wrapper composing e.g.
23
+ * `['moz-button', 'my-tooltip']` still skips the warning: the keyboard
24
+ * activation path flows through the button, even if other composed
25
+ * children are synthetic.
26
+ */
27
+ export declare function isKeyboardCoveredByComposition(customConfig: CustomComponentConfig | undefined): boolean;
16
28
  /**
17
29
  * Returns true when `content` has at least one `@click=${...}` handler on a
18
30
  * *synthetic* interactive element (e.g. `<div @click>`), which lacks native
@@ -76,6 +76,25 @@ const NATIVE_CLICK_INTERACTIVE_TAGS = new Set([
76
76
  'moz-radio-group',
77
77
  'moz-menulist',
78
78
  ]);
79
+ /**
80
+ * Returns true when `customConfig` declares that the component's keyboard
81
+ * activation path is covered by a wrapped native-interactive inner element,
82
+ * either through an explicit opt-out (`keyboardCovered: true`) or by
83
+ * composing a tag that lives in {@link NATIVE_CLICK_INTERACTIVE_TAGS}.
84
+ *
85
+ * Uses `.some` rather than `.every` so that a wrapper composing e.g.
86
+ * `['moz-button', 'my-tooltip']` still skips the warning: the keyboard
87
+ * activation path flows through the button, even if other composed
88
+ * children are synthetic.
89
+ */
90
+ export function isKeyboardCoveredByComposition(customConfig) {
91
+ if (!customConfig)
92
+ return false;
93
+ if (customConfig.keyboardCovered === true)
94
+ return true;
95
+ const composes = customConfig.composes ?? [];
96
+ return composes.some((tag) => NATIVE_CLICK_INTERACTIVE_TAGS.has(tag));
97
+ }
79
98
  /**
80
99
  * Returns true when `content` has at least one `@click=${...}` handler on a
81
100
  * *synthetic* interactive element (e.g. `<div @click>`), which lacks native
@@ -39,14 +39,18 @@ export async function validateStructure(componentDir, tagName, type, customConfi
39
39
  // Localized custom components must have a {tag}.ftl file. Without one,
40
40
  // apply silently deploys nothing for the locale and the runtime
41
41
  // localization payload is empty, which is hard to spot in review.
42
- if (type === 'custom' && customConfig?.localized) {
42
+ //
43
+ // Components that declare `sharedFtl` participate in a pre-existing
44
+ // feature-scoped bundle, so there is no per-component .ftl to require —
45
+ // the shared file is owned by whoever authored the feature bundle.
46
+ if (type === 'custom' && customConfig?.localized && !customConfig.sharedFtl) {
43
47
  const ftlPath = join(componentDir, `${tagName}.ftl`);
44
48
  if (!(await pathExists(ftlPath))) {
45
49
  issues.push({
46
50
  component: tagName,
47
51
  severity: 'error',
48
52
  check: 'missing-ftl',
49
- message: `Component is marked localized: true but ${tagName}.ftl is missing. Create the file or set localized: false in furnace.json.`,
53
+ message: `Component is marked localized: true but ${tagName}.ftl is missing. Create the file, set localized: false in furnace.json, or switch to sharedFtl.`,
50
54
  });
51
55
  }
52
56
  }
@@ -37,10 +37,13 @@ function buildOverrideVersionDriftIssues(config, currentVersion, tagName) {
37
37
  export async function validateComponent(componentDir, tagName, type, config, root, options) {
38
38
  const issues = [];
39
39
  // Pass the matching custom config so structure validation can enforce
40
- // the .ftl-when-localized invariant. Non-custom validations ignore the
40
+ // the .ftl-when-localized invariant and accessibility validation can
41
+ // recognize wrapper-over-native components (via `composes` or an
42
+ // explicit `keyboardCovered` opt-out). Non-custom validations ignore the
41
43
  // parameter, so this is a no-op for stock and override components.
42
- issues.push(...(await validateStructure(componentDir, tagName, type, type === 'custom' ? config?.custom[tagName] : undefined)));
43
- issues.push(...(await validateAccessibility(componentDir, tagName)));
44
+ const customConfigForTag = type === 'custom' ? config?.custom[tagName] : undefined;
45
+ issues.push(...(await validateStructure(componentDir, tagName, type, customConfigForTag)));
46
+ issues.push(...(await validateAccessibility(componentDir, tagName, customConfigForTag)));
44
47
  issues.push(...(await validateCompatibility(componentDir, tagName, type, config, root)));
45
48
  if (root && config && type === 'override') {
46
49
  const forgeConfig = await loadConfig(root);
@@ -1,3 +1,4 @@
1
+ import { type SmokeLineCallback, type SmokeRunResult } from '../utils/process.js';
1
2
  export { attemptMozinfoRewrite, type BuildArtifactCheck, buildArtifactMismatchMessage, hasBuildArtifacts, type MozinfoRewriteResult, } from './mach-build-artifacts.js';
2
3
  export { generateMozconfig, type MozconfigVariables } from './mach-mozconfig.js';
3
4
  export { ensurePython, resetResolvedPython } from './mach-python.js';
@@ -79,6 +80,31 @@ export declare function buildUI(engineDir: string): Promise<number>;
79
80
  * @returns Exit code
80
81
  */
81
82
  export declare function run(engineDir: string, args?: string[]): Promise<number>;
83
+ /**
84
+ * Options for {@link runMachSmoke}.
85
+ */
86
+ export interface RunMachSmokeOptions {
87
+ env?: Record<string, string>;
88
+ smokeTimeoutMs: number;
89
+ killGraceMs?: number;
90
+ onStdoutLine?: SmokeLineCallback;
91
+ onStderrLine?: SmokeLineCallback;
92
+ mirror?: {
93
+ stdout?: NodeJS.WritableStream;
94
+ stderr?: NodeJS.WritableStream;
95
+ };
96
+ }
97
+ /**
98
+ * Launches `mach run` under the smoke-run wrapper: streams line-by-line,
99
+ * enforces a deadline by SIGTERMing the whole process group, and returns
100
+ * the captured output alongside a `timedOut` flag.
101
+ *
102
+ * Unlike {@link run}, this variant does NOT inherit stdio. The child
103
+ * stdout/stderr are piped back through the line callbacks so the caller
104
+ * can scan for `JavaScript error:` / `console.error:` without coupling
105
+ * the runner to chrome-specific pattern logic.
106
+ */
107
+ export declare function runMachSmoke(args: string[], engineDir: string, options: RunMachSmokeOptions): Promise<SmokeRunResult>;
82
108
  /**
83
109
  * Creates a distribution package.
84
110
  * @param engineDir - Path to the engine directory
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { MachNotFoundError } from '../errors/build.js';
4
4
  import { pathExists } from '../utils/fs.js';
5
5
  import { warn } from '../utils/logger.js';
6
- import { exec, execInherit, execInheritCapture, execStream } from '../utils/process.js';
6
+ import { exec, execInherit, execInheritCapture, execSmokeRun, execStream, } from '../utils/process.js';
7
7
  import { explainMachError } from './mach-error-hints.js';
8
8
  import { getPython } from './mach-python.js';
9
9
  // Re-export sub-modules so existing `from './mach.js'` imports keep working.
@@ -165,6 +165,30 @@ export async function buildUI(engineDir) {
165
165
  export async function run(engineDir, args = []) {
166
166
  return runMach(['run', ...args], engineDir, { inherit: true });
167
167
  }
168
+ /**
169
+ * Launches `mach run` under the smoke-run wrapper: streams line-by-line,
170
+ * enforces a deadline by SIGTERMing the whole process group, and returns
171
+ * the captured output alongside a `timedOut` flag.
172
+ *
173
+ * Unlike {@link run}, this variant does NOT inherit stdio. The child
174
+ * stdout/stderr are piped back through the line callbacks so the caller
175
+ * can scan for `JavaScript error:` / `console.error:` without coupling
176
+ * the runner to chrome-specific pattern logic.
177
+ */
178
+ export async function runMachSmoke(args, engineDir, options) {
179
+ const python = await getPython(engineDir);
180
+ await ensureMach(engineDir);
181
+ const machPath = join(engineDir, 'mach');
182
+ return execSmokeRun(python, [machPath, ...args], {
183
+ cwd: engineDir,
184
+ ...(options.env ? { env: options.env } : {}),
185
+ smokeTimeoutMs: options.smokeTimeoutMs,
186
+ ...(options.killGraceMs !== undefined ? { killGraceMs: options.killGraceMs } : {}),
187
+ ...(options.onStdoutLine ? { onStdoutLine: options.onStdoutLine } : {}),
188
+ ...(options.onStderrLine ? { onStderrLine: options.onStderrLine } : {}),
189
+ ...(options.mirror ? { mirror: options.mirror } : {}),
190
+ });
191
+ }
168
192
  /**
169
193
  * Creates a distribution package.
170
194
  * @param engineDir - Path to the engine directory
@@ -89,6 +89,11 @@ export declare function lintModifiedFileHeaders(repoDir: string, affectedFiles:
89
89
  * @param diffContent - Raw unified diff string
90
90
  * @param config - Project configuration
91
91
  * @param patchQueueCtx - Optional cross-patch context for ownership resolution
92
+ * @param ignoreChecks - Optional set of per-patch `check` IDs to drop from the
93
+ * returned issues. Threaded from `PatchMetadata.lintIgnore` so a patch that
94
+ * is advisory-noisy by nature (a cohesive branding bundle, auto-generated
95
+ * manifest, etc.) can opt out of a specific rule without reaching for the
96
+ * blunt `--skip-lint` hammer. Not mutated by this function.
92
97
  * @returns Array of all lint issues found
93
98
  */
94
- export declare function lintExportedPatch(repoDir: string, affectedFiles: string[], diffContent: string, config: FireForgeConfig, patchQueueCtx?: import('./patch-lint-cross.js').PatchQueueContext): Promise<PatchLintIssue[]>;
99
+ export declare function lintExportedPatch(repoDir: string, affectedFiles: string[], diffContent: string, config: FireForgeConfig, patchQueueCtx?: import('./patch-lint-cross.js').PatchQueueContext, ignoreChecks?: ReadonlySet<string>): Promise<PatchLintIssue[]>;
@@ -455,9 +455,14 @@ export async function lintModifiedFileHeaders(repoDir, affectedFiles, newFiles)
455
455
  * @param diffContent - Raw unified diff string
456
456
  * @param config - Project configuration
457
457
  * @param patchQueueCtx - Optional cross-patch context for ownership resolution
458
+ * @param ignoreChecks - Optional set of per-patch `check` IDs to drop from the
459
+ * returned issues. Threaded from `PatchMetadata.lintIgnore` so a patch that
460
+ * is advisory-noisy by nature (a cohesive branding bundle, auto-generated
461
+ * manifest, etc.) can opt out of a specific rule without reaching for the
462
+ * blunt `--skip-lint` hammer. Not mutated by this function.
458
463
  * @returns Array of all lint issues found
459
464
  */
460
- export async function lintExportedPatch(repoDir, affectedFiles, diffContent, config, patchQueueCtx) {
465
+ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, config, patchQueueCtx, ignoreChecks) {
461
466
  const newFiles = detectNewFilesInDiff(diffContent);
462
467
  const { textLines: lineCount } = countNonBinaryDiffLines(diffContent);
463
468
  const patchOwnedFiles = resolvePatchOwnedSysMjs(newFiles, patchQueueCtx);
@@ -482,6 +487,14 @@ export async function lintExportedPatch(repoDir, affectedFiles, diffContent, con
482
487
  const checkJsIssues = await runCheckJs(repoDir, patchOwnedFiles);
483
488
  issues.push(...checkJsIssues);
484
489
  }
490
+ // Filter out ignored checks last so every rule still runs (keeps the
491
+ // implementation uniform) but suppressed rules do not surface. We do not
492
+ // reclassify severities — an ignored error simply drops, mirroring how
493
+ // inline `fireforge-ignore: <check>` markers work in the CSS and
494
+ // forward-import rules.
495
+ if (ignoreChecks && ignoreChecks.size > 0) {
496
+ return issues.filter((issue) => !ignoreChecks.has(issue.check));
497
+ }
485
498
  return issues;
486
499
  }
487
500
  //# sourceMappingURL=patch-lint.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Structural rules for the `sharedFtl` field on a custom component.
3
+ *
4
+ * Both the `--shared-ftl` CLI flag path (`furnace create`) and the
5
+ * furnace.json parser apply the same rules — extracting them here
6
+ * avoids drift between the two entry points and lets the `.mjs`
7
+ * template generator assume the value is safe to interpolate verbatim.
8
+ */
9
+ /**
10
+ * Outcome of {@link validateSharedFtl}. `ok: true` carries the trimmed
11
+ * (operator-safe) value; `ok: false` carries a human-readable message
12
+ * suitable for throwing as a `FurnaceError` or `InvalidArgumentError`.
13
+ */
14
+ export type SharedFtlValidation = {
15
+ ok: true;
16
+ value: string;
17
+ } | {
18
+ ok: false;
19
+ reason: string;
20
+ };
21
+ /**
22
+ * Validates a candidate `sharedFtl` value. Returns the trimmed value
23
+ * when well-formed, or a structured reason when not. Callers throw the
24
+ * error type appropriate to their context (CLI vs config parser).
25
+ */
26
+ export declare function validateSharedFtl(raw: unknown, context: {
27
+ localized: boolean;
28
+ }): SharedFtlValidation;
@@ -0,0 +1,42 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Structural rules for the `sharedFtl` field on a custom component.
4
+ *
5
+ * Both the `--shared-ftl` CLI flag path (`furnace create`) and the
6
+ * furnace.json parser apply the same rules — extracting them here
7
+ * avoids drift between the two entry points and lets the `.mjs`
8
+ * template generator assume the value is safe to interpolate verbatim.
9
+ */
10
+ /**
11
+ * Characters that must not appear in `sharedFtl`:
12
+ * - Backticks close the MJS template literal the scaffold writes.
13
+ * - `\` is a path-escape we do not want to interpret.
14
+ * - `${` opens a template expression and turns the FTL path into
15
+ * executable code.
16
+ */
17
+ const UNSAFE_CHARS = /[`\\]|\$\{/;
18
+ /**
19
+ * Validates a candidate `sharedFtl` value. Returns the trimmed value
20
+ * when well-formed, or a structured reason when not. Callers throw the
21
+ * error type appropriate to their context (CLI vs config parser).
22
+ */
23
+ export function validateSharedFtl(raw, context) {
24
+ if (typeof raw !== 'string') {
25
+ return { ok: false, reason: 'must be a string when set' };
26
+ }
27
+ const value = raw.trim();
28
+ if (value.length === 0) {
29
+ return { ok: false, reason: 'must not be empty' };
30
+ }
31
+ if (UNSAFE_CHARS.test(value)) {
32
+ return {
33
+ ok: false,
34
+ reason: 'must not contain backticks, backslashes, or ${ (would break the generated .mjs)',
35
+ };
36
+ }
37
+ if (!context.localized) {
38
+ return { ok: false, reason: 'requires localized to be true' };
39
+ }
40
+ return { ok: true, value };
41
+ }
42
+ //# sourceMappingURL=shared-ftl.js.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Shared regex library for `fireforge run --smoke-exit`. Used by the smoke
3
+ * runner to decide whether a console line from the real chrome counts as a
4
+ * runtime error. Kept separate from the runner so the patterns can be
5
+ * exercised in isolation and amended without touching process-group logic.
6
+ *
7
+ * Matching is anchored at the start of the line (`^`) on purpose: a runtime
8
+ * error leaves a canonical prefix that Firefox's logging layer prints at
9
+ * column zero. Embedded mentions of the same string inside an unrelated
10
+ * warning (e.g. `"pending JavaScript error cleanup"`) do not start with
11
+ * the prefix and should not trip the scanner.
12
+ */
13
+ /**
14
+ * Line prefixes that signal an actual runtime error in a Firefox chrome
15
+ * process. A hit on any of these patterns is a smoke failure unless the
16
+ * line also matches the caller-supplied allowlist.
17
+ *
18
+ * Additions here should be conservative — false positives turn every
19
+ * smoke run into noise for operators and every CI run into flake.
20
+ */
21
+ export declare const SMOKE_ERROR_PATTERNS: readonly RegExp[];
22
+ /**
23
+ * Returns `true` when `line` matches any pattern in
24
+ * {@link SMOKE_ERROR_PATTERNS}. Does not consult the allowlist — that step
25
+ * lives in {@link matchesAllowlist}, so the smoke runner can count
26
+ * allowlisted hits separately from raw error matches for its summary.
27
+ */
28
+ export declare function matchesSmokeError(line: string): boolean;
29
+ /**
30
+ * Returns `true` when `line` matches any regex in `allow`. Safe to call
31
+ * with an empty allowlist (always returns `false`).
32
+ */
33
+ export declare function matchesAllowlist(line: string, allow: readonly RegExp[]): boolean;
34
+ /**
35
+ * Parses a newline-delimited allowlist file body. Lines are trimmed; blank
36
+ * lines and `#`-prefixed comments are skipped. Each remaining line is
37
+ * compiled as a RegExp. A bad pattern throws immediately — better to fail
38
+ * fast at CLI parse time than to silently let a typo match nothing.
39
+ */
40
+ export declare function compileAllowlistFromFile(body: string, sourcePath: string): RegExp[];
41
+ /**
42
+ * Compiles an array of regex-string inputs (e.g. repeated `--console-allow`
43
+ * flag values). Same fail-fast semantics as {@link compileAllowlistFromFile}.
44
+ */
45
+ export declare function compileAllowlistFromStrings(sources: readonly string[]): RegExp[];
@@ -0,0 +1,100 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Shared regex library for `fireforge run --smoke-exit`. Used by the smoke
4
+ * runner to decide whether a console line from the real chrome counts as a
5
+ * runtime error. Kept separate from the runner so the patterns can be
6
+ * exercised in isolation and amended without touching process-group logic.
7
+ *
8
+ * Matching is anchored at the start of the line (`^`) on purpose: a runtime
9
+ * error leaves a canonical prefix that Firefox's logging layer prints at
10
+ * column zero. Embedded mentions of the same string inside an unrelated
11
+ * warning (e.g. `"pending JavaScript error cleanup"`) do not start with
12
+ * the prefix and should not trip the scanner.
13
+ */
14
+ /**
15
+ * Line prefixes that signal an actual runtime error in a Firefox chrome
16
+ * process. A hit on any of these patterns is a smoke failure unless the
17
+ * line also matches the caller-supplied allowlist.
18
+ *
19
+ * Additions here should be conservative — false positives turn every
20
+ * smoke run into noise for operators and every CI run into flake.
21
+ */
22
+ export const SMOKE_ERROR_PATTERNS = [
23
+ // Firefox chrome error lines — `JavaScript error: chrome://…, line N: TypeError: …`.
24
+ /^\s*JavaScript error:/i,
25
+ // Some log paths prefix browser-console `console.error(...)` with the literal label below.
26
+ /^\s*console\.error:/i,
27
+ // Older bracketed-prefix variant still seen in some chrome logs / test runs.
28
+ /^\s*\[JavaScript (Error|Warning)\]/i,
29
+ // IPC-layer fatal assertions — Firefox prints `###!!! [Parent] Error: …` on content-process crashes.
30
+ /^\s*###!!! \[Parent\]/,
31
+ ];
32
+ /**
33
+ * Returns `true` when `line` matches any pattern in
34
+ * {@link SMOKE_ERROR_PATTERNS}. Does not consult the allowlist — that step
35
+ * lives in {@link matchesAllowlist}, so the smoke runner can count
36
+ * allowlisted hits separately from raw error matches for its summary.
37
+ */
38
+ export function matchesSmokeError(line) {
39
+ for (const pattern of SMOKE_ERROR_PATTERNS) {
40
+ if (pattern.test(line)) {
41
+ return true;
42
+ }
43
+ }
44
+ return false;
45
+ }
46
+ /**
47
+ * Returns `true` when `line` matches any regex in `allow`. Safe to call
48
+ * with an empty allowlist (always returns `false`).
49
+ */
50
+ export function matchesAllowlist(line, allow) {
51
+ for (const pattern of allow) {
52
+ if (pattern.test(line)) {
53
+ return true;
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+ /**
59
+ * Parses a newline-delimited allowlist file body. Lines are trimmed; blank
60
+ * lines and `#`-prefixed comments are skipped. Each remaining line is
61
+ * compiled as a RegExp. A bad pattern throws immediately — better to fail
62
+ * fast at CLI parse time than to silently let a typo match nothing.
63
+ */
64
+ export function compileAllowlistFromFile(body, sourcePath) {
65
+ const lines = body.split(/\r?\n/);
66
+ const compiled = [];
67
+ lines.forEach((raw, index) => {
68
+ const line = raw.trim();
69
+ if (!line || line.startsWith('#'))
70
+ return;
71
+ try {
72
+ compiled.push(new RegExp(line));
73
+ }
74
+ catch (error) {
75
+ const message = error instanceof Error ? error.message : String(error);
76
+ throw new Error(`Invalid allowlist regex at ${sourcePath}:${String(index + 1)}: ${message}`, {
77
+ cause: error,
78
+ });
79
+ }
80
+ });
81
+ return compiled;
82
+ }
83
+ /**
84
+ * Compiles an array of regex-string inputs (e.g. repeated `--console-allow`
85
+ * flag values). Same fail-fast semantics as {@link compileAllowlistFromFile}.
86
+ */
87
+ export function compileAllowlistFromStrings(sources) {
88
+ const compiled = [];
89
+ sources.forEach((source, index) => {
90
+ try {
91
+ compiled.push(new RegExp(source));
92
+ }
93
+ catch (error) {
94
+ const message = error instanceof Error ? error.message : String(error);
95
+ throw new Error(`Invalid --console-allow regex at position ${String(index + 1)} ("${source}"): ${message}`, { cause: error });
96
+ }
97
+ });
98
+ return compiled;
99
+ }
100
+ //# sourceMappingURL=smoke-patterns.js.map
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Auto-injects `--app-path=<abs>` into `mach test` invocations whose nearest
3
+ * xpcshell.toml sets `firefox-appdir = "browser"` (or `<appname>-appdir = …`)
4
+ * but whose `appname` is not `firefox`.
5
+ *
6
+ * ## Why this exists
7
+ *
8
+ * The upstream xpcshell harness computes the manifest key for the appdir
9
+ * override as `mozInfo["appname"] + "-appdir"`. On a stock Firefox build the
10
+ * key is `firefox-appdir`, so the very common `firefox-appdir = "browser"`
11
+ * directive is honoured. On a rebranded fork (appname=`hominis`,
12
+ * `mybrowser`, …) the harness looks for `hominis-appdir` / `mybrowser-appdir`
13
+ * — the literal `firefox-appdir` line is silently ignored, `appPath` falls
14
+ * back to `xrePath`, and every `resource:///modules/…` import throws
15
+ * `Failed to load resource:///modules/<name>.sys.mjs` because xpcshell now
16
+ * resolves the `resource:///` prefix one level above the real app root.
17
+ *
18
+ * ## Strategy
19
+ *
20
+ * 1. For each test path the operator handed us, find the nearest
21
+ * `xpcshell.toml`. If none exists, the test is not an xpcshell test and
22
+ * nothing to inject.
23
+ * 2. Read the manifest's `[DEFAULT]` section. Look for `<appname>-appdir`
24
+ * first — if present, the harness already finds it and there's nothing to
25
+ * do. Fall back to `firefox-appdir`. This ordering matches upstream
26
+ * precedence and avoids overriding an operator who already migrated.
27
+ * 3. If only `firefox-appdir` is present and `appname != "firefox"`, compute
28
+ * the absolute app dir path against the active `obj-X/dist` tree
29
+ * (probing `dist/bin/<value>` first, then any `dist/<bundle>.app/Contents/
30
+ * Resources/<value>` for the macOS packaged layout) and return it as
31
+ * the value to pass to `--app-path`.
32
+ * 4. If multiple test paths disagree on the resolved value (e.g. one
33
+ * manifest sets `browser`, another sets `xulrunner`), refuse injection
34
+ * and return null — the operator can drop down to `--mach-arg`.
35
+ *
36
+ * Operator escape hatches: `--mach-arg=--app-path=…` always wins (handled in
37
+ * test.ts; we skip injection when `--app-path=` already appears in the
38
+ * forwarded args).
39
+ */
40
+ /**
41
+ * Result of attempting to resolve the auto-injected `--app-path` value.
42
+ * Carries enough context for the caller to log a useful info line and for
43
+ * the diagnostic hint to know whether an injection was attempted.
44
+ */
45
+ export interface AppdirResolveResult {
46
+ /** Absolute path to the app dir. Pass as `--app-path=<value>`. */
47
+ appPath: string;
48
+ /** Manifest the value was sourced from. Used for the info log. */
49
+ manifestPath: string;
50
+ /** Manifest key (e.g. `firefox-appdir`) that triggered the injection. */
51
+ key: string;
52
+ /** Relative appdir from the manifest (e.g. `browser`). */
53
+ relativeAppdir: string;
54
+ }
55
+ /**
56
+ * `[DEFAULT]` section parser shaped to the narrow case we need: pull a
57
+ * single key/value out without depending on a real TOML parser. Avoids
58
+ * pulling a TOML dep into the test path for a one-shot lookup.
59
+ *
60
+ * Accepts:
61
+ * - Single- or double-quoted values
62
+ * - Whitespace either side of `=`
63
+ * - Continuation comments (`#` or `;`) at the end of the line
64
+ * - Bare unquoted bareword values (e.g. `firefox-appdir = browser`) — some
65
+ * operators omit the quotes and the harness honours either form.
66
+ *
67
+ * Returns `undefined` when the key is absent or sits outside `[DEFAULT]`.
68
+ */
69
+ export declare function parseAppdirFromToml(tomlText: string, key: string): {
70
+ value: string;
71
+ lineIndex: number;
72
+ } | undefined;
73
+ /**
74
+ * Walks up from `startPath` (a file or directory under `engineDir`) and
75
+ * returns the absolute path of the first sibling `xpcshell.toml` found.
76
+ * Stops at `engineDir` (inclusive) and returns null on miss.
77
+ *
78
+ * Special-cases `startPath` itself when it already ends with
79
+ * `xpcshell.toml` — operators sometimes pass a manifest path directly.
80
+ */
81
+ export declare function findNearestXpcshellManifest(engineDir: string, startPath: string): Promise<string | null>;
82
+ /**
83
+ * Reads `<objDir>/mozinfo.json` for the active app name. Returns
84
+ * `"firefox"` when mozinfo cannot be read or the field is missing — that
85
+ * is the safe default because it matches stock Firefox behaviour and
86
+ * means the resolver will not inject anything (the manifest's
87
+ * `firefox-appdir` value WILL be honoured by the upstream harness when
88
+ * appname is firefox).
89
+ */
90
+ export declare function readMozinfoAppname(objDirPath: string): Promise<string>;
91
+ /**
92
+ * Probes the obj-dir's `dist/` subtree for the absolute path that the
93
+ * harness would have computed if the manifest key had been honoured.
94
+ * Returns null when no candidate exists — better to skip injection
95
+ * silently than to point the harness at a path that doesn't exist
96
+ * (which fails with a different error than the original `firefox-appdir`
97
+ * symptom and confuses triage).
98
+ *
99
+ * Probe order matches the on-disk layouts FireForge supports today:
100
+ * 1. `<objDir>/dist/bin/<value>` — Linux primary, also macOS via the
101
+ * `dist/bin -> dist/<App>.app/Contents/MacOS/` symlink.
102
+ * 2. `<objDir>/dist/<bundle>.app/Contents/Resources/<value>` — macOS
103
+ * packaged layout, where `dist/bin/` may not exist as a directory.
104
+ */
105
+ export declare function resolveAbsoluteAppPath(objDirAbs: string, relativeAppdir: string): Promise<string | null>;
106
+ /**
107
+ * Outcome carrier for {@link resolveXpcshellAppdirArg}. Distinguishes the
108
+ * three "did nothing" cases so callers can shape diagnostics:
109
+ * - `none`: no manifest under any test path needs injection.
110
+ * - `mismatch`: at least two manifests resolved to different values; we
111
+ * refuse to guess which one the operator meant.
112
+ * - `unresolved`: the manifest asks for `firefox-appdir = "<value>"` but
113
+ * no `dist/` candidate exists for that value.
114
+ * - `injected`: the absolute path to pass via `--app-path=`.
115
+ */
116
+ export type XpcshellAppdirOutcome = {
117
+ kind: 'none';
118
+ } | {
119
+ kind: 'mismatch';
120
+ values: string[];
121
+ } | {
122
+ kind: 'unresolved';
123
+ relativeAppdir: string;
124
+ manifestPath: string;
125
+ } | {
126
+ kind: 'injected';
127
+ result: AppdirResolveResult;
128
+ };
129
+ /**
130
+ * Top-level resolver. Walks every test path, reads the nearest
131
+ * xpcshell.toml, and returns the single absolute path to inject (or a
132
+ * structured "no injection" outcome). Never throws — every fs / parse
133
+ * error is folded into a `none` outcome so the test command always falls
134
+ * through to the diagnostic hint instead of dying inside a helper.
135
+ */
136
+ export declare function resolveXpcshellAppdirArg(engineDir: string, testPaths: readonly string[], objDirName: string): Promise<XpcshellAppdirOutcome>;
137
+ /**
138
+ * Returns true when the operator already passed `--app-path=` (or its
139
+ * `--app-path <value>` two-token form) through `--mach-arg`. Used by the
140
+ * test command to skip auto-injection so the operator override always
141
+ * wins.
142
+ */
143
+ export declare function operatorAlreadySetAppPath(extraArgs: readonly string[]): boolean;