Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
34a7526
Merge main into develop
github-actions[bot] Sep 17, 2026
ebf8143
feat(override)!: add newtab, bookmarks and history entrypoints
atldays Sep 17, 2026
512076c
test(locale): separate runtime and type contract checks
atldays Sep 18, 2026
11529f8
test(entrypoint): cover option reader export shapes by functionality
atldays Sep 18, 2026
9a35833
test(entrypoint): cover every parser through its own definition
atldays Sep 18, 2026
133940c
test(options): drop option reader duplicates from the parser tests
atldays Sep 18, 2026
ef29d6e
docs: define placement of entrypoint parsing tests
atldays Sep 18, 2026
b7338de
fix(view): pass only HTML options to the tags plugin
atldays Sep 18, 2026
7366e6a
feat(entrypoint): add a shared permissions options contract
atldays Sep 18, 2026
05a0a51
feat(override): declare permissions in override entrypoints
atldays Sep 18, 2026
90c51b0
feat(entrypoint): declare permissions in popup, sidebar and options
atldays Sep 21, 2026
d9ee668
docs: define type declaration and reuse conventions
atldays Sep 21, 2026
16db4c3
Merge branch 'feature/overrides' into develop
atldays Sep 21, 2026
4bc0e88
fix(command)!: reject duplicate command names
atldays Sep 21, 2026
70c305c
refactor(transport): share definition resolution across transport ent…
atldays Sep 21, 2026
17c9f95
refactor(background)!: resolve background definitions in the runtime …
atldays Sep 21, 2026
d5b90d1
test(command): resolve package imports in command finder fixtures
atldays Sep 21, 2026
954382a
refactor(command)!: resolve command definitions in the runtime entry
atldays Sep 21, 2026
2fc5eb9
refactor(entry): import resolvers from their owning files
atldays Sep 21, 2026
73effdc
refactor(view)!: resolve and mount views in one adapter-driven builder
atldays Sep 21, 2026
e80fe23
feat(entry)!: render text and DOM elements the same way in every adapter
atldays Sep 21, 2026
012df13
feat(offscreen)!: render offscreen views with the injected view builder
atldays Sep 22, 2026
8d27fe1
feat(sandbox)!: render sandbox views with the injected view builder
atldays Sep 22, 2026
a3cfaee
refactor(cli): declare generated entrypoint modules with one wildcard
atldays Sep 22, 2026
6aa1a53
test: resolve package imports in fixture projects
atldays Sep 22, 2026
2479b76
feat(view): organize view types by adapter and export the render cont…
atldays Sep 22, 2026
179209c
chore(release): filter release notes and standardize sync commits
atldays Sep 22, 2026
55f176c
test(view): render injected view builders in Chrome
atldays Sep 22, 2026
0d1164c
docs(view): describe the view runtime, adapters and render contract
atldays Sep 22, 2026
bc65b50
fix(entry): render every React node the React adapters accept
atldays Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ jobs:
type: now
from_branch: main
target_branch: develop
message: "chore(sync): merge main into develop"
github_token: ${{ secrets.GITHUB_TOKEN }}
env:
HUSKY: 0
13 changes: 4 additions & 9 deletions .release-it.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,6 @@ const types = new Map([
["fix", "🐛 Bug Fixed"],
["perf", "⚡️ Performance Improvements"],
["refactor", "🛠️ Refactoring"],
["docs", "📝 Documentation"],
["test", "🧪 Tests"],
["build", "🏗️ Build System"],
["ci", "🤖 CI"],
["chore", "🧹 Chores"],
["revert", "⏪ Reverts"],
]);

Expand Down Expand Up @@ -254,12 +249,12 @@ const createReleaseConfig = () => {
const type = (nextCommit.type || "").toLowerCase().replace(/!+$/, "");
const section = types.get(type);

if (section) {
nextCommit.type = section;
} else {
nextCommit.type = "🧩 Other";
if (!section) {
return null;
}

nextCommit.type = section;

if (nextCommit.body) {
const body = nextCommit.body.replace(/\r\n/g, "\n").trim();

Expand Down
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@
- 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.
- A local pass on one operating system does not establish cross-platform compatibility. For platform-specific failures, reproduce the relevant filesystem or process behavior when possible and distinguish that simulation from validation on the actual CI runner. Do not hide the failure by skipping the platform or only increasing timeouts.

## Entrypoint parsing tests

- `src/cli/entrypoint/file` reads source files without knowing entrypoints: a parser only supplies a definition name and its schema keys through `AbstractParser.optionFile()`. Organize its tests and fixtures by what the reader can read, not by entrypoint: export shapes in `tests/fixtures/export-shapes`, static value resolution in `static-options`, and expression kinds in `expression`. Cover every way a file may declare options there once; do not repeat that matrix per entrypoint.
- `src/cli/entrypoint/parser` tests are organized by entrypoint: one `<Name>Parser.test.ts` per parser, or one kebab-case file using `describe.each` for a family whose parsers differ only in the definition they recognise. Keep fixtures in `tests/fixtures/<entrypoint>/options/<scenario>/`, `invalid/`, and `contracts/`. Use real entrypoint files rather than mocked readers or generated source strings.
- A parser test proves what the parser owns: its definition is recognised and a sibling's is ignored; a `full` scenario reads every accepted key through its own definition; defaults; invalid values of its own keys; entrypoint-specific interpretation; and `contract()` when the parser declares an agreement. The `full` scenario is also how an entrypoint proves that it adopted a shared schema fragment.
- Test a shared schema fragment once at its owner: the common filters in `AbstractParser.test.ts`; view and CSP options belong beside `ViewParser` and `ViewCspParser`; a mixin schema beside its module in `parser/schemas`. Do not add a cross-cutting test file with one row per adopting entrypoint.
- A schema fragment that unrelated parsers opt into, such as `PermissionsSchema`, is a mixin: keep it as a module in `src/cli/entrypoint/parser/schemas` and merge it explicitly in each adopting parser. Do not put it on `AbstractParser`, which holds only what every entrypoint shares, and do not add an intermediate parser class for it: independent mixins cannot be combined through single inheritance.
- Do not re-test export shapes such as named exports, default objects, `as`, or `satisfies` in a parser test. Add a parser-level shape scenario only when the entrypoint gives that shape a meaning, for example the Content default render or the Relay init.

## Responsibility boundaries

### Layer ownership
Expand Down Expand Up @@ -71,6 +80,8 @@
- Import internal TypeScript modules through configured aliases or relative paths without a file extension. Do not write the future `.js` output path into a TypeScript source import.
- Output extensions and external-module paths belong to the build configuration. When a bundled entry needs an external dependency, resolve its source import to the emitted ESM path during the build.
- Imports of actual JavaScript files, package subpaths, and loader-specific resources such as `.ts?raw` keep the syntax required by their owner. This rule does not rewrite raw template contents or generated JavaScript imports.
- A directory of functions inside an entrypoint runtime, such as `src/entry/<name>/resolvers`, has no `index.ts`. Import the owning file, for example `./resolvers/definition`, so the path shows where the logic lives. Directories of classes follow the class export rules below.
- An entrypoint runtime's `index.ts` is its package subpath `adnbn/entry/<name>`. List its exports explicitly and do not use `export *`, so an internal helper never becomes public by accident.
- Apply this convention to new and changed imports; do not mass-rewrite unrelated files.

## Code layout
Expand Down Expand Up @@ -110,6 +121,16 @@ return node;
- Name classes after their responsibility and role, such as `LocaleFinder` or `ContentParser`. Avoid names tied only to a temporary implementation detail or former file location.
- Keep private helpers and local variables concise when their surrounding scope already supplies the domain. Do not mechanically repeat a long public prefix everywhere.

## Type declarations and reuse

- Apply these conventions to every type the project owns: shared contracts, runtime APIs, entrypoints, CLI, bundler plugins, and tests.
- Refer to a type by its name wherever it appears in class and function code: return types, parameters, properties, generic arguments, and local annotations. Do not reach into another type with an indexed access such as `ReadonlyConfig["homepage"]` or `ViewItem<O>["options"]`. The reader has to open the other declaration to learn what the member is, and the signature silently changes when that declaration does.
- Reuse before declaring. Look for the named type that already describes the value, starting in `src/types`, and use it instead of describing the same shape again inline. For example, use `ManifestHostPermissions` rather than `Set<string>`, `EntrypointFile` rather than `{file: string; import: string}`, and `ViewItems<O>` rather than `Map<string, ViewItem<O>>`. One concept has one name across types, parsers, finders, plugins, and builders.
- When no name exists, declare one where the concept is owned: a shared contract in `src/types`, a type private to one module beside its only consumer. Follow the symbol naming rules above, and do not keep two names for the same shape.
- For a third-party type that exposes only a nested member, add a local alias in the owning contract, as `types/manifest.ts` does for `chrome.runtime.ManifestPermission`, instead of indexing it inside a class.
- Build a contract by composing named contracts rather than copying their fields, for example `PopupConfig & PermissionsOptions & CspOptions & ViewOptions`. A contract that only part of the entrypoints adopt stays a separate named mixin.
- Indexed access remains appropriate inside type-level utilities that are generic over the key, such as `PluginHandlerOptions<K>`, where no single named type can replace it.

## Class exports

- When a class is the main export of a file, always export it by default. Keep the class explicitly named to match the file, for example `export default class ReactLocale` in `ReactLocale.ts`.
Expand Down
56 changes: 56 additions & 0 deletions src/cli/builders/manifest/Manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,62 @@ describe.each([
});
});

describe.each([
["ManifestV2", ManifestV2],
["ManifestV3", ManifestV3],
] as const)("%s override page", (_, Builder) => {
describe.each([Browser.Chrome, Browser.Edge, Browser.Opera, Browser.Safari, Browser.Firefox])("%s", browser => {
const rawOverrides = {bookmarks: "raw-bookmarks.html", history: "raw-history.html"};

it.each(["newtab", "bookmarks", "history"] as const)("writes the generated %s override under its key", page => {
const manifest = new Builder(browser).setOverride({page, path: `${page}.html`}).build();

expect(manifest.chrome_url_overrides).toStrictEqual({[page]: `${page}.html`});
});

it("preserves raw chrome_url_overrides without an override entrypoint", () => {
const manifest = new Builder(browser).raw({chrome_url_overrides: rawOverrides}).build();

expect(manifest.chrome_url_overrides).toStrictEqual(rawOverrides);
});

it("replaces raw chrome_url_overrides with only the generated page", () => {
const manifest = new Builder(browser)
.setOverride({page: "newtab", path: "newtab.html"})
.raw({chrome_url_overrides: rawOverrides})
.build();

expect(manifest.chrome_url_overrides).toStrictEqual({newtab: "newtab.html"});
});

it("restores raw chrome_url_overrides after clearing a generated override", () => {
const builder = new Builder(browser)
.raw({chrome_url_overrides: rawOverrides})
.setOverride({page: "newtab", path: "newtab.html"});

builder.build();
const manifest = builder.setOverride(undefined).build();

expect(manifest.chrome_url_overrides).toStrictEqual(rawOverrides);
});

it("omits chrome_url_overrides when there is no override page", () => {
const manifest = new Builder(browser).build();

expect(manifest).not.toHaveProperty("chrome_url_overrides");
});

it("omits chrome_url_overrides after clearing an override without raw fallback", () => {
const builder = new Builder(browser).setOverride({page: "history", path: "history.html"});

builder.build();
const manifest = builder.setOverride(undefined).build();

expect(manifest).not.toHaveProperty("chrome_url_overrides");
});
});
});

describe("Manifest browser specific settings", () => {
it("sets and merges Firefox browser specific settings", () => {
const builder = new ManifestV3(Browser.Firefox);
Expand Down
20 changes: 20 additions & 0 deletions src/cli/builders/manifest/ManifestBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ManifestIncognito,
ManifestOptionalPermissions,
ManifestOptions,
ManifestOverride,
ManifestPermissions,
ManifestPopup,
ManifestSandbox,
Expand Down Expand Up @@ -64,6 +65,7 @@ export default abstract class<T extends CoreManifest> implements ManifestBuilder
protected popup?: ManifestPopup;
protected sidebar?: ManifestSidebar;
protected options?: ManifestOptions;
protected urlOverride?: ManifestOverride;
protected sandboxes: ManifestSandboxes = new Set();
protected sandboxCsp: CspBuilder<SandboxCspConfig> = new SandboxCsp();
protected csp: CspBuilder<CspConfig> = new Csp();
Expand Down Expand Up @@ -273,6 +275,12 @@ export default abstract class<T extends CoreManifest> implements ManifestBuilder
return this;
}

public setOverride(override?: ManifestOverride): this {
this.urlOverride = override;

return this;
}

public addSandbox(sandbox: ManifestSandbox): this {
this.sandboxes.add(sandbox);

Expand Down Expand Up @@ -445,6 +453,7 @@ export default abstract class<T extends CoreManifest> implements ManifestBuilder
this.buildAction(),
this.buildSidebar(),
this.buildOptions(),
this.buildOverride(),
this.buildContentScripts(),
this.buildPermissions(),
this.buildOptionalPermissions(),
Expand Down Expand Up @@ -678,6 +687,16 @@ export default abstract class<T extends CoreManifest> implements ManifestBuilder
return _.pick(this.combinedRaws, ["options_ui", "options_page"]);
}

protected buildOverride(): Partial<CoreManifest> {
if (this.urlOverride) {
const {page, path} = this.urlOverride;

return {chrome_url_overrides: {[page]: path}};
}

return _.pick(this.combinedRaws, ["chrome_url_overrides"]);
}

protected buildBrowserSpecificSettings(): Partial<Manifest> | undefined {
const optionalSettings = this.combinedRaws.browser_specific_settings;
const {safari, gecko, geckoAndroid} = this.specific || {};
Expand Down Expand Up @@ -757,6 +776,7 @@ export default abstract class<T extends CoreManifest> implements ManifestBuilder
sidebar,
options_ui,
options_page,
chrome_url_overrides,
content_scripts,
permissions,
optional_permissions,
Expand Down
125 changes: 123 additions & 2 deletions src/cli/entrypoint/file/OptionFile.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import "./tests/background";

import path from "path";
import OptionFile from "./OptionFile";

Expand All @@ -9,6 +7,129 @@ const read = (name: string, properties: string[]) =>
.setDefinition("defineContentScript")
.setProperties(properties);

const shapes = path.resolve(__dirname, "tests/fixtures/export-shapes");
const shape = (name: string, properties: string[], definition: string | string[] = "defineBackground") =>
OptionFile.make(path.join(shapes, name + ".ts"))
.setDefinition(definition)
.setProperties(properties);

/**
* How an entrypoint file may declare its options. The reader does not know entrypoints: parsers only
* supply a definition name and schema keys, so every shape is covered here once, not per entrypoint.
* The definition used by a fixture is arbitrary; each parser proves its own one in its `full` scenario.
*/
describe("OptionFile export shapes", () => {
test.each([
{
title: "a definition call",
name: "definition-only",
properties: ["persistent", "includeBrowser"],
expected: {persistent: true, includeBrowser: ["firefox"]},
},
{
title: "a generic definition call with explicit type arguments",
name: "definition-type-arguments",
definition: "defineService",
properties: ["name", "includeBrowser"],
expected: {name: "math", includeBrowser: ["firefox"]},
},
{
title: "a definition call behind satisfies",
name: "definition-satisfies",
definition: "definePopup",
properties: ["title", "excludeBrowser"],
expected: {title: "Popup", excludeBrowser: ["safari"]},
},
{
title: "a definition call with shorthand and imported values",
name: "definition-shorthand",
properties: ["permissions", "persistent", "excludeBrowser", "includeBrowser", "excludeApp"],
expected: {
permissions: ["storage", "tabs"],
persistent: true,
excludeBrowser: ["edge"],
includeBrowser: ["firefox"],
excludeApp: ["my_test_app"],
},
},
{
title: "named exports beside a default function",
name: "default-function",
properties: ["persistent", "excludeBrowser", "excludeApps"],
expected: {persistent: true, excludeBrowser: ["edge"], excludeApps: ["my_test_app"]},
},
{
title: "named exports combined with a definition call",
name: "definition-with-named-exports",
properties: ["persistent", "excludeBrowser", "includeBrowser"],
expected: {persistent: true, excludeBrowser: ["edge"], includeBrowser: ["firefox"]},
},
{
title: "named exports combined with a default object",
name: "default-object",
properties: ["persistent", "excludeBrowser", "includeBrowser"],
expected: {persistent: true, excludeBrowser: ["opera"], includeBrowser: ["chrome"]},
},
{
title: "a default object behind a type assertion",
name: "default-object-assertion",
properties: ["persistent", "excludeBrowser", "includeBrowser"],
expected: {persistent: true, excludeBrowser: ["safari"], includeBrowser: ["chromium"]},
},
{
title: "a default object behind satisfies",
name: "default-object-satisfies",
properties: ["persistent", "excludeBrowser", "includeBrowser"],
expected: {persistent: true, includeBrowser: ["chromium"]},
},
])("reads $title", ({name, definition, properties, expected}) => {
expect(shape(name, properties, definition).getOptions()).toEqual(expected);
});

test("prefers the default export over named exports and keeps an explicit false", () => {
expect(shape("default-over-named", ["persistent", "includeBrowser", "excludeBrowser"]).getOptions()).toEqual({
persistent: false,
includeBrowser: ["firefox"],
excludeBrowser: ["opera"],
});
});

test("reads only the properties requested by the caller", () => {
expect(shape("definition-with-named-exports", ["includeBrowser"]).getOptions()).toEqual({
includeBrowser: ["firefox"],
});
});

test("ignores options wrapped in a definition the caller did not allow", () => {
const file = shape("foreign-definition", ["persistent", "includeBrowser", "excludeBrowser"]);

expect(file.getOptions()).toEqual({excludeBrowser: ["edge"]});
expect(file.getDefinition()).toBeUndefined();
});

test("ignores a definition that is not imported from the framework and warns about it", () => {
const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);

try {
const file = shape("local-definition", ["persistent", "includeBrowser", "excludeBrowser"]);

expect(file.getOptions()).toEqual({excludeBrowser: ["edge"]});
expect(warn).toHaveBeenCalledWith(expect.stringContaining("Function defineBackground is not imported"));
} finally {
warn.mockRestore();
}
});

test("reports which of the allowed definitions wraps the options", () => {
const definitions = ["defineCommand", "defineExecuteActionCommand"];

expect(shape("alternative-definition", ["defaultKey"], definitions).getDefinition()).toBe(
"defineExecuteActionCommand"
);
expect(shape("definition-only", ["persistent"], definitions).getDefinition()).toBeUndefined();
});
});

describe("OptionFile static values", () => {
test("reads nested objects, enum injectors, imported constants and ordered spreads", () => {
expect(read("definition", ["isolation", "includeBrowser"]).getOptions()).toEqual({
Expand Down
Loading
Loading