Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,16 @@ jobs:
name_suffix: ${{ steps.set.outputs.name_suffix }}
steps:
- id: set
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
FULL_MATRIX: ${{ inputs.full }}
run: |
if [[ "${{ github.event_name }}" != "workflow_call" || "${{ inputs.full }}" == "true" ]]; then
if [[ "$FULL_MATRIX" == "true" || ( "$EVENT_NAME" == "pull_request" && "$BASE_REF" == "main" ) ]]; then
echo 'matrix={"os":["ubuntu-24.04","windows-latest"],"node":[22,24]}' >> "$GITHUB_OUTPUT"
echo 'name_suffix=(full matrix)' >> "$GITHUB_OUTPUT"
else
echo 'matrix={"os":["ubuntu-24.04"],"node":[22]}' >> "$GITHUB_OUTPUT"
echo 'matrix={"os":["ubuntu-24.04","windows-latest"],"node":[24]}' >> "$GITHUB_OUTPUT"
echo 'name_suffix=' >> "$GITHUB_OUTPUT"
fi

Expand Down Expand Up @@ -60,13 +64,13 @@ jobs:
- name: Verify test groups
run: npm run test:inventory
- name: Units with coverage (no package build)
if: matrix.os == 'ubuntu-24.04' && matrix.node == 22
if: matrix.os == 'ubuntu-24.04' && matrix.node == 24
run: npm run test:run -- --selectProjects unit-node unit-dom --ci --coverage --coverageDirectory=coverage/unit
- name: Units (no package build)
if: matrix.os != 'ubuntu-24.04' || matrix.node != 22
if: matrix.os != 'ubuntu-24.04' || matrix.node != 24
run: npm run test:run -- --selectProjects unit-node unit-dom --ci
- name: Upload unit coverage
if: matrix.os == 'ubuntu-24.04' && matrix.node == 22
if: matrix.os == 'ubuntu-24.04' && matrix.node == 24
uses: actions/upload-artifact@v7.0.1
with:
name: coverage-unit-${{ matrix.os }}-node${{ matrix.node }}
Expand Down Expand Up @@ -94,17 +98,17 @@ jobs:
- name: Build package
run: npm run build
- name: Compiler, CLI and declaration integrations with coverage
if: matrix.os == 'ubuntu-24.04' && matrix.node == 22
if: matrix.os == 'ubuntu-24.04' && matrix.node == 24
run: npm run test:run -- --selectProjects build types --ci --maxWorkers=2 --coverage --coverageDirectory=coverage/build
- name: Compiler, CLI and declaration integrations
if: matrix.os != 'ubuntu-24.04' || matrix.node != 22
if: matrix.os != 'ubuntu-24.04' || matrix.node != 24
run: npm run test:run -- --selectProjects build types --ci --maxWorkers=2
- name: Override builds in isolated sessions
run: npm run test:run -- --selectProjects build --testPathPatterns=tests/integration/build/override --ci --maxWorkers=2
env:
ADNBN_OVERRIDE_BUILD_MODE: session
- name: Upload build coverage
if: matrix.os == 'ubuntu-24.04' && matrix.node == 22
if: matrix.os == 'ubuntu-24.04' && matrix.node == 24
uses: actions/upload-artifact@v7.0.1
with:
name: coverage-build-${{ matrix.os }}-node${{ matrix.node }}
Expand Down Expand Up @@ -183,7 +187,7 @@ jobs:
retention-days: 7

coverage:
name: Coverage (ubuntu-24.04, Node 22)
name: Coverage (ubuntu-24.04, Node 24)
needs: [compute-matrix, unit, build]
runs-on: ubuntu-24.04
permissions:
Expand All @@ -194,26 +198,26 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v7.0.0
with:
node-version: 22
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Download unit coverage
uses: actions/download-artifact@v8.0.1
with:
name: coverage-unit-ubuntu-24.04-node22
name: coverage-unit-ubuntu-24.04-node24
path: coverage/unit
- name: Download build coverage
uses: actions/download-artifact@v8.0.1
with:
name: coverage-build-ubuntu-24.04-node22
name: coverage-build-ubuntu-24.04-node24
path: coverage/build
- name: Merge source coverage from both groups
run: node scripts/merge-coverage.mjs coverage/unit/coverage-final.json coverage/build/coverage-final.json
- name: Upload coverage
uses: actions/upload-artifact@v7.0.1
with:
name: coverage-ubuntu-24.04-node22
name: coverage-ubuntu-24.04-node24
path: coverage/merged

# Keep the existing required check names while the work runs in independent jobs.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v7.0.0
with:
node-version: 22
node-version: 24
cache: 'npm'

- name: Update npm
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

## Cross-platform tests

- The full CI matrix in `.github/workflows/ci.yml` runs on Linux and Windows with Node.js 22 and 24. New and changed non-browser tests must work on both operating systems. Chrome and Firefox browser integrations currently run on Linux; consult the workflow for the current matrix.
- Regular CI in `.github/workflows/ci.yml` runs on Linux and Windows with Node.js 24. Pull requests targeting `main` and pre-release validation use the full Node.js 22/24 matrix. New and changed non-browser tests must work on both operating systems and supported Node.js versions. Chrome and Firefox browser integrations run on Linux for each selected Node.js version; consult the workflow for the current matrix. Coverage and package publication use Node.js 24.
- Build filesystem paths with `path.resolve` and `path.join`, including paths used with in-memory filesystems. Do not hardcode Unix roots such as `/dist` or Windows drive letters. Configure the compiler and read, write, or assert its output using the same resolved directory. Keep filesystem paths distinct from URLs and manifest asset paths, which use forward slashes.
- In watch tests, simulate edits with `writeFile(destination, await readFile(source))`. Do not use `copyFile` to update watched files: on Windows it can preserve an old `mtime`, preventing the watcher from detecting the change. Copying initial fixtures before starting watch is fine.
- Wait for the expected compilation or observable output with a descriptive timeout. Do not rely on fixed sleeps or assume that emitted files mean the watcher has finished reconnecting. Preserve exact filename casing and account for platform line endings when they are not part of the tested contract.
Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@
"typescript": "^5.7.3",
"utility-types": "^3.11.0",
"webpack-merge": "^6.0.1",
"zod": "^3.24.2"
"zod": "^4.6.7"
},
"devDependencies": {
"@commitlint/cli": "^20.1.0",
Expand Down Expand Up @@ -237,6 +237,9 @@
}
},
"overrides": {
"get-uri": {
"basic-ftp": "6.2.3"
},
"glob": "$glob",
"jsdom": "27.4.0",
"test-exclude": "^7.0.1",
Expand Down
2 changes: 1 addition & 1 deletion src/cli/entrypoint/parser/AbstractParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class CommonParser extends AbstractParser<EntrypointOptions> {
return "defineBackground";
}

protected schema(): z.AnyZodObject {
protected schema(): z.ZodObject {
return this.CommonPropertiesSchema;
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/cli/entrypoint/parser/AbstractParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default abstract class AbstractParser<O extends EntrypointOptions> implem
manifestVersion: z.union([z.literal(2), z.literal(3)]).optional(),
});

protected abstract schema(): z.AnyZodObject;
protected abstract schema(): z.ZodObject;

protected abstract definition(): string | string[];

Expand Down Expand Up @@ -59,7 +59,7 @@ export default abstract class AbstractParser<O extends EntrypointOptions> implem
const {success, error, data} = this.schema().safeParse(this.getOptions(file));

if (!success) {
const e = error?.errors[0];
const e = error.issues[0];

if (e) {
throw new Error(`Invalid options ${e.path.join(", ")} in "${file.file}": ${e.message}`);
Expand Down
2 changes: 1 addition & 1 deletion src/cli/entrypoint/parser/ContentParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default class ContentParser<
return ["defineContentScript", "defineContentScriptAppend"];
}

protected schema(): z.AnyZodObject {
protected schema(): z.ZodObject {
const frameSchema = z
.object({
type: z.literal(ContentScriptIsolation.Iframe),
Expand Down
18 changes: 18 additions & 0 deletions src/cli/entrypoint/parser/SandboxParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ describe("SandboxParser", () => {
expect(parseOptions("options", "defaults", "sandbox.ts")).toEqual({});
});

test("preserves positive infinite timeout values accepted by existing configurations", () => {
expect(parseOptions("options", "infinite-timeouts", "sandbox.ts")).toStrictEqual({
readyTimeout: Infinity,
requestTimeout: Infinity,
});
});

test.each([
["zero-timeout", "readyTimeout"],
["negative-timeout", "requestTimeout"],
["string-timeout", "readyTimeout"],
])("rejects %s and identifies its field and source", (scenario, field) => {
const source = file("invalid", `${scenario}.ts`);

expect(() => parser.options(source)).toThrow(`Invalid options ${field}`);
expect(() => parser.options(source)).toThrow(source.file);
});

test("parses full sandbox options from a real entrypoint file", () => {
expect(parseOptions("options", "full", "sandbox.ts")).toEqual({
as: "unsafe-parser-frame",
Expand Down
6 changes: 4 additions & 2 deletions src/cli/entrypoint/parser/SandboxParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export default class extends ViewParser<SandboxEntrypointOptions> {

protected schema(): typeof this.CommonPropertiesSchema {
const sources = z.array(z.enum(sandboxSourceValues)).optional();
// Preserve the positive-number contract from Zod 3, including positive infinity.
const timeout = z.union([z.number().positive(), z.literal(Infinity)]).optional();

return super.schema().extend({
name: z
Expand All @@ -30,8 +32,8 @@ export default class extends ViewParser<SandboxEntrypointOptions> {
"Key must start with a Unicode letter, `$` or `_`, and may only contain letters, digits, `$` or `_`",
})
.optional(),
readyTimeout: z.number().positive().optional(),
requestTimeout: z.number().positive().optional(),
readyTimeout: timeout,
requestTimeout: timeout,
removeOnRequestTimeout: z.boolean().optional(),
csp: z
.object({
Expand Down
19 changes: 18 additions & 1 deletion src/cli/entrypoint/parser/ViewParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ describe("ViewParser", () => {
expect(parseOptions("options", "defaults", "page.ts")).toEqual({});
});

test("preserves optional values and falsy attributes while stripping unknown nested options", () => {
expect(parseOptions("options", "sparse", "page.ts")).toStrictEqual({
append: undefined,
metas: {
attributes: {name: "description", content: "", hidden: false, priority: 0},
glob: undefined,
},
scripts: [{path: "extra.js", attributes: {defer: true}}],
});
});

test("reads a meta object", () => {
expect(parseOptions("options", "object", "page.ts")).toEqual({metas: {attributes: {charset: "utf-8"}}});
});
Expand All @@ -79,7 +90,7 @@ describe("ViewParser", () => {
["meta-missing", /meta object with nonempty attributes/],
["meta-null", /meta object with nonempty attributes/],
["meta-invalid-attribute", /meta object with nonempty attributes/],
["meta-nonfinite-attribute", /Number must be finite/],
["meta-nonfinite-attribute", /meta object with nonempty attributes/],
])("rejects %s and identifies its field and source file", (scenario, message) => {
expect(() => parseOptions("invalid", `${scenario}.ts`)).toThrow("Invalid options metas");
expect(() => parseOptions("invalid", `${scenario}.ts`)).toThrow(`${scenario}.ts`);
Expand All @@ -96,6 +107,12 @@ describe("ViewParser", () => {
);
});

test("retains the configuration hint for a forbidden static callback option", () => {
expect(() => parseOptions("invalid", "static-add-hash.ts")).toThrow(
/Invalid options addHash.*static-add-hash.ts.*Configure addHash in config.html/
);
});

test.each([
["add-hash", "addHash"],
["add-public-path", "addPublicPath"],
Expand Down
18 changes: 7 additions & 11 deletions src/cli/entrypoint/parser/schemas/html.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import {z} from "zod";

const AttributesSchema = z.record(z.union([z.string(), z.boolean(), z.number().finite()]));
const AttributesSchema = z.record(z.string(), z.union([z.string(), z.boolean(), z.number().finite()]));

const CommonSchema = z.object({
append: z.boolean().optional(),
useHash: z.boolean().optional(),
addHash: z.never({invalid_type_error: "Configure addHash in config.html"}).optional(),
addHash: z.never({error: "Configure addHash in config.html"}).optional(),
hash: z.union([z.boolean(), z.string()]).optional(),
usePublicPath: z.boolean().optional(),
addPublicPath: z.never({invalid_type_error: "Configure addPublicPath in config.html"}).optional(),
addPublicPath: z.never({error: "Configure addPublicPath in config.html"}).optional(),
publicPath: z.union([z.boolean(), z.string()]).optional(),
});

Expand All @@ -22,7 +22,7 @@ const BaseTagSchema = CommonSchema.extend({
const LinkSchema = BaseTagSchema.extend({
path: z.string(),
attributes: AttributesSchema.optional(),
external: z.never({invalid_type_error: "external is only supported for scripts"}).optional(),
external: z.never({error: "external is only supported for scripts"}).optional(),
});

const ScriptSchema = LinkSchema.extend({
Expand All @@ -34,7 +34,7 @@ const MetaSchema = BaseTagSchema.extend({
attributes: AttributesSchema.refine(attributes => Object.keys(attributes).length > 0, {
message: "A meta tag requires a nonempty attributes object",
}),
external: z.never({invalid_type_error: "external is only supported for scripts"}).optional(),
external: z.never({error: "external is only supported for scripts"}).optional(),
});

const TagSchema = ScriptSchema.extend({type: z.enum(["css", "js"]).optional()});
Expand All @@ -46,17 +46,13 @@ export const HtmlEntrypointOptionsSchema = CommonSchema.extend({
prependExternals: z.boolean().optional(),
jsExtensions: z.union([z.string(), z.array(z.string())]).optional(),
cssExtensions: z.union([z.string(), z.array(z.string())]).optional(),
files: z
.never({invalid_type_error: "The entrypoint selects its HTML file; configure files in config.html"})
.optional(),
files: z.never({error: "The entrypoint selects its HTML file; configure files in config.html"}).optional(),
links: z.union([LinkValueSchema, z.array(LinkValueSchema)]).optional(),
scripts: z.union([ScriptValueSchema, z.array(ScriptValueSchema)]).optional(),
tags: z.union([TagValueSchema, z.array(TagValueSchema)]).optional(),
metas: z
.union([MetaSchema, z.array(MetaSchema)], {
errorMap: () => ({
message: "Expected a meta object with nonempty attributes, or an array of such objects",
}),
error: "Expected a meta object with nonempty attributes, or an array of such objects",
})
.optional(),
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const requestTimeout = -1;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const readyTimeout = "1000";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const readyTimeout = 0;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const readyTimeout = 1e309;
export const requestTimeout = 1e309;

export const init = () => ({});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const addHash = "invalid";
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const append = undefined;

export const metas = {
attributes: {name: "description", content: "", hidden: false, priority: 0},
glob: undefined,
unused: "ignored",
};

export const scripts = [{path: "extra.js", attributes: {defer: true}, unused: "ignored"}];
14 changes: 14 additions & 0 deletions src/cli/plugins/meta/SpecificSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ describe("SpecificSettings meta", () => {

const makeConfig = (specific: any): any => ({specific});

test.each([{}, {gecko: {}}, {gecko: {dataCollectionPermissions: {}}}, {gecko: undefined}])(
"preserves missing and explicitly undefined optional fields in %p",
specific => {
expect(new SpecificSettings(makeConfig(specific)).getResolved()).toStrictEqual(specific);
}
);

test.each([{unknown: true}, {gecko: null}, {gecko: {dataCollectionPermissions: {required: null}}}])(
"rejects unknown keys and null optional fields in %p",
specific => {
expect(new SpecificSettings(makeConfig(specific)).getResolved()).toBeUndefined();
}
);

test("returns direct valid object", () => {
const specific = {
gecko: {
Expand Down
Loading