From ebf81433722804176594b7addd0ee54cf8f1a1e2 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:34:22 +0300 Subject: [PATCH 01/28] feat(override)!: add newtab, bookmarks and history entrypoints Discover, parse and build the pages that replace the browser new tab, bookmarks and history through chrome_url_overrides. Each page is a singleton view: app files replace shared ones instead of merging, and only the highest-precedence candidate is built. Select the override per build in one plugin: skip pages the target browser does not support and fail when more than one remains, because Chromium refuses to load an extension that overrides several pages. Write the selected page as a single manifest override that replaces raw chrome_url_overrides. Cover parsing, discovery, manifest generation, the selection policy and real Chrome rendering with unit, build and browser integration tests, and document the fixtures. BREAKING CHANGE: top-level newtab, bookmarks and history files and directories in app or shared sources are entrypoints now. Page entrypoints named newtab, bookmarks or history receive a numbered HTML filename such as history1.html; their aliases do not change. --- src/cli/builders/manifest/Manifest.test.ts | 56 +++++++ src/cli/builders/manifest/ManifestBase.ts | 20 +++ .../finder/AbstractOverrideFinder.test.ts | 112 ++++++++++++++ .../finder/AbstractOverrideFinder.ts | 31 ++++ src/cli/entrypoint/finder/BookmarksFinder.ts | 21 +++ src/cli/entrypoint/finder/HistoryFinder.ts | 21 +++ src/cli/entrypoint/finder/NewtabFinder.ts | 21 +++ src/cli/entrypoint/finder/PageFinder.test.ts | 11 +- src/cli/entrypoint/finder/PageFinder.ts | 5 +- src/cli/entrypoint/finder/index.ts | 4 + .../bookmarks/src/account.bookmarks.tsx | 1 + .../src/advanced.bookmarks/index.tsx | 1 + .../discovery/bookmarks/src/bookmarks.ts | 1 + .../bookmarks/src/bookmarks/index.ts | 1 + .../override/discovery/bookmarks/src/popup.ts | 1 + .../discovery/history/src/account.history.tsx | 1 + .../history/src/advanced.history/index.tsx | 1 + .../override/discovery/history/src/history.ts | 1 + .../discovery/history/src/history/index.ts | 1 + .../override/discovery/history/src/popup.ts | 1 + .../discovery/newtab/src/account.newtab.tsx | 1 + .../newtab/src/advanced.newtab/index.tsx | 1 + .../override/discovery/newtab/src/newtab.ts | 1 + .../discovery/newtab/src/newtab/index.ts | 1 + .../override/discovery/newtab/src/popup.ts | 1 + .../plugins/third-party/bookmarks.ts | 4 + .../workspace/plugins/third-party/history.ts | 4 + .../workspace/plugins/third-party/newtab.ts | 4 + .../workspace/src/apps/app/bookmarks.ts | 7 + .../workspace/src/apps/app/history.ts | 7 + .../override/workspace/src/apps/app/newtab.ts | 7 + .../workspace/src/shared/bookmarks.ts | 4 + .../override/workspace/src/shared/history.ts | 4 + .../override/workspace/src/shared/newtab.ts | 4 + .../fixtures/override/workspace/tsconfig.json | 1 + src/cli/entrypoint/parser/BookmarksParser.ts | 9 ++ src/cli/entrypoint/parser/HistoryParser.ts | 9 ++ src/cli/entrypoint/parser/NewtabParser.ts | 9 ++ src/cli/entrypoint/parser/index.ts | 3 + .../parser/override-parsers.test.ts | 62 ++++++++ .../bookmarks/foreign-definition/bookmarks.ts | 6 + .../override/bookmarks/full/bookmarks.ts | 30 ++++ .../bookmarks/named-exports/bookmarks.ts | 4 + .../history/foreign-definition/history.ts | 6 + .../fixtures/override/history/full/history.ts | 30 ++++ .../override/history/named-exports/history.ts | 4 + .../newtab/foreign-definition/newtab.ts | 6 + .../fixtures/override/newtab/full/newtab.ts | 30 ++++ .../override/newtab/named-exports/newtab.ts | 4 + src/cli/plugins/index.ts | 1 + src/cli/plugins/override/Override.test.ts | 142 ++++++++++++++++++ src/cli/plugins/override/Override.ts | 135 +++++++++++++++++ src/cli/plugins/override/index.ts | 52 +++++++ .../tests/fixtures/bookmarks/src/bookmarks.ts | 7 + .../tests/fixtures/bookmarks/tsconfig.json | 1 + .../fixtures/conflict/src/history/index.ts | 7 + .../tests/fixtures/conflict/src/newtab.ts | 7 + .../tests/fixtures/conflict/tsconfig.json | 1 + .../tests/fixtures/empty/src/popup.ts | 1 + .../tests/fixtures/history/src/history.ts | 7 + .../tests/fixtures/history/tsconfig.json | 1 + .../tests/fixtures/newtab/src/newtab.ts | 7 + .../tests/fixtures/newtab/tsconfig.json | 1 + .../tests/fixtures/split/src/history.ts | 7 + .../tests/fixtures/split/src/newtab.ts | 8 + .../tests/fixtures/split/tsconfig.json | 1 + src/cli/plugins/view/index.ts | 11 +- src/cli/resolvers/config.test.ts | 1 + src/cli/resolvers/config.ts | 2 + src/main/index.ts | 1 + src/main/override.ts | 22 +++ src/types/entrypoint.ts | 3 + src/types/manifest.ts | 21 +++ src/types/override.ts | 37 +++++ src/types/plugin.ts | 6 + tests/integration/README.md | 8 + tests/integration/browser/override/README.md | 18 +++ .../override/bookmarks/adnbn.config.ts | 9 ++ .../browser/override/bookmarks/package.json | 9 ++ .../override/bookmarks/src/bookmarks/index.ts | 30 ++++ .../bookmarks/src/bookmarks/styles.css | 3 + .../browser/override/bookmarks/tsconfig.json | 3 + .../browser/override/history/adnbn.config.ts | 9 ++ .../browser/override/history/package.json | 9 ++ .../override/history/src/history/index.ts | 30 ++++ .../override/history/src/history/styles.css | 3 + .../browser/override/history/tsconfig.json | 3 + .../browser/override/newtab/adnbn.config.ts | 10 ++ .../browser/override/newtab/package.json | 12 ++ .../browser/override/newtab/src/help.page.tsx | 9 ++ .../override/newtab/src/newtab/index.tsx | 22 +++ .../override/newtab/src/newtab/styles.css | 3 + .../browser/override/newtab/tsconfig.json | 6 + .../override/override.integration.test.ts | 82 ++++++++++ .../build/override/bookmarks/adnbn.config.ts | 7 + .../build/override/bookmarks/package.json | 9 ++ .../build/override/bookmarks/src/bookmarks.ts | 7 + .../build/override/bookmarks/tsconfig.json | 3 + .../build/override/history/adnbn.config.ts | 7 + .../build/override/history/package.json | 9 ++ .../build/override/history/src/history.ts | 7 + .../build/override/history/tsconfig.json | 3 + .../build/override/newtab/adnbn.config.ts | 7 + .../build/override/newtab/package.json | 9 ++ .../build/override/newtab/src/newtab.ts | 7 + .../build/override/newtab/tsconfig.json | 3 + .../override/override.integration.test.ts | 109 ++++++++++++++ 107 files changed, 1524 insertions(+), 6 deletions(-) create mode 100644 src/cli/entrypoint/finder/AbstractOverrideFinder.test.ts create mode 100644 src/cli/entrypoint/finder/AbstractOverrideFinder.ts create mode 100644 src/cli/entrypoint/finder/BookmarksFinder.ts create mode 100644 src/cli/entrypoint/finder/HistoryFinder.ts create mode 100644 src/cli/entrypoint/finder/NewtabFinder.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/account.bookmarks.tsx create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/advanced.bookmarks/index.tsx create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/bookmarks.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/bookmarks/index.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/popup.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/account.history.tsx create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/advanced.history/index.tsx create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/history.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/history/index.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/popup.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/account.newtab.tsx create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/advanced.newtab/index.tsx create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/newtab.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/newtab/index.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/popup.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/bookmarks.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/history.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/newtab.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/bookmarks.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/history.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/newtab.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/shared/bookmarks.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/shared/history.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/shared/newtab.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/override/workspace/tsconfig.json create mode 100644 src/cli/entrypoint/parser/BookmarksParser.ts create mode 100644 src/cli/entrypoint/parser/HistoryParser.ts create mode 100644 src/cli/entrypoint/parser/NewtabParser.ts create mode 100644 src/cli/entrypoint/parser/override-parsers.test.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/foreign-definition/bookmarks.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/full/bookmarks.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/named-exports/bookmarks.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/override/history/foreign-definition/history.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/override/history/full/history.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/override/history/named-exports/history.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/override/newtab/foreign-definition/newtab.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/override/newtab/full/newtab.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/override/newtab/named-exports/newtab.ts create mode 100644 src/cli/plugins/override/Override.test.ts create mode 100644 src/cli/plugins/override/Override.ts create mode 100644 src/cli/plugins/override/index.ts create mode 100644 src/cli/plugins/override/tests/fixtures/bookmarks/src/bookmarks.ts create mode 100644 src/cli/plugins/override/tests/fixtures/bookmarks/tsconfig.json create mode 100644 src/cli/plugins/override/tests/fixtures/conflict/src/history/index.ts create mode 100644 src/cli/plugins/override/tests/fixtures/conflict/src/newtab.ts create mode 100644 src/cli/plugins/override/tests/fixtures/conflict/tsconfig.json create mode 100644 src/cli/plugins/override/tests/fixtures/empty/src/popup.ts create mode 100644 src/cli/plugins/override/tests/fixtures/history/src/history.ts create mode 100644 src/cli/plugins/override/tests/fixtures/history/tsconfig.json create mode 100644 src/cli/plugins/override/tests/fixtures/newtab/src/newtab.ts create mode 100644 src/cli/plugins/override/tests/fixtures/newtab/tsconfig.json create mode 100644 src/cli/plugins/override/tests/fixtures/split/src/history.ts create mode 100644 src/cli/plugins/override/tests/fixtures/split/src/newtab.ts create mode 100644 src/cli/plugins/override/tests/fixtures/split/tsconfig.json create mode 100644 src/main/override.ts create mode 100644 src/types/override.ts create mode 100644 tests/integration/browser/override/README.md create mode 100644 tests/integration/browser/override/bookmarks/adnbn.config.ts create mode 100644 tests/integration/browser/override/bookmarks/package.json create mode 100644 tests/integration/browser/override/bookmarks/src/bookmarks/index.ts create mode 100644 tests/integration/browser/override/bookmarks/src/bookmarks/styles.css create mode 100644 tests/integration/browser/override/bookmarks/tsconfig.json create mode 100644 tests/integration/browser/override/history/adnbn.config.ts create mode 100644 tests/integration/browser/override/history/package.json create mode 100644 tests/integration/browser/override/history/src/history/index.ts create mode 100644 tests/integration/browser/override/history/src/history/styles.css create mode 100644 tests/integration/browser/override/history/tsconfig.json create mode 100644 tests/integration/browser/override/newtab/adnbn.config.ts create mode 100644 tests/integration/browser/override/newtab/package.json create mode 100644 tests/integration/browser/override/newtab/src/help.page.tsx create mode 100644 tests/integration/browser/override/newtab/src/newtab/index.tsx create mode 100644 tests/integration/browser/override/newtab/src/newtab/styles.css create mode 100644 tests/integration/browser/override/newtab/tsconfig.json create mode 100644 tests/integration/browser/override/override.integration.test.ts create mode 100644 tests/integration/build/override/bookmarks/adnbn.config.ts create mode 100644 tests/integration/build/override/bookmarks/package.json create mode 100644 tests/integration/build/override/bookmarks/src/bookmarks.ts create mode 100644 tests/integration/build/override/bookmarks/tsconfig.json create mode 100644 tests/integration/build/override/history/adnbn.config.ts create mode 100644 tests/integration/build/override/history/package.json create mode 100644 tests/integration/build/override/history/src/history.ts create mode 100644 tests/integration/build/override/history/tsconfig.json create mode 100644 tests/integration/build/override/newtab/adnbn.config.ts create mode 100644 tests/integration/build/override/newtab/package.json create mode 100644 tests/integration/build/override/newtab/src/newtab.ts create mode 100644 tests/integration/build/override/newtab/tsconfig.json create mode 100644 tests/integration/build/override/override.integration.test.ts diff --git a/src/cli/builders/manifest/Manifest.test.ts b/src/cli/builders/manifest/Manifest.test.ts index ae2cab93..c26e4a92 100644 --- a/src/cli/builders/manifest/Manifest.test.ts +++ b/src/cli/builders/manifest/Manifest.test.ts @@ -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); diff --git a/src/cli/builders/manifest/ManifestBase.ts b/src/cli/builders/manifest/ManifestBase.ts index 80027b2c..4a28e719 100644 --- a/src/cli/builders/manifest/ManifestBase.ts +++ b/src/cli/builders/manifest/ManifestBase.ts @@ -19,6 +19,7 @@ import { ManifestIncognito, ManifestOptionalPermissions, ManifestOptions, + ManifestOverride, ManifestPermissions, ManifestPopup, ManifestSandbox, @@ -64,6 +65,7 @@ export default abstract class implements ManifestBuilder protected popup?: ManifestPopup; protected sidebar?: ManifestSidebar; protected options?: ManifestOptions; + protected urlOverride?: ManifestOverride; protected sandboxes: ManifestSandboxes = new Set(); protected sandboxCsp: CspBuilder = new SandboxCsp(); protected csp: CspBuilder = new Csp(); @@ -273,6 +275,12 @@ export default abstract class implements ManifestBuilder return this; } + public setOverride(override?: ManifestOverride): this { + this.urlOverride = override; + + return this; + } + public addSandbox(sandbox: ManifestSandbox): this { this.sandboxes.add(sandbox); @@ -445,6 +453,7 @@ export default abstract class implements ManifestBuilder this.buildAction(), this.buildSidebar(), this.buildOptions(), + this.buildOverride(), this.buildContentScripts(), this.buildPermissions(), this.buildOptionalPermissions(), @@ -678,6 +687,16 @@ export default abstract class implements ManifestBuilder return _.pick(this.combinedRaws, ["options_ui", "options_page"]); } + protected buildOverride(): Partial { + if (this.urlOverride) { + const {page, path} = this.urlOverride; + + return {chrome_url_overrides: {[page]: path}}; + } + + return _.pick(this.combinedRaws, ["chrome_url_overrides"]); + } + protected buildBrowserSpecificSettings(): Partial | undefined { const optionalSettings = this.combinedRaws.browser_specific_settings; const {safari, gecko, geckoAndroid} = this.specific || {}; @@ -757,6 +776,7 @@ export default abstract class implements ManifestBuilder sidebar, options_ui, options_page, + chrome_url_overrides, content_scripts, permissions, optional_permissions, diff --git a/src/cli/entrypoint/finder/AbstractOverrideFinder.test.ts b/src/cli/entrypoint/finder/AbstractOverrideFinder.test.ts new file mode 100644 index 00000000..fc81d9b5 --- /dev/null +++ b/src/cli/entrypoint/finder/AbstractOverrideFinder.test.ts @@ -0,0 +1,112 @@ +import path from "path"; + +import BookmarksFinder from "./BookmarksFinder"; +import HistoryFinder from "./HistoryFinder"; +import NewtabFinder from "./NewtabFinder"; + +import {toPosix} from "@cli/utils/path"; + +import type {ReadonlyConfig} from "@typing/config"; +import type {EntrypointFile} from "@typing/entrypoint"; + +const fixtures = path.resolve(__dirname, "tests", "fixtures", "override"); +const workspace = path.join(fixtures, "workspace"); + +const makeConfig = (overrides: Partial = {}): ReadonlyConfig => { + return { + app: "app", + appSrcDir: ".", + appsDir: "apps", + debug: false, + htmlDir: ".", + plugins: [], + rootDir: workspace, + sharedDir: "shared", + srcDir: "src", + ...overrides, + } as Partial as ReadonlyConfig; +}; + +const relativeFiles = (root: string, files: Iterable): string[] => { + return Array.from(files, ({file}) => toPosix(path.relative(root, file))); +}; + +describe.each([ + {type: "newtab", Finder: NewtabFinder}, + {type: "bookmarks", Finder: BookmarksFinder}, + {type: "history", Finder: HistoryFinder}, +] as const)("$type finder", ({type, Finder}) => { + const makeFinder = (overrides: Partial = {}) => { + const config = makeConfig(overrides); + const finder = new Finder(config); + + config.plugins.push( + { + name: "third-party", + [type]: { + file: path.join(workspace, "plugins", "third-party", `${type}.ts`), + import: `third-party/${type}`, + external: "third-party", + }, + }, + { + name: "adnbn:override", + [type]: () => finder.files(), + } + ); + + return finder; + }; + + test("discovers named files and index directories of a single-app project", async () => { + const rootDir = path.join(fixtures, "discovery", type); + const finder = new Finder(makeConfig({rootDir, sharedDir: "."})); + + expect(relativeFiles(path.join(rootDir, "src"), await finder.files()).sort()).toEqual([ + `account.${type}.tsx`, + `advanced.${type}/index.tsx`, + `${type}.ts`, + `${type}/index.ts`, + ]); + }); + + test("replaces the shared candidate with the app one instead of merging them", async () => { + expect(relativeFiles(workspace, await makeFinder().files())).toEqual([`src/apps/app/${type}.ts`]); + }); + + test("falls back to the shared candidate for an app without its own", async () => { + expect(relativeFiles(workspace, await makeFinder({app: "other"}).files())).toEqual([`src/shared/${type}.ts`]); + }); + + test("turns only the highest-precedence candidate into a view with its CSP", async () => { + const finder = makeFinder(); + + expect(relativeFiles(workspace, await finder.plugin().files())).toEqual([ + `plugins/third-party/${type}.ts`, + `src/apps/app/${type}.ts`, + ]); + + const views = await finder.views(); + + expect([...views.keys()]).toEqual([type]); + expect(views.get(type)).toMatchObject({ + alias: type, + filename: `${type}.html`, + file: {file: path.join(workspace, "src", "apps", "app", `${type}.ts`)}, + options: {title: `App ${type}`}, + }); + await expect(finder.csp()).resolves.toEqual([{sources: {connect: ["https://app.example.com"]}}]); + }); + + test("uses the plugin candidate when the workspace has none", async () => { + const finder = makeFinder({srcDir: "missing"}); + const [view] = (await finder.views()).values(); + + expect(view).toMatchObject({ + alias: `third-party/${type}`, + filename: `${type}.html`, + options: {title: `Plugin ${type}`}, + }); + await expect(finder.csp()).resolves.toEqual([{sources: {connect: ["https://plugin.example.com"]}}]); + }); +}); diff --git a/src/cli/entrypoint/finder/AbstractOverrideFinder.ts b/src/cli/entrypoint/finder/AbstractOverrideFinder.ts new file mode 100644 index 00000000..a89196af --- /dev/null +++ b/src/cli/entrypoint/finder/AbstractOverrideFinder.ts @@ -0,0 +1,31 @@ +import ViewCspFinder from "./ViewCspFinder"; +import PluginFinder from "./PluginFinder"; + +import {ReadonlyConfig} from "@typing/config"; +import {EntrypointOptionsFinder} from "@typing/entrypoint"; +import {OverrideEntrypointOptions, OverrideEntrypointType} from "@typing/override"; + +/** + * Override pages are singletons: app files replace shared ones instead of merging with them, + * and only the highest-precedence candidate becomes a view. + */ +export default abstract class AbstractOverrideFinder extends ViewCspFinder { + protected constructor(config: ReadonlyConfig) { + super(config); + } + + public abstract type(): OverrideEntrypointType; + + protected getPlugin(): EntrypointOptionsFinder { + // Plugin handlers of override pages are keyed by their entrypoint type. + return new PluginFinder(this.config, `${this.type()}` as const, this); + } + + public canMerge(): boolean { + return false; + } + + public allowMultiple(): boolean { + return false; + } +} diff --git a/src/cli/entrypoint/finder/BookmarksFinder.ts b/src/cli/entrypoint/finder/BookmarksFinder.ts new file mode 100644 index 00000000..35f040a7 --- /dev/null +++ b/src/cli/entrypoint/finder/BookmarksFinder.ts @@ -0,0 +1,21 @@ +import AbstractOverrideFinder from "./AbstractOverrideFinder"; + +import {BookmarksParser} from "../parser"; + +import {ReadonlyConfig} from "@typing/config"; +import {EntrypointParser, EntrypointType} from "@typing/entrypoint"; +import {BookmarksEntrypointOptions, OverrideEntrypointType} from "@typing/override"; + +export default class BookmarksFinder extends AbstractOverrideFinder { + public constructor(config: ReadonlyConfig) { + super(config); + } + + public type(): OverrideEntrypointType { + return EntrypointType.Bookmarks; + } + + protected getParser(): EntrypointParser { + return new BookmarksParser(this.config); + } +} diff --git a/src/cli/entrypoint/finder/HistoryFinder.ts b/src/cli/entrypoint/finder/HistoryFinder.ts new file mode 100644 index 00000000..0f44b262 --- /dev/null +++ b/src/cli/entrypoint/finder/HistoryFinder.ts @@ -0,0 +1,21 @@ +import AbstractOverrideFinder from "./AbstractOverrideFinder"; + +import {HistoryParser} from "../parser"; + +import {ReadonlyConfig} from "@typing/config"; +import {EntrypointParser, EntrypointType} from "@typing/entrypoint"; +import {HistoryEntrypointOptions, OverrideEntrypointType} from "@typing/override"; + +export default class HistoryFinder extends AbstractOverrideFinder { + public constructor(config: ReadonlyConfig) { + super(config); + } + + public type(): OverrideEntrypointType { + return EntrypointType.History; + } + + protected getParser(): EntrypointParser { + return new HistoryParser(this.config); + } +} diff --git a/src/cli/entrypoint/finder/NewtabFinder.ts b/src/cli/entrypoint/finder/NewtabFinder.ts new file mode 100644 index 00000000..6d61164c --- /dev/null +++ b/src/cli/entrypoint/finder/NewtabFinder.ts @@ -0,0 +1,21 @@ +import AbstractOverrideFinder from "./AbstractOverrideFinder"; + +import {NewtabParser} from "../parser"; + +import {ReadonlyConfig} from "@typing/config"; +import {EntrypointParser, EntrypointType} from "@typing/entrypoint"; +import {NewtabEntrypointOptions, OverrideEntrypointType} from "@typing/override"; + +export default class NewtabFinder extends AbstractOverrideFinder { + public constructor(config: ReadonlyConfig) { + super(config); + } + + public type(): OverrideEntrypointType { + return EntrypointType.Newtab; + } + + protected getParser(): EntrypointParser { + return new NewtabParser(this.config); + } +} diff --git a/src/cli/entrypoint/finder/PageFinder.test.ts b/src/cli/entrypoint/finder/PageFinder.test.ts index 0322806c..3c2b0e2c 100644 --- a/src/cli/entrypoint/finder/PageFinder.test.ts +++ b/src/cli/entrypoint/finder/PageFinder.test.ts @@ -64,11 +64,14 @@ describe("PageFinder", () => { expect(new PageFinder(makeConfig({mergePages: false})).canMerge()).toBe(false); }); - test.each(["sandbox", "options"])("keeps page filenames away from reserved %s output", async name => { - const page = new ExposedPageFinder(config, new Map([[file(`${name}.ts`), {as: name}]])); + test.each(["sandbox", "options", "newtab", "bookmarks", "history"])( + "keeps page filenames away from reserved %s output", + async name => { + const page = new ExposedPageFinder(config, new Map([[file(`${name}.ts`), {as: name}]])); - await expect(page.views()).resolves.toMatchObject(new Map([[`${name}.page`, {filename: `${name}1.html`}]])); - }); + await expect(page.views()).resolves.toMatchObject(new Map([[`${name}.page`, {filename: `${name}1.html`}]])); + } + ); test("uses page name as an alias when it is defined", () => { expect(new ExposedPageFinder(config).aliasFrom(file("named.ts"), {name: "docs"})).toBe("docs"); diff --git a/src/cli/entrypoint/finder/PageFinder.ts b/src/cli/entrypoint/finder/PageFinder.ts index be0d5b90..43f2e337 100644 --- a/src/cli/entrypoint/finder/PageFinder.ts +++ b/src/cli/entrypoint/finder/PageFinder.ts @@ -32,7 +32,10 @@ export default class extends ViewCspFinder { .reserve(EntrypointType.Popup) .reserve(EntrypointType.Offscreen) .reserve(EntrypointType.Sandbox) - .reserve(EntrypointType.Options); + .reserve(EntrypointType.Options) + .reserve(EntrypointType.Newtab) + .reserve(EntrypointType.Bookmarks) + .reserve(EntrypointType.History); } protected createViewAlias(file: EntrypointFile, options: PageEntrypointOptions): string { diff --git a/src/cli/entrypoint/finder/index.ts b/src/cli/entrypoint/finder/index.ts index c964633e..2852eb4f 100644 --- a/src/cli/entrypoint/finder/index.ts +++ b/src/cli/entrypoint/finder/index.ts @@ -1,5 +1,6 @@ export {default as AbstractFinder} from "./AbstractFinder"; export {default as AbstractEntrypointFinder} from "./AbstractEntrypointFinder"; +export {default as AbstractOverrideFinder} from "./AbstractOverrideFinder"; export {default as AbstractPluginFinder} from "./AbstractPluginFinder"; export { default as AbstractViewFinder, @@ -11,10 +12,13 @@ export { export {default as ViewCspFinder} from "./ViewCspFinder"; export {default as AbstractParsedFinder} from "./AbstractParsedFinder"; export {default as BackgroundFinder} from "./BackgroundFinder"; +export {default as BookmarksFinder} from "./BookmarksFinder"; export {default as CommandFinder} from "./CommandFinder"; export {default as ContentFinder} from "./ContentFinder"; +export {default as HistoryFinder} from "./HistoryFinder"; export {default as IconFinder, type IconName, type IconGroups, type IconItem, type IconItems} from "./IconFinder"; export {default as LocaleFinder} from "./LocaleFinder"; +export {default as NewtabFinder} from "./NewtabFinder"; export {default as OffscreenFinder} from "./OffscreenFinder"; export {default as OffscreenViewFinder} from "./OffscreenViewFinder"; export {default as OptionsFinder} from "./OptionsFinder"; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/account.bookmarks.tsx b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/account.bookmarks.tsx new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/account.bookmarks.tsx @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/advanced.bookmarks/index.tsx b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/advanced.bookmarks/index.tsx new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/advanced.bookmarks/index.tsx @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/bookmarks.ts b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/bookmarks.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/bookmarks.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/bookmarks/index.ts b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/bookmarks/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/bookmarks/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/popup.ts b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/popup.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/bookmarks/src/popup.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/account.history.tsx b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/account.history.tsx new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/account.history.tsx @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/advanced.history/index.tsx b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/advanced.history/index.tsx new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/advanced.history/index.tsx @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/history.ts b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/history.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/history.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/history/index.ts b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/history/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/history/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/popup.ts b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/popup.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/history/src/popup.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/account.newtab.tsx b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/account.newtab.tsx new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/account.newtab.tsx @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/advanced.newtab/index.tsx b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/advanced.newtab/index.tsx new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/advanced.newtab/index.tsx @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/newtab.ts b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/newtab.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/newtab.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/newtab/index.ts b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/newtab/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/newtab/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/popup.ts b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/popup.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/discovery/newtab/src/popup.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/bookmarks.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/bookmarks.ts new file mode 100644 index 00000000..3e352bfb --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/bookmarks.ts @@ -0,0 +1,4 @@ +export const title = "Plugin bookmarks"; +export const csp = {sources: {connect: ["https://plugin.example.com"]}}; + +export default () => "Plugin bookmarks"; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/history.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/history.ts new file mode 100644 index 00000000..f39700f0 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/history.ts @@ -0,0 +1,4 @@ +export const title = "Plugin history"; +export const csp = {sources: {connect: ["https://plugin.example.com"]}}; + +export default () => "Plugin history"; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/newtab.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/newtab.ts new file mode 100644 index 00000000..37d09b87 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/newtab.ts @@ -0,0 +1,4 @@ +export const title = "Plugin newtab"; +export const csp = {sources: {connect: ["https://plugin.example.com"]}}; + +export default () => "Plugin newtab"; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/bookmarks.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/bookmarks.ts new file mode 100644 index 00000000..21b9b2fc --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/bookmarks.ts @@ -0,0 +1,7 @@ +import {defineBookmarks} from "adnbn"; + +export default defineBookmarks({ + title: "App bookmarks", + csp: {sources: {connect: ["https://app.example.com"]}}, + render: () => "App bookmarks", +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/history.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/history.ts new file mode 100644 index 00000000..37502540 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/history.ts @@ -0,0 +1,7 @@ +import {defineHistory} from "adnbn"; + +export default defineHistory({ + title: "App history", + csp: {sources: {connect: ["https://app.example.com"]}}, + render: () => "App history", +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/newtab.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/newtab.ts new file mode 100644 index 00000000..10a3678f --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/newtab.ts @@ -0,0 +1,7 @@ +import {defineNewtab} from "adnbn"; + +export default defineNewtab({ + title: "App newtab", + csp: {sources: {connect: ["https://app.example.com"]}}, + render: () => "App newtab", +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/shared/bookmarks.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/shared/bookmarks.ts new file mode 100644 index 00000000..3859c368 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/shared/bookmarks.ts @@ -0,0 +1,4 @@ +export const title = "Shared bookmarks"; +export const csp = {sources: {connect: ["https://shared.example.com"]}}; + +export default () => "Shared bookmarks"; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/shared/history.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/shared/history.ts new file mode 100644 index 00000000..feab86c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/shared/history.ts @@ -0,0 +1,4 @@ +export const title = "Shared history"; +export const csp = {sources: {connect: ["https://shared.example.com"]}}; + +export default () => "Shared history"; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/shared/newtab.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/shared/newtab.ts new file mode 100644 index 00000000..6de612a2 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/shared/newtab.ts @@ -0,0 +1,4 @@ +export const title = "Shared newtab"; +export const csp = {sources: {connect: ["https://shared.example.com"]}}; + +export default () => "Shared newtab"; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/tsconfig.json b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/tsconfig.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/src/cli/entrypoint/parser/BookmarksParser.ts b/src/cli/entrypoint/parser/BookmarksParser.ts new file mode 100644 index 00000000..8f8ca8ef --- /dev/null +++ b/src/cli/entrypoint/parser/BookmarksParser.ts @@ -0,0 +1,9 @@ +import ViewCspParser from "./ViewCspParser"; + +import {BookmarksEntrypointOptions} from "@typing/override"; + +export default class BookmarksParser extends ViewCspParser { + protected definition(): string { + return "defineBookmarks"; + } +} diff --git a/src/cli/entrypoint/parser/HistoryParser.ts b/src/cli/entrypoint/parser/HistoryParser.ts new file mode 100644 index 00000000..a9a494db --- /dev/null +++ b/src/cli/entrypoint/parser/HistoryParser.ts @@ -0,0 +1,9 @@ +import ViewCspParser from "./ViewCspParser"; + +import {HistoryEntrypointOptions} from "@typing/override"; + +export default class HistoryParser extends ViewCspParser { + protected definition(): string { + return "defineHistory"; + } +} diff --git a/src/cli/entrypoint/parser/NewtabParser.ts b/src/cli/entrypoint/parser/NewtabParser.ts new file mode 100644 index 00000000..6c2fba6e --- /dev/null +++ b/src/cli/entrypoint/parser/NewtabParser.ts @@ -0,0 +1,9 @@ +import ViewCspParser from "./ViewCspParser"; + +import {NewtabEntrypointOptions} from "@typing/override"; + +export default class NewtabParser extends ViewCspParser { + protected definition(): string { + return "defineNewtab"; + } +} diff --git a/src/cli/entrypoint/parser/index.ts b/src/cli/entrypoint/parser/index.ts index 542a5dc5..97c3df9c 100644 --- a/src/cli/entrypoint/parser/index.ts +++ b/src/cli/entrypoint/parser/index.ts @@ -1,6 +1,9 @@ export {default as BackgroundParser} from "./BackgroundParser"; +export {default as BookmarksParser} from "./BookmarksParser"; export {default as CommandParser} from "./CommandParser"; export {default as ContentParser} from "./ContentParser"; +export {default as HistoryParser} from "./HistoryParser"; +export {default as NewtabParser} from "./NewtabParser"; export {default as OptionsParser} from "./OptionsParser"; export {default as PageParser} from "./PageParser"; export {default as PopupParser} from "./PopupParser"; diff --git a/src/cli/entrypoint/parser/override-parsers.test.ts b/src/cli/entrypoint/parser/override-parsers.test.ts new file mode 100644 index 00000000..5b83783c --- /dev/null +++ b/src/cli/entrypoint/parser/override-parsers.test.ts @@ -0,0 +1,62 @@ +import path from "path"; + +import BookmarksParser from "./BookmarksParser"; +import HistoryParser from "./HistoryParser"; +import NewtabParser from "./NewtabParser"; + +import type {ReadonlyConfig} from "@typing/config"; + +const rootDir = path.resolve(__dirname, "../../../.."); +const fixtures = path.resolve(__dirname, "tests", "fixtures", "override"); + +const config = {rootDir} as ReadonlyConfig; + +describe.each([ + {type: "newtab", label: "New tab", parser: new NewtabParser(config)}, + {type: "bookmarks", label: "Bookmarks", parser: new BookmarksParser(config)}, + {type: "history", label: "History", parser: new HistoryParser(config)}, +])("$type parser", ({type, label, parser}) => { + const parseOptions = (scenario: string) => { + const filename = path.join(fixtures, type, scenario, `${type}.ts`); + + return parser.options({file: filename, import: filename}); + }; + + test("parses its own definition with inherited view, CSP and build filters", () => { + expect(parseOptions("full")).toEqual({ + as: "dashboard", + title: `${label} override`, + template: "./template.html", + includeApp: ["app"], + excludeApp: ["legacy"], + includeBrowser: ["chrome"], + excludeBrowser: ["opera"], + mode: "production", + debug: true, + manifestVersion: 3, + csp: { + wasm: true, + sources: { + connect: ["'self'", "https://api.example.com"], + image: ["'self'", "data:", "blob:"], + }, + }, + scripts: "extra.js", + links: "extra.css", + metas: { + attributes: { + name: `${type}-test`, + content: "enabled", + }, + }, + }); + }); + + test("reads named exports alongside a default render function without options-only settings", () => { + expect(parseOptions("named-exports")).toEqual({title: `Named ${type}`}); + }); + + test("ignores options wrapped in another override definition", () => { + expect(parseOptions("foreign-definition")).toEqual({}); + }); +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/foreign-definition/bookmarks.ts b/src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/foreign-definition/bookmarks.ts new file mode 100644 index 00000000..f95f4eb2 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/foreign-definition/bookmarks.ts @@ -0,0 +1,6 @@ +import {defineNewtab} from "adnbn"; + +export default defineNewtab({ + title: "Foreign bookmarks", + render: () => "Bookmarks", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/full/bookmarks.ts b/src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/full/bookmarks.ts new file mode 100644 index 00000000..dcdaddba --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/full/bookmarks.ts @@ -0,0 +1,30 @@ +import {Browser, CspSource, defineBookmarks, Mode} from "adnbn"; + +export default defineBookmarks({ + as: "dashboard", + title: "Bookmarks override", + template: "./template.html", + includeApp: ["app"], + excludeApp: ["legacy"], + includeBrowser: [Browser.Chrome], + excludeBrowser: [Browser.Opera], + mode: Mode.Production, + debug: true, + manifestVersion: 3, + csp: { + wasm: true, + sources: { + connect: [CspSource.Self, "https://api.example.com"], + image: [CspSource.Self, "data:", "blob:"], + }, + }, + scripts: "extra.js", + links: "extra.css", + metas: { + attributes: { + name: "bookmarks-test", + content: "enabled", + }, + }, + render: ({title}) => title, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/named-exports/bookmarks.ts b/src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/named-exports/bookmarks.ts new file mode 100644 index 00000000..b22a06dc --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/named-exports/bookmarks.ts @@ -0,0 +1,4 @@ +export const title = "Named bookmarks"; +export const openInTab = false; + +export default () => "Bookmarks"; diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/history/foreign-definition/history.ts b/src/cli/entrypoint/parser/tests/fixtures/override/history/foreign-definition/history.ts new file mode 100644 index 00000000..527a401e --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/override/history/foreign-definition/history.ts @@ -0,0 +1,6 @@ +import {defineBookmarks} from "adnbn"; + +export default defineBookmarks({ + title: "Foreign history", + render: () => "History", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/history/full/history.ts b/src/cli/entrypoint/parser/tests/fixtures/override/history/full/history.ts new file mode 100644 index 00000000..991a9b6a --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/override/history/full/history.ts @@ -0,0 +1,30 @@ +import {Browser, CspSource, defineHistory, Mode} from "adnbn"; + +export default defineHistory({ + as: "dashboard", + title: "History override", + template: "./template.html", + includeApp: ["app"], + excludeApp: ["legacy"], + includeBrowser: [Browser.Chrome], + excludeBrowser: [Browser.Opera], + mode: Mode.Production, + debug: true, + manifestVersion: 3, + csp: { + wasm: true, + sources: { + connect: [CspSource.Self, "https://api.example.com"], + image: [CspSource.Self, "data:", "blob:"], + }, + }, + scripts: "extra.js", + links: "extra.css", + metas: { + attributes: { + name: "history-test", + content: "enabled", + }, + }, + render: ({title}) => title, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/history/named-exports/history.ts b/src/cli/entrypoint/parser/tests/fixtures/override/history/named-exports/history.ts new file mode 100644 index 00000000..d3035a25 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/override/history/named-exports/history.ts @@ -0,0 +1,4 @@ +export const title = "Named history"; +export const openInTab = false; + +export default () => "History"; diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/newtab/foreign-definition/newtab.ts b/src/cli/entrypoint/parser/tests/fixtures/override/newtab/foreign-definition/newtab.ts new file mode 100644 index 00000000..7a8409fc --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/override/newtab/foreign-definition/newtab.ts @@ -0,0 +1,6 @@ +import {defineHistory} from "adnbn"; + +export default defineHistory({ + title: "Foreign newtab", + render: () => "New tab", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/newtab/full/newtab.ts b/src/cli/entrypoint/parser/tests/fixtures/override/newtab/full/newtab.ts new file mode 100644 index 00000000..db36d500 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/override/newtab/full/newtab.ts @@ -0,0 +1,30 @@ +import {Browser, CspSource, defineNewtab, Mode} from "adnbn"; + +export default defineNewtab({ + as: "dashboard", + title: "New tab override", + template: "./template.html", + includeApp: ["app"], + excludeApp: ["legacy"], + includeBrowser: [Browser.Chrome], + excludeBrowser: [Browser.Opera], + mode: Mode.Production, + debug: true, + manifestVersion: 3, + csp: { + wasm: true, + sources: { + connect: [CspSource.Self, "https://api.example.com"], + image: [CspSource.Self, "data:", "blob:"], + }, + }, + scripts: "extra.js", + links: "extra.css", + metas: { + attributes: { + name: "newtab-test", + content: "enabled", + }, + }, + render: ({title}) => title, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/newtab/named-exports/newtab.ts b/src/cli/entrypoint/parser/tests/fixtures/override/newtab/named-exports/newtab.ts new file mode 100644 index 00000000..6adee51a --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/override/newtab/named-exports/newtab.ts @@ -0,0 +1,4 @@ +export const title = "Named newtab"; +export const openInTab = false; + +export default () => "New tab"; diff --git a/src/cli/plugins/index.ts b/src/cli/plugins/index.ts index c07fb892..4ac57cb3 100644 --- a/src/cli/plugins/index.ts +++ b/src/cli/plugins/index.ts @@ -12,6 +12,7 @@ export {default as pluginLocale} from "./locale"; export {default as pluginMeta} from "./meta"; export {default as pluginOffscreen} from "./offscreen"; export {default as pluginOptions} from "./options"; +export {default as pluginOverride} from "./override"; export {default as pluginPage} from "./page"; export {default as pluginPopup} from "./popup"; export {default as pluginPublic} from "./public"; diff --git a/src/cli/plugins/override/Override.test.ts b/src/cli/plugins/override/Override.test.ts new file mode 100644 index 00000000..3a04db55 --- /dev/null +++ b/src/cli/plugins/override/Override.test.ts @@ -0,0 +1,142 @@ +import {cp, mkdtemp, readFile, rm, writeFile} from "fs/promises"; +import os from "os"; +import path from "path"; + +import Override from "./Override"; + +import {Browser} from "@typing/browser"; +import {EntrypointType} from "@typing/entrypoint"; +import type {ReadonlyConfig} from "@typing/config"; + +const fixtures = path.resolve(__dirname, "tests", "fixtures"); + +/** Wires the manager the way the plugin does: every override handler serves the workspace files. */ +const makeOverride = (fixture: string, config: Partial = {}): Override => { + const resolved = { + app: "myapp", + appSrcDir: ".", + appsDir: "apps", + browser: Browser.Chrome, + debug: false, + htmlDir: ".", + plugins: [], + rootDir: path.join(fixtures, fixture), + sharedDir: ".", + srcDir: "src", + ...config, + } as Partial as ReadonlyConfig; + + const override = new Override(resolved); + + resolved.plugins.push({ + name: "adnbn:override", + newtab: () => override.files(EntrypointType.Newtab), + bookmarks: () => override.files(EntrypointType.Bookmarks), + history: () => override.files(EntrypointType.History), + }); + + return override; +}; + +const chromium = [Browser.Chrome, Browser.Chromium, Browser.Edge]; + +describe.each([ + {page: "newtab", supported: [...chromium, Browser.Firefox, Browser.Safari], unsupported: [Browser.Opera]}, + {page: "bookmarks", supported: chromium, unsupported: [Browser.Firefox, Browser.Safari, Browser.Opera]}, + {page: "history", supported: chromium, unsupported: [Browser.Firefox, Browser.Safari, Browser.Opera]}, +])("Override with a $page entrypoint", ({page, supported, unsupported}) => { + test.each(supported)("builds the page, its manifest override and CSP for %s", async browser => { + const override = makeOverride(page, {browser}); + const entry = {file: path.join(fixtures, page, "src", `${page}.ts`)}; + const view = await override.view(); + + await expect(override.manifest()).resolves.toEqual({page, path: `${page}.html`}); + await expect(override.csp()).resolves.toEqual([{sources: {connect: [`https://${page}.example.com`]}}]); + await expect(view?.entries()).resolves.toEqual(new Map([[page, new Set([expect.objectContaining(entry)])]])); + await expect(view?.html()).resolves.toMatchObject([ + {filename: `${page}.html`, title: `Custom ${page}`, chunks: [page]}, + ]); + }); + + test.each(unsupported)("builds nothing for %s, which does not support the page", async browser => { + const override = makeOverride(page, {browser}); + + await expect(override.view()).resolves.toBeUndefined(); + await expect(override.manifest()).resolves.toBeUndefined(); + await expect(override.csp()).resolves.toEqual([]); + }); +}); + +describe("Override with competing entrypoints", () => { + test.each(chromium)("fails the %s build and names every competing entrypoint", async browser => { + const override = makeOverride("conflict", {browser}); + + const message = [ + `An extension can override only one browser page, but app "myapp" enables 2 override entrypoints for ${browser}:`, + " - newtab: src/newtab.ts", + " - history: src/history/index.ts", + 'Keep a single override or limit the others with "includeApp", "excludeApp", "includeBrowser" or "excludeBrowser".', + ].join("\n"); + + await expect(override.view()).rejects.toThrow(message); + await expect(override.manifest()).rejects.toThrow(message); + await expect(override.csp()).rejects.toThrow(message); + }); + + test.each([Browser.Firefox, Browser.Safari])( + "keeps the new tab for %s because the competing page is not supported there", + async browser => { + const override = makeOverride("conflict", {browser}); + + await expect(override.manifest()).resolves.toEqual({page: "newtab", path: "newtab.html"}); + await expect(override.csp()).resolves.toEqual([{sources: {connect: ["https://newtab.example.com"]}}]); + } + ); + + test.each([ + {browser: Browser.Chrome, page: "history"}, + {browser: Browser.Edge, page: "history"}, + {browser: Browser.Firefox, page: "newtab"}, + {browser: Browser.Safari, page: "newtab"}, + ])("resolves the competition for $browser with entrypoint browser filters", async ({browser, page}) => { + const override = makeOverride("split", {browser}); + + await expect(override.manifest()).resolves.toEqual({page, path: `${page}.html`}); + }); +}); + +describe("Override without entrypoints", () => { + test("selects nothing", async () => { + const override = makeOverride("empty"); + + await expect(override.view()).resolves.toBeUndefined(); + await expect(override.manifest()).resolves.toBeUndefined(); + await expect(override.csp()).resolves.toEqual([]); + }); +}); + +describe("Override refresh", () => { + test("clear re-evaluates the selection against the current files", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "adnbn-override-")); + + try { + await cp(path.join(fixtures, "newtab"), rootDir, {recursive: true}); + + const override = makeOverride("newtab", {rootDir}); + + await expect(override.manifest()).resolves.toEqual({page: "newtab", path: "newtab.html"}); + + await rm(path.join(rootDir, "src", "newtab.ts")); + await writeFile( + path.join(rootDir, "src", "history.ts"), + await readFile(path.join(fixtures, "history", "src", "history.ts")) + ); + + await expect(override.manifest()).resolves.toEqual({page: "newtab", path: "newtab.html"}); + await expect(override.clear().manifest()).resolves.toEqual({page: "history", path: "history.html"}); + await expect(override.csp()).resolves.toEqual([{sources: {connect: ["https://history.example.com"]}}]); + } finally { + await rm(rootDir, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } + }); +}); diff --git a/src/cli/plugins/override/Override.ts b/src/cli/plugins/override/Override.ts new file mode 100644 index 00000000..dd33a665 --- /dev/null +++ b/src/cli/plugins/override/Override.ts @@ -0,0 +1,135 @@ +import path from "path"; + +import View from "../view/View"; + +import {AbstractOverrideFinder, BookmarksFinder, HistoryFinder, NewtabFinder} from "@cli/entrypoint"; +import {resolveRootPath} from "@cli/resolvers/path"; +import {toPosix} from "@cli/utils/path"; + +import {Browser} from "@typing/browser"; +import {EntrypointFile, EntrypointType} from "@typing/entrypoint"; +import type {ReadonlyConfig} from "@typing/config"; +import type {CspConfig} from "@typing/csp"; +import type {ManifestOverride} from "@typing/manifest"; +import type {OverrideEntrypointOptions, OverrideEntrypointType} from "@typing/override"; + +type OverrideFinder = AbstractOverrideFinder; + +const ChromiumBrowsers: readonly Browser[] = [Browser.Chrome, Browser.Chromium, Browser.Edge]; + +/** Browsers that honour each overridden page. Other targets are built without that entrypoint. */ +const OverrideBrowsers: Readonly>> = { + [EntrypointType.Newtab]: new Set([...ChromiumBrowsers, Browser.Firefox, Browser.Safari]), + [EntrypointType.Bookmarks]: new Set(ChromiumBrowsers), + [EntrypointType.History]: new Set(ChromiumBrowsers), +}; + +/** + * Owns the rule shared by the override entrypoints: a build carries at most one of them. + * Pages the target browser does not support are skipped first, then the remaining ones must not compete. + */ +export default class Override { + protected readonly finders: Readonly>; + + protected _finder?: Promise; + + public constructor(protected readonly config: ReadonlyConfig) { + this.finders = { + [EntrypointType.Newtab]: new NewtabFinder(config), + [EntrypointType.Bookmarks]: new BookmarksFinder(config), + [EntrypointType.History]: new HistoryFinder(config), + }; + } + + public files(type: OverrideEntrypointType): Promise> { + return this.finders[type].files(); + } + + public async view(): Promise | undefined> { + const finder = await this.finder(); + + return finder && new View(this.config, finder); + } + + public async manifest(): Promise { + const finder = await this.finder(); + + if (!finder) { + return; + } + + const [view] = (await finder.views()).values(); + + return {page: `${finder.type()}` as const, path: view.filename}; + } + + public async csp(): Promise { + const finder = await this.finder(); + + return finder ? finder.csp() : []; + } + + public clear(): this { + this._finder = undefined; + + for (const finder of Object.values(this.finders)) { + finder.clear(); + } + + return this; + } + + protected finder(): Promise { + return (this._finder ??= this.select()); + } + + protected async select(): Promise { + const {browser, debug} = this.config; + + const finders: OverrideFinder[] = []; + + for (const finder of Object.values(this.finders)) { + if (await finder.empty()) { + continue; + } + + if (!OverrideBrowsers[finder.type()].has(browser)) { + if (debug) { + console.warn(`The "${finder.type()}" override is not supported by ${browser} and was skipped`); + } + + continue; + } + + finders.push(finder); + } + + if (finders.length > 1) { + throw new Error(await this.describeConflict(finders)); + } + + return finders[0]; + } + + protected async describeConflict(finders: OverrideFinder[]): Promise { + const {app, browser} = this.config; + + const rootDir = resolveRootPath(this.config); + + const entries = await Promise.all( + finders.map(async finder => { + const [{file}] = (await finder.views()).values(); + + const location = file.external ? file.import : toPosix(path.relative(rootDir, file.file)); + + return ` - ${finder.type()}: ${location}`; + }) + ); + + return [ + `An extension can override only one browser page, but app "${app}" enables ${finders.length} override entrypoints for ${browser}:`, + ...entries, + `Keep a single override or limit the others with "includeApp", "excludeApp", "includeBrowser" or "excludeBrowser".`, + ].join("\n"); + } +} diff --git a/src/cli/plugins/override/index.ts b/src/cli/plugins/override/index.ts new file mode 100644 index 00000000..005f5577 --- /dev/null +++ b/src/cli/plugins/override/index.ts @@ -0,0 +1,52 @@ +import {Configuration as RspackConfig, HtmlRspackPlugin} from "@rspack/core"; +import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; + +import Override from "./Override"; + +import {definePlugin} from "@main/plugin"; +import {EntrypointPlugin} from "@cli/bundler"; +import {virtualViewModule} from "@cli/virtual"; + +import {Command} from "@typing/app"; +import {EntrypointType} from "@typing/entrypoint"; + +export default definePlugin(() => { + let override: Override; + + return { + name: "adnbn:override", + startup: ({config}) => { + override = new Override(config); + }, + newtab: () => override.files(EntrypointType.Newtab), + bookmarks: () => override.files(EntrypointType.Bookmarks), + history: () => override.files(EntrypointType.History), + bundler: async ({config}) => { + const view = await override.view(); + + if (!view) { + if (config.debug) { + console.info("Override entry not found"); + } + + return {}; + } + + const plugin = EntrypointPlugin.from(await view.entries()).virtual(virtualViewModule); + + if (config.command === Command.Watch) { + plugin.watch(async () => (await override.clear().view())?.entries() ?? new Map()); + } + + const htmlPlugins = (await view.html()).map(options => new HtmlRspackPlugin(options)); + const tagsPlugins = (await view.tags()).map(options => new HtmlRspackTagsPlugin(options)); + + return { + plugins: [plugin, ...htmlPlugins, ...tagsPlugins], + } satisfies RspackConfig; + }, + manifest: async ({manifest}) => { + manifest.setOverride(await override.manifest()).appendCsp(await override.csp()); + }, + }; +}); diff --git a/src/cli/plugins/override/tests/fixtures/bookmarks/src/bookmarks.ts b/src/cli/plugins/override/tests/fixtures/bookmarks/src/bookmarks.ts new file mode 100644 index 00000000..2bd6fb95 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/bookmarks/src/bookmarks.ts @@ -0,0 +1,7 @@ +import {defineBookmarks} from "adnbn"; + +export default defineBookmarks({ + title: "Custom bookmarks", + csp: {sources: {connect: ["https://bookmarks.example.com"]}}, + render: () => "Custom bookmarks", +}); diff --git a/src/cli/plugins/override/tests/fixtures/bookmarks/tsconfig.json b/src/cli/plugins/override/tests/fixtures/bookmarks/tsconfig.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/bookmarks/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/src/cli/plugins/override/tests/fixtures/conflict/src/history/index.ts b/src/cli/plugins/override/tests/fixtures/conflict/src/history/index.ts new file mode 100644 index 00000000..94b3af6b --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/conflict/src/history/index.ts @@ -0,0 +1,7 @@ +import {defineHistory} from "adnbn"; + +export default defineHistory({ + title: "Custom history", + csp: {sources: {connect: ["https://history.example.com"]}}, + render: () => "Custom history", +}); diff --git a/src/cli/plugins/override/tests/fixtures/conflict/src/newtab.ts b/src/cli/plugins/override/tests/fixtures/conflict/src/newtab.ts new file mode 100644 index 00000000..bb6c262d --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/conflict/src/newtab.ts @@ -0,0 +1,7 @@ +import {defineNewtab} from "adnbn"; + +export default defineNewtab({ + title: "Custom newtab", + csp: {sources: {connect: ["https://newtab.example.com"]}}, + render: () => "Custom newtab", +}); diff --git a/src/cli/plugins/override/tests/fixtures/conflict/tsconfig.json b/src/cli/plugins/override/tests/fixtures/conflict/tsconfig.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/conflict/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/src/cli/plugins/override/tests/fixtures/empty/src/popup.ts b/src/cli/plugins/override/tests/fixtures/empty/src/popup.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/empty/src/popup.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/plugins/override/tests/fixtures/history/src/history.ts b/src/cli/plugins/override/tests/fixtures/history/src/history.ts new file mode 100644 index 00000000..94b3af6b --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/history/src/history.ts @@ -0,0 +1,7 @@ +import {defineHistory} from "adnbn"; + +export default defineHistory({ + title: "Custom history", + csp: {sources: {connect: ["https://history.example.com"]}}, + render: () => "Custom history", +}); diff --git a/src/cli/plugins/override/tests/fixtures/history/tsconfig.json b/src/cli/plugins/override/tests/fixtures/history/tsconfig.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/history/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/src/cli/plugins/override/tests/fixtures/newtab/src/newtab.ts b/src/cli/plugins/override/tests/fixtures/newtab/src/newtab.ts new file mode 100644 index 00000000..bb6c262d --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/newtab/src/newtab.ts @@ -0,0 +1,7 @@ +import {defineNewtab} from "adnbn"; + +export default defineNewtab({ + title: "Custom newtab", + csp: {sources: {connect: ["https://newtab.example.com"]}}, + render: () => "Custom newtab", +}); diff --git a/src/cli/plugins/override/tests/fixtures/newtab/tsconfig.json b/src/cli/plugins/override/tests/fixtures/newtab/tsconfig.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/newtab/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/src/cli/plugins/override/tests/fixtures/split/src/history.ts b/src/cli/plugins/override/tests/fixtures/split/src/history.ts new file mode 100644 index 00000000..94b3af6b --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/split/src/history.ts @@ -0,0 +1,7 @@ +import {defineHistory} from "adnbn"; + +export default defineHistory({ + title: "Custom history", + csp: {sources: {connect: ["https://history.example.com"]}}, + render: () => "Custom history", +}); diff --git a/src/cli/plugins/override/tests/fixtures/split/src/newtab.ts b/src/cli/plugins/override/tests/fixtures/split/src/newtab.ts new file mode 100644 index 00000000..7fd494c5 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/split/src/newtab.ts @@ -0,0 +1,8 @@ +import {defineNewtab} from "adnbn"; + +export default defineNewtab({ + title: "Custom newtab", + excludeBrowser: ["chrome", "chromium", "edge"], + csp: {sources: {connect: ["https://newtab.example.com"]}}, + render: () => "Custom newtab", +}); diff --git a/src/cli/plugins/override/tests/fixtures/split/tsconfig.json b/src/cli/plugins/override/tests/fixtures/split/tsconfig.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/split/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/src/cli/plugins/view/index.ts b/src/cli/plugins/view/index.ts index 8c344183..18d7f673 100644 --- a/src/cli/plugins/view/index.ts +++ b/src/cli/plugins/view/index.ts @@ -11,7 +11,16 @@ export default definePlugin(() => { return { name: "adnbn:view", bundler: ({config}) => { - const entryTypeFilter = onlyViaTopLevelEntry(["page", "popup", "sidebar", "offscreen", "options"]); + const entryTypeFilter = onlyViaTopLevelEntry([ + "page", + "popup", + "sidebar", + "offscreen", + "options", + "newtab", + "bookmarks", + "history", + ]); return { optimization: { diff --git a/src/cli/resolvers/config.test.ts b/src/cli/resolvers/config.test.ts index 54e55345..410e8c50 100644 --- a/src/cli/resolvers/config.test.ts +++ b/src/cli/resolvers/config.test.ts @@ -16,6 +16,7 @@ jest.mock("../plugins", () => { pluginOptimization: plugin("optimization"), pluginOptions: plugin("options"), pluginOutput: plugin("output"), + pluginOverride: plugin("override"), pluginPage: plugin("page"), pluginPopup: plugin("popup"), pluginPublic: plugin("public"), diff --git a/src/cli/resolvers/config.ts b/src/cli/resolvers/config.ts index 6a1d1de0..bbc30e1e 100644 --- a/src/cli/resolvers/config.ts +++ b/src/cli/resolvers/config.ts @@ -18,6 +18,7 @@ import { pluginOptimization, pluginOptions, pluginOutput, + pluginOverride, pluginPage, pluginPopup, pluginPublic, @@ -369,6 +370,7 @@ export default async (config: OptionalConfig): Promise => { pluginContent(), pluginBackground(), pluginOptions(), + pluginOverride(), pluginPopup(), pluginPublic(), pluginSidebar(), diff --git a/src/main/index.ts b/src/main/index.ts index c2d909a8..f99da880 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,6 +11,7 @@ export * from "./icon"; export * from "./manifest"; export * from "./offscreen"; export * from "./options"; +export * from "./override"; export * from "./page"; export * from "./plugin"; export * from "./popup"; diff --git a/src/main/override.ts b/src/main/override.ts new file mode 100644 index 00000000..bf5b2192 --- /dev/null +++ b/src/main/override.ts @@ -0,0 +1,22 @@ +import { + BookmarksDefinition, + BookmarksProps, + HistoryDefinition, + HistoryProps, + NewtabDefinition, + NewtabProps, +} from "@typing/override"; + +export type {NewtabDefinition, NewtabProps, BookmarksDefinition, BookmarksProps, HistoryDefinition, HistoryProps}; + +export const defineNewtab = (options: NewtabDefinition): NewtabDefinition => { + return options; +}; + +export const defineBookmarks = (options: BookmarksDefinition): BookmarksDefinition => { + return options; +}; + +export const defineHistory = (options: HistoryDefinition): HistoryDefinition => { + return options; +}; diff --git a/src/types/entrypoint.ts b/src/types/entrypoint.ts index 340a5071..9170c30f 100644 --- a/src/types/entrypoint.ts +++ b/src/types/entrypoint.ts @@ -19,6 +19,9 @@ export enum EntrypointType { Sidebar = "sidebar", Offscreen = "offscreen", Sandbox = "sandbox", + Newtab = "newtab", + Bookmarks = "bookmarks", + History = "history", } export interface EntrypointOptions { diff --git a/src/types/manifest.ts b/src/types/manifest.ts index 0be0103b..1953ca12 100644 --- a/src/types/manifest.ts +++ b/src/types/manifest.ts @@ -133,6 +133,8 @@ export interface ManifestBuilder { setOptions(options?: ManifestOptions): this; + setOverride(override?: ManifestOverride): this; + // Sandbox addSandbox(sandbox: ManifestSandbox): this; @@ -257,6 +259,25 @@ export interface ManifestOptions { openInTab?: boolean; } +export type ManifestOverridePage = "newtab" | "bookmarks" | "history"; + +/** + * Chromium refuses to load an extension that overrides more than one page and other browsers + * only support `newtab`, so the manifest holds a single override instead of a dictionary. + */ +export interface ManifestOverride { + /** + * Built-in browser page replaced by the extension. + * Written as the key of `chrome_url_overrides` in the manifest. + */ + page: ManifestOverridePage; + /** + * Path to the replacement HTML file relative to the extension root. + * Written as the value of `chrome_url_overrides[page]`. + */ + path: string; +} + export interface ManifestAccessibleResource { resources: string[]; matches?: string[]; diff --git a/src/types/override.ts b/src/types/override.ts new file mode 100644 index 00000000..0e3d1c85 --- /dev/null +++ b/src/types/override.ts @@ -0,0 +1,37 @@ +import {ViewDefinition, ViewOptions} from "@typing/view"; +import {CspOptions} from "@typing/csp"; +import {EntrypointType} from "@typing/entrypoint"; + +/** + * Entrypoints that replace a built-in browser page through `chrome_url_overrides`. + * Chromium refuses to load an extension that overrides more than one page, + * so each build selects a single override. + */ +export type OverrideEntrypointType = EntrypointType.Newtab | EntrypointType.Bookmarks | EntrypointType.History; + +export type OverrideEntrypointOptions = CspOptions & ViewOptions; + +export type OverrideProps = OverrideEntrypointOptions; + +export type OverrideDefinition = OverrideEntrypointOptions & ViewDefinition; + +// Newtab +export type NewtabEntrypointOptions = OverrideEntrypointOptions; + +export type NewtabProps = NewtabEntrypointOptions; + +export type NewtabDefinition = NewtabEntrypointOptions & ViewDefinition; + +// Bookmarks +export type BookmarksEntrypointOptions = OverrideEntrypointOptions; + +export type BookmarksProps = BookmarksEntrypointOptions; + +export type BookmarksDefinition = BookmarksEntrypointOptions & ViewDefinition; + +// History +export type HistoryEntrypointOptions = OverrideEntrypointOptions; + +export type HistoryProps = HistoryEntrypointOptions; + +export type HistoryDefinition = HistoryEntrypointOptions & ViewDefinition; diff --git a/src/types/plugin.ts b/src/types/plugin.ts index a08f4b89..22e0eb99 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -78,9 +78,12 @@ interface PluginName { export interface Plugin extends PluginName { background?: PluginHandler; + bookmarks?: PluginHandler; bundler?: PluginHandler; command?: PluginHandler; content?: PluginHandler; + history?: PluginHandler; + newtab?: PluginHandler; options?: PluginHandler; page?: PluginHandler; popup?: PluginHandler; @@ -104,8 +107,11 @@ export type PluginHandlerKeys = keyof Omit; export type PluginEntrypointKeys = keyof Pick< Plugin, | "background" + | "bookmarks" | "command" | "content" + | "history" + | "newtab" | "options" | "page" | "popup" diff --git a/tests/integration/README.md b/tests/integration/README.md index f338a05e..0aa35278 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -22,6 +22,11 @@ tests/integration/ │ ├── options/ │ │ ├── options.integration.test.ts │ │ └── embedded/ +│ ├── override/ +│ │ ├── override.integration.test.ts +│ │ ├── newtab/ +│ │ ├── bookmarks/ +│ │ └── history/ │ └── … ├── browser/ │ ├── content/ @@ -44,6 +49,7 @@ tests/integration/ │ ├── locale/ │ ├── offscreen/ │ ├── options/ +│ ├── override/ │ └── utils/ │ ├── BidiClient.ts │ ├── CdpClient.ts @@ -119,6 +125,8 @@ Tests copy application inputs to unique directories under `.cache/integration`. - `types/registries`: generated registry augmentation, empty fallbacks, public and internal type agreement, and message contracts against source and built package APIs. Compiler-host path checks cover both slash styles. - `build/options/embedded`: ten manifest checks covering explicit `openInTab: false` across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. No browser is launched. - `browser/options`: two Chrome MV3 cases covering Vanilla and React rendering, CSS, state/events, opening Options from background, and a View chunk shared with a Page. +- `build/override`: thirty manifest checks covering the New Tab, Bookmarks, and History applications across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. Supporting browsers receive `chrome_url_overrides`, the page HTML, and its CSP; the others receive none of them. Three more cases add the History entrypoint to a copy of the New Tab application: Chrome and Edge builds fail naming both entrypoints, while Firefox keeps the New Tab. No browser is launched. +- `browser/override`: three Chrome MV3 cases opening `chrome://newtab`, `chrome://bookmarks`, and `chrome://history`. They verify that Chrome serves the extension page, React and Vanilla rendering, CSS, state/events, custom `as` and `htmlDir` naming, and a View chunk shared with a Page. - `browser/offscreen/service`: one Chrome MV3 round trip from background through Offscreen to a registered background service. - `browser/content/entrypoint-assets`: one Chrome MV3 case and two Firefox cases (MV2 and MV3) using the same application and probe assertions. They cover current asset getters, rejecting the full-map getter outside background, common chunks, dynamic imports, CSS/SVG resource URLs, world separation, and top/child frames. The Chrome case also reads the full-map readiness flag in background. - `browser/content/isolation-shadow`: production Shadow DOM coverage in Chrome MV3 and Firefox MV2/MV3. Two shadow entrypoints and one ordinary entrypoint verify file-backed initial/lazy/shared CSS, independent runtime registries, strict CSP, local fonts declared with CSS `@font-face`, watch-driven remount, top documents, and child iframes. The shared CSS remains manifest CSS for the ordinary consumer and a web accessible resource for the shadow consumers. diff --git a/tests/integration/browser/override/README.md b/tests/integration/browser/override/README.md new file mode 100644 index 00000000..722d0705 --- /dev/null +++ b/tests/integration/browser/override/README.md @@ -0,0 +1,18 @@ +# Override browser fixtures + +See the [integration guide](../../README.md) for editor preparation, dependency setup, and test isolation. + +Run only the override browser cases from the repository root: + +```bash +npm run test -- tests/integration/browser/override/override.integration.test.ts --runInBand +``` + +- `newtab/src/newtab/index.tsx` uses React state with custom `as` and `htmlDir` values. The independent `help.page.tsx` shares the View chunk with it. +- `bookmarks/src/bookmarks/index.ts` and `history/src/history/index.ts` use DOM rendering and event handling with default output names. + +Each entrypoint keeps its `styles.css` in the same directory. Chromium accepts a single overridden page per extension, so every page has its own application. + +The cases navigate a real Chrome MV3 tab to `chrome://newtab`, `chrome://bookmarks`, and `chrome://history`. They check the exact `chrome_url_overrides` object, that the loaded document is the extension page, CSS application, state changes, and runtime errors. Expected pages are `ui/dashboard.newtab.html`, `bookmarks.html`, and `history.html`. + +The browser support matrix and the competing-entrypoints failure live separately in `tests/integration/build/override` and run without Chrome through `npm run test:integration:build`. diff --git a/tests/integration/browser/override/bookmarks/adnbn.config.ts b/tests/integration/browser/override/bookmarks/adnbn.config.ts new file mode 100644 index 00000000..8e7d158b --- /dev/null +++ b/tests/integration/browser/override/bookmarks/adnbn.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Bookmarks Override Integration", + description: "Chrome MV3 bookmarks page using the Vanilla view adapter.", + version: "1.0.0", + jsFilename: "[name].js", + cssFilename: "[name].css", +}); diff --git a/tests/integration/browser/override/bookmarks/package.json b/tests/integration/browser/override/bookmarks/package.json new file mode 100644 index 00000000..f08091e0 --- /dev/null +++ b/tests/integration/browser/override/bookmarks/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-override-bookmarks-vanilla", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../.." + } +} diff --git a/tests/integration/browser/override/bookmarks/src/bookmarks/index.ts b/tests/integration/browser/override/bookmarks/src/bookmarks/index.ts new file mode 100644 index 00000000..ebaa6ff0 --- /dev/null +++ b/tests/integration/browser/override/bookmarks/src/bookmarks/index.ts @@ -0,0 +1,30 @@ +import {defineBookmarks} from "adnbn"; + +import "./styles.css"; + +export default defineBookmarks({ + title: "Vanilla Bookmarks", + render() { + const main = document.createElement("main"); + main.dataset.testid = "override"; + main.dataset.adapter = "vanilla"; + + const heading = document.createElement("h1"); + heading.textContent = "Vanilla bookmarks"; + + const count = document.createElement("output"); + count.dataset.testid = "count"; + count.textContent = "0"; + + const increment = document.createElement("button"); + increment.dataset.testid = "increment"; + increment.textContent = "Increment"; + increment.addEventListener("click", () => { + count.textContent = String(Number(count.textContent) + 1); + }); + + main.append(heading, count, increment); + + return main; + }, +}); diff --git a/tests/integration/browser/override/bookmarks/src/bookmarks/styles.css b/tests/integration/browser/override/bookmarks/src/bookmarks/styles.css new file mode 100644 index 00000000..be3e27f0 --- /dev/null +++ b/tests/integration/browser/override/bookmarks/src/bookmarks/styles.css @@ -0,0 +1,3 @@ +[data-testid="override"] { + color: rgb(31, 78, 121); +} diff --git a/tests/integration/browser/override/bookmarks/tsconfig.json b/tests/integration/browser/override/bookmarks/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/browser/override/bookmarks/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/browser/override/history/adnbn.config.ts b/tests/integration/browser/override/history/adnbn.config.ts new file mode 100644 index 00000000..3298ab7d --- /dev/null +++ b/tests/integration/browser/override/history/adnbn.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "History Override Integration", + description: "Chrome MV3 history page using the Vanilla view adapter.", + version: "1.0.0", + jsFilename: "[name].js", + cssFilename: "[name].css", +}); diff --git a/tests/integration/browser/override/history/package.json b/tests/integration/browser/override/history/package.json new file mode 100644 index 00000000..7510c50b --- /dev/null +++ b/tests/integration/browser/override/history/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-override-history-vanilla", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../.." + } +} diff --git a/tests/integration/browser/override/history/src/history/index.ts b/tests/integration/browser/override/history/src/history/index.ts new file mode 100644 index 00000000..7a722285 --- /dev/null +++ b/tests/integration/browser/override/history/src/history/index.ts @@ -0,0 +1,30 @@ +import {defineHistory} from "adnbn"; + +import "./styles.css"; + +export default defineHistory({ + title: "Vanilla History", + render() { + const main = document.createElement("main"); + main.dataset.testid = "override"; + main.dataset.adapter = "vanilla"; + + const heading = document.createElement("h1"); + heading.textContent = "Vanilla history"; + + const count = document.createElement("output"); + count.dataset.testid = "count"; + count.textContent = "0"; + + const increment = document.createElement("button"); + increment.dataset.testid = "increment"; + increment.textContent = "Increment"; + increment.addEventListener("click", () => { + count.textContent = String(Number(count.textContent) + 1); + }); + + main.append(heading, count, increment); + + return main; + }, +}); diff --git a/tests/integration/browser/override/history/src/history/styles.css b/tests/integration/browser/override/history/src/history/styles.css new file mode 100644 index 00000000..be3e27f0 --- /dev/null +++ b/tests/integration/browser/override/history/src/history/styles.css @@ -0,0 +1,3 @@ +[data-testid="override"] { + color: rgb(31, 78, 121); +} diff --git a/tests/integration/browser/override/history/tsconfig.json b/tests/integration/browser/override/history/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/browser/override/history/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/browser/override/newtab/adnbn.config.ts b/tests/integration/browser/override/newtab/adnbn.config.ts new file mode 100644 index 00000000..039fcfa0 --- /dev/null +++ b/tests/integration/browser/override/newtab/adnbn.config.ts @@ -0,0 +1,10 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "New Tab Override Integration", + description: "Chrome MV3 new tab page using the React view adapter.", + version: "1.0.0", + htmlDir: "ui", + jsFilename: "[name].js", + cssFilename: "[name].css", +}); diff --git a/tests/integration/browser/override/newtab/package.json b/tests/integration/browser/override/newtab/package.json new file mode 100644 index 00000000..22a82519 --- /dev/null +++ b/tests/integration/browser/override/newtab/package.json @@ -0,0 +1,12 @@ +{ + "name": "adnbn-test-override-newtab-react", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../..", + "react": "*", + "react-dom": "*", + "scheduler": "*" + } +} diff --git a/tests/integration/browser/override/newtab/src/help.page.tsx b/tests/integration/browser/override/newtab/src/help.page.tsx new file mode 100644 index 00000000..2eea4bc0 --- /dev/null +++ b/tests/integration/browser/override/newtab/src/help.page.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import {definePage} from "adnbn"; + +export default definePage({ + title: "New Tab Help", + render() { + return
New tab help
; + }, +}); diff --git a/tests/integration/browser/override/newtab/src/newtab/index.tsx b/tests/integration/browser/override/newtab/src/newtab/index.tsx new file mode 100644 index 00000000..a6436806 --- /dev/null +++ b/tests/integration/browser/override/newtab/src/newtab/index.tsx @@ -0,0 +1,22 @@ +import React, {useState} from "react"; +import {defineNewtab} from "adnbn"; + +import "./styles.css"; + +export default defineNewtab({ + as: "dashboard", + title: "React New Tab", + render() { + const [count, setCount] = useState(0); + + return ( +
+

React new tab

+ {count} + +
+ ); + }, +}); diff --git a/tests/integration/browser/override/newtab/src/newtab/styles.css b/tests/integration/browser/override/newtab/src/newtab/styles.css new file mode 100644 index 00000000..be3e27f0 --- /dev/null +++ b/tests/integration/browser/override/newtab/src/newtab/styles.css @@ -0,0 +1,3 @@ +[data-testid="override"] { + color: rgb(31, 78, 121); +} diff --git a/tests/integration/browser/override/newtab/tsconfig.json b/tests/integration/browser/override/newtab/tsconfig.json new file mode 100644 index 00000000..a732df41 --- /dev/null +++ b/tests/integration/browser/override/newtab/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "./.adnbn/tsconfig.json", + "compilerOptions": { + "jsx": "react" + } +} diff --git a/tests/integration/browser/override/override.integration.test.ts b/tests/integration/browser/override/override.integration.test.ts new file mode 100644 index 00000000..40de93d6 --- /dev/null +++ b/tests/integration/browser/override/override.integration.test.ts @@ -0,0 +1,82 @@ +import {readFile} from "fs/promises"; +import path from "path"; + +import {waitFor} from "../utils/browser"; +import {startBrowserSession} from "../utils/session"; +import {createIntegrationFixture, type IntegrationFixture} from "../../utils/fixture"; + +const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); + +jest.setTimeout(90_000); + +const newtab = "ui/dashboard.newtab.html"; + +test.each([ + // The New Tab application also ships a Help page, so both views must load the shared View chunk. + {page: "newtab", adapter: "react", html: newtab, title: "React New Tab", shared: [newtab, "ui/help.html"]}, + {page: "bookmarks", adapter: "vanilla", html: "bookmarks.html", title: "Vanilla Bookmarks", shared: []}, + {page: "history", adapter: "vanilla", html: "history.html", title: "Vanilla History", shared: []}, +])("Chrome MV3 replaces chrome://$page with the $adapter entrypoint", async ({page, adapter, html, title, shared}) => { + let fixture: IntegrationFixture | undefined; + let session: Awaited> | undefined; + + try { + fixture = await createIntegrationFixture(rootDir, path.join(__dirname, page)); + + const extensionDir = await fixture.build(); + const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); + + expect(manifest.manifest_version).toBe(3); + expect(manifest.chrome_url_overrides).toEqual({[page]: html}); + + for (const view of shared) { + expect(await readFile(path.join(extensionDir, view), "utf8")).toContain("common.view.js"); + } + + session = await startBrowserSession("chrome", rootDir, extensionDir); + + const {evaluate} = session; + + await session.navigate(`chrome://${page}/`); + + const rendered = await waitFor( + async () => { + return ( + (await evaluate(`(() => { + const root = document.querySelector('[data-testid="override"]'); + + if (!root) { + return null; + } + + return { + adapter: root.dataset.adapter, + title: document.title, + count: root.querySelector('[data-testid="count"]')?.textContent, + color: getComputedStyle(root).color, + extensionPage: location.href === chrome.runtime.getURL(${JSON.stringify(html)}), + }; + })()`)) ?? undefined + ); + }, + 15_000, + `chrome://${page} to render the extension page` + ); + + expect(rendered).toEqual({adapter, title, count: "0", color: "rgb(31, 78, 121)", extensionPage: true}); + + await evaluate("document.querySelector('[data-testid=increment]').click()"); + + const count = await waitFor(async () => { + const value = await evaluate("document.querySelector('[data-testid=count]').textContent"); + + return value === "1" ? value : undefined; + }); + + expect(count).toBe("1"); + expect(session.errors).toEqual([]); + } finally { + await session?.close(); + await fixture?.dispose(); + } +}); diff --git a/tests/integration/build/override/bookmarks/adnbn.config.ts b/tests/integration/build/override/bookmarks/adnbn.config.ts new file mode 100644 index 00000000..fe0eef49 --- /dev/null +++ b/tests/integration/build/override/bookmarks/adnbn.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Bookmarks Override Integration", + description: "Build fixture replacing the browser bookmarks page.", + version: "1.0.0", +}); diff --git a/tests/integration/build/override/bookmarks/package.json b/tests/integration/build/override/bookmarks/package.json new file mode 100644 index 00000000..08478bfc --- /dev/null +++ b/tests/integration/build/override/bookmarks/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-override-bookmarks", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../.." + } +} diff --git a/tests/integration/build/override/bookmarks/src/bookmarks.ts b/tests/integration/build/override/bookmarks/src/bookmarks.ts new file mode 100644 index 00000000..5bb9a9b9 --- /dev/null +++ b/tests/integration/build/override/bookmarks/src/bookmarks.ts @@ -0,0 +1,7 @@ +import {defineBookmarks} from "adnbn"; + +export default defineBookmarks({ + title: "Custom bookmarks", + csp: {sources: {connect: ["https://bookmarks.example.com"]}}, + render: "
Custom bookmarks
", +}); diff --git a/tests/integration/build/override/bookmarks/tsconfig.json b/tests/integration/build/override/bookmarks/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/build/override/bookmarks/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/build/override/history/adnbn.config.ts b/tests/integration/build/override/history/adnbn.config.ts new file mode 100644 index 00000000..45b03b06 --- /dev/null +++ b/tests/integration/build/override/history/adnbn.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "History Override Integration", + description: "Build fixture replacing the browser history page.", + version: "1.0.0", +}); diff --git a/tests/integration/build/override/history/package.json b/tests/integration/build/override/history/package.json new file mode 100644 index 00000000..2c43484a --- /dev/null +++ b/tests/integration/build/override/history/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-override-history", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../.." + } +} diff --git a/tests/integration/build/override/history/src/history.ts b/tests/integration/build/override/history/src/history.ts new file mode 100644 index 00000000..befe68a0 --- /dev/null +++ b/tests/integration/build/override/history/src/history.ts @@ -0,0 +1,7 @@ +import {defineHistory} from "adnbn"; + +export default defineHistory({ + title: "Custom history", + csp: {sources: {connect: ["https://history.example.com"]}}, + render: "
Custom history
", +}); diff --git a/tests/integration/build/override/history/tsconfig.json b/tests/integration/build/override/history/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/build/override/history/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/build/override/newtab/adnbn.config.ts b/tests/integration/build/override/newtab/adnbn.config.ts new file mode 100644 index 00000000..fb8e8feb --- /dev/null +++ b/tests/integration/build/override/newtab/adnbn.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "New Tab Override Integration", + description: "Build fixture replacing the browser new tab page.", + version: "1.0.0", +}); diff --git a/tests/integration/build/override/newtab/package.json b/tests/integration/build/override/newtab/package.json new file mode 100644 index 00000000..299a39d2 --- /dev/null +++ b/tests/integration/build/override/newtab/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-override-newtab", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../.." + } +} diff --git a/tests/integration/build/override/newtab/src/newtab.ts b/tests/integration/build/override/newtab/src/newtab.ts new file mode 100644 index 00000000..8012c26b --- /dev/null +++ b/tests/integration/build/override/newtab/src/newtab.ts @@ -0,0 +1,7 @@ +import {defineNewtab} from "adnbn"; + +export default defineNewtab({ + title: "Custom new tab", + csp: {sources: {connect: ["https://newtab.example.com"]}}, + render: "
Custom new tab
", +}); diff --git a/tests/integration/build/override/newtab/tsconfig.json b/tests/integration/build/override/newtab/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/build/override/newtab/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/build/override/override.integration.test.ts b/tests/integration/build/override/override.integration.test.ts new file mode 100644 index 00000000..5ce1c697 --- /dev/null +++ b/tests/integration/build/override/override.integration.test.ts @@ -0,0 +1,109 @@ +import {readdir, readFile, writeFile} from "fs/promises"; +import path from "path"; + +import {createIntegrationFixture, type IntegrationFixture} from "../../utils/fixture"; + +const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); + +jest.setTimeout(90_000); + +const readManifest = async (extensionDir: string) => { + return JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); +}; + +/** Adds the History entrypoint to a copy of the New Tab application, so two overrides compete. */ +const createCompetingFixture = async (): Promise => { + const fixture = await createIntegrationFixture(rootDir, path.join(__dirname, "newtab")); + + try { + await writeFile( + path.join(fixture.directory, "src", "history.ts"), + await readFile(path.join(__dirname, "history", "src", "history.ts")) + ); + } catch (error) { + await fixture.dispose(); + + throw error; + } + + return fixture; +}; + +describe.each([ + {page: "newtab", supported: ["chrome", "edge", "firefox", "safari"], unsupported: ["opera"]}, + {page: "bookmarks", supported: ["chrome", "edge"], unsupported: ["firefox", "safari", "opera"]}, + {page: "history", supported: ["chrome", "edge"], unsupported: ["firefox", "safari", "opera"]}, +])("$page override", ({page, supported, unsupported}) => { + const fixtureDir = path.join(__dirname, page); + const source = `https://${page}.example.com`; + + describe.each(supported)("%s", browser => { + test.each([2, 3] as const)("MV%s build replaces the browser page", async manifestVersion => { + const fixture = await createIntegrationFixture(rootDir, fixtureDir); + + try { + const extensionDir = await fixture.build({browser, manifestVersion}); + const manifest = await readManifest(extensionDir); + const html = await readFile(path.join(extensionDir, `${page}.html`), "utf8"); + + expect(manifest.manifest_version).toBe(manifestVersion); + expect(manifest.chrome_url_overrides).toEqual({[page]: `${page}.html`}); + expect(JSON.stringify(manifest.content_security_policy)).toContain(source); + expect(html).toContain("Custom"); + expect(html).toContain("<script"); + } finally { + await fixture.dispose(); + } + }); + }); + + describe.each(unsupported)("%s", browser => { + test.each([2, 3] as const)("MV%s build skips the unsupported page", async manifestVersion => { + const fixture = await createIntegrationFixture(rootDir, fixtureDir); + + try { + const extensionDir = await fixture.build({browser, manifestVersion}); + const manifest = await readManifest(extensionDir); + + expect(manifest.manifest_version).toBe(manifestVersion); + expect(manifest).not.toHaveProperty("chrome_url_overrides"); + expect(JSON.stringify(manifest)).not.toContain(source); + expect(await readdir(extensionDir)).not.toContain(`${page}.html`); + } finally { + await fixture.dispose(); + } + }); + }); +}); + +describe("competing overrides", () => { + test.each(["chrome", "edge"])("%s build fails and names both entrypoints", async browser => { + const fixture = await createCompetingFixture(); + + try { + const build = fixture.build({browser}); + + await expect(build).rejects.toThrow( + `An extension can override only one browser page, but app "myapp" enables 2 override entrypoints for ${browser}:` + ); + await expect(build).rejects.toThrow(" - newtab: src/newtab.ts"); + await expect(build).rejects.toThrow(" - history: src/history.ts"); + } finally { + await fixture.dispose(); + } + }); + + test("firefox build keeps the new tab because it does not support the history page", async () => { + const fixture = await createCompetingFixture(); + + try { + const extensionDir = await fixture.build({browser: "firefox"}); + const manifest = await readManifest(extensionDir); + + expect(manifest.chrome_url_overrides).toEqual({newtab: "newtab.html"}); + expect(await readdir(extensionDir)).not.toContain("history.html"); + } finally { + await fixture.dispose(); + } + }); +}); From 512076c2ed8ef9696983b5a9ae1e105083e816a1 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:28:44 +0300 Subject: [PATCH 02/28] test(locale): separate runtime and type contract checks --- src/locale/tests/language-names.test.ts | 15 ++ src/types/locale.test.ts | 153 ------------------ .../types/fixtures/locale/substitutions.ts | 40 +++++ .../types/locale.integration.test.ts | 47 ++++++ 4 files changed, 102 insertions(+), 153 deletions(-) create mode 100644 src/locale/tests/language-names.test.ts delete mode 100644 src/types/locale.test.ts create mode 100644 tests/integration/types/fixtures/locale/substitutions.ts create mode 100644 tests/integration/types/locale.integration.test.ts diff --git a/src/locale/tests/language-names.test.ts b/src/locale/tests/language-names.test.ts new file mode 100644 index 00000000..4c24111d --- /dev/null +++ b/src/locale/tests/language-names.test.ts @@ -0,0 +1,15 @@ +import {Language, LanguageNames} from "@typing/locale"; + +describe("LanguageNames", () => { + test("has a nonempty native title for every supported language", () => { + expect(Object.keys(LanguageNames).sort()).toEqual(Object.values(Language).sort()); + expect(Object.values(LanguageNames).every(name => name.trim().length > 0)).toBe(true); + expect(LanguageNames[Language.PortugueseBrazil]).toBe("Português (Brasil)"); + expect(LanguageNames[Language.PortuguesePortugal]).toBe("Português (Portugal)"); + expect(LanguageNames[Language.ChineseChina]).toBe("中文(简体)"); + expect(LanguageNames[Language.ChineseTaiwan]).toBe("中文(繁體)"); + expect(LanguageNames[Language.EnglishAustralia]).toBe("English (Australia)"); + expect(LanguageNames[Language.EnglishUSA]).toBe("English (United States)"); + expect(LanguageNames[Language.SpanishLatinAmericaAndCaribbean]).toBe("Español (Latinoamérica y el Caribe)"); + }); +}); diff --git a/src/types/locale.test.ts b/src/types/locale.test.ts deleted file mode 100644 index f108756e..00000000 --- a/src/types/locale.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import path from "path"; -import ts from "typescript"; - -import {Language, LanguageNames} from "./locale"; - -describe("LanguageNames", () => { - test("has a nonempty native title for every supported language", () => { - expect(Object.keys(LanguageNames).sort()).toEqual(Object.values(Language).sort()); - expect(Object.values(LanguageNames).every(name => name.trim().length > 0)).toBe(true); - expect(LanguageNames[Language.PortugueseBrazil]).toBe("Português (Brasil)"); - expect(LanguageNames[Language.PortuguesePortugal]).toBe("Português (Portugal)"); - expect(LanguageNames[Language.ChineseChina]).toBe("中文(简体)"); - expect(LanguageNames[Language.ChineseTaiwan]).toBe("中文(繁體)"); - expect(LanguageNames[Language.EnglishAustralia]).toBe("English (Australia)"); - expect(LanguageNames[Language.EnglishUSA]).toBe("English (United States)"); - expect(LanguageNames[Language.SpanishLatinAmericaAndCaribbean]).toBe("Español (Latinoamérica y el Caribe)"); - }); -}); - -const normalizeFilename = (filename: string): string => { - return path.normalize(filename).replaceAll("\\", "/").toLowerCase(); -}; - -const typecheck = (source: string, filename = path.join(__dirname, "__locale-type-test.ts")): string[] => { - const normalizedFilename = normalizeFilename(filename); - const isTestFilename = (file: string): boolean => normalizeFilename(file) === normalizedFilename; - const options: ts.CompilerOptions = { - module: ts.ModuleKind.ESNext, - moduleResolution: ts.ModuleResolutionKind.Bundler, - noEmit: true, - skipLibCheck: true, - strict: true, - target: ts.ScriptTarget.ESNext, - types: [], - }; - - const host = ts.createCompilerHost(options); - const getSourceFile = host.getSourceFile.bind(host); - const readFile = host.readFile.bind(host); - const fileExists = host.fileExists.bind(host); - - host.getSourceFile = (file, languageVersion, onError, shouldCreateNewSourceFile) => { - if (isTestFilename(file)) { - return ts.createSourceFile(file, source, languageVersion, true); - } - - return getSourceFile(file, languageVersion, onError, shouldCreateNewSourceFile); - }; - - host.readFile = file => (isTestFilename(file) ? source : readFile(file)); - host.fileExists = file => isTestFilename(file) || fileExists(file); - - const program = ts.createProgram([filename], options, host); - - return ts - .getPreEmitDiagnostics(program) - .filter(diagnostic => { - return diagnostic.file?.text === source; - }) - .map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); -}; - -const prelude = ` -import type {LocaleNonPluralKeys, LocalePluralKeys, LocaleSubstitutionArgs} from "./locale"; - -interface Structure { - "app.name": {plural: false; substitutions: []}; - "app.greeting": {plural: false; substitutions: ["name"]}; - "app.cars": {plural: true; substitutions: ["count"]}; -} - -declare function trans<K extends LocaleNonPluralKeys<Structure>>( - key: K, - ...args: LocaleSubstitutionArgs<Structure, K> -): string; - -declare function choice<K extends LocalePluralKeys<Structure>>( - key: K, - count: number, - ...args: LocaleSubstitutionArgs<Structure, K> -): string; -`; - -describe("locale types", () => { - test("captures diagnostics for Windows-style test filenames", () => { - const diagnostics = typecheck( - ` -declare function takesString(value: string): void; - -takesString(123); -`, - path.join(__dirname, "__locale-type-test.ts").split(path.sep).join("\\") - ).join("\n"); - - expect(diagnostics).toMatch("Argument of type 'number' is not assignable to parameter of type 'string'."); - }); - - test("accept valid locale calls", () => { - expect( - typecheck(` -${prelude} - -trans("app.name"); -trans("app.greeting", {name: "Alice"}); -choice("app.cars", 2, {count: 2}); -`) - ).toEqual([]); - }); - - test("reject substitutions for keys without placeholders", () => { - const diagnostics = typecheck(` -${prelude} - -trans("app.name", {name: "Alice"}); -`).join("\n"); - - expect(diagnostics).toMatch("Expected 1 arguments, but got 2."); - }); - - test("require all substitutions for keys with placeholders", () => { - const diagnostics = typecheck(` -${prelude} - -trans("app.greeting"); -trans("app.greeting", {}); -`).join("\n"); - - expect(diagnostics).toMatch("Expected 2 arguments, but got 1."); - expect(diagnostics).toMatch("Property 'name' is missing"); - }); - - test("reject unknown substitution keys", () => { - const diagnostics = typecheck(` -${prelude} - -trans("app.greeting", {name: "Alice", extra: "bad"}); -`).join("\n"); - - expect(diagnostics).toMatch("'extra' does not exist"); - }); - - test("keeps plural and non-plural locale keys separate", () => { - const diagnostics = typecheck(` -${prelude} - -trans("app.cars", {count: 2}); -choice("app.greeting", 1, {name: "Alice"}); -`).join("\n"); - - expect(diagnostics).toMatch("Argument of type '\"app.cars\"' is not assignable"); - expect(diagnostics).toMatch("Argument of type '\"app.greeting\"' is not assignable"); - }); -}); diff --git a/tests/integration/types/fixtures/locale/substitutions.ts b/tests/integration/types/fixtures/locale/substitutions.ts new file mode 100644 index 00000000..98a5201e --- /dev/null +++ b/tests/integration/types/fixtures/locale/substitutions.ts @@ -0,0 +1,40 @@ +import type {LocaleNonPluralKeys, LocalePluralKeys, LocaleSubstitutionArgs} from "adnbn/locale"; + +interface Structure { + "app.name": {plural: false; substitutions: []}; + "app.greeting": {plural: false; substitutions: ["name"]}; + "app.cars": {plural: true; substitutions: ["count"]}; +} + +declare function trans<K extends LocaleNonPluralKeys<Structure>>( + key: K, + ...args: LocaleSubstitutionArgs<Structure, K> +): string; + +declare function choice<K extends LocalePluralKeys<Structure>>( + key: K, + count: number, + ...args: LocaleSubstitutionArgs<Structure, K> +): string; + +trans("app.name"); +trans("app.greeting", {name: "Alice"}); +choice("app.cars", 2, {count: 2}); + +// @ts-expect-error keys without placeholders do not accept substitutions +trans("app.name", {name: "Alice"}); + +// @ts-expect-error keys with placeholders require a substitutions argument +trans("app.greeting"); + +// @ts-expect-error all declared placeholders must be supplied +trans("app.greeting", {}); + +// @ts-expect-error unknown substitution names are rejected +trans("app.greeting", {name: "Alice", extra: "bad"}); + +// @ts-expect-error plural messages cannot be used as ordinary messages +trans("app.cars", {count: 2}); + +// @ts-expect-error ordinary messages cannot be used as plural messages +choice("app.greeting", 1, {name: "Alice"}); diff --git a/tests/integration/types/locale.integration.test.ts b/tests/integration/types/locale.integration.test.ts new file mode 100644 index 00000000..2e009de5 --- /dev/null +++ b/tests/integration/types/locale.integration.test.ts @@ -0,0 +1,47 @@ +import path from "path"; +import ts from "typescript"; + +describe("locale contracts", () => { + const fixture = path.join(__dirname, "fixtures/locale/substitutions.ts"); + + test.each(["source", "package"])( + "checks substitution arguments and plural keys through the %s API", + mode => { + const config = ts.readConfigFile(path.join(ADNBN_TEST_ROOT, "tsconfig.json"), ts.sys.readFile); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, ADNBN_TEST_ROOT); + expect(config.error).toBeUndefined(); + expect(parsed.errors).toEqual([]); + + const options: ts.CompilerOptions = { + ...(mode === "source" ? parsed.options : {}), + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ESNext, + strict: true, + noEmit: true, + skipLibCheck: mode === "source", + types: ["node", "chrome"], + }; + const host = ts.createCompilerHost(options); + const program = ts.createProgram([fixture], options, host); + const diagnostics = ts.getPreEmitDiagnostics(program).map(diagnostic => { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); + const position = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0); + + return `${diagnostic.file?.fileName ?? "compiler"}:${(position?.line ?? 0) + 1}: ${message}`; + }); + + expect(diagnostics).toEqual([]); + + const apiFile = ts.resolveModuleName("adnbn/locale", fixture, options, host).resolvedModule + ?.resolvedFileName; + + expect(apiFile).toBe( + path + .join(ADNBN_TEST_ROOT, mode === "source" ? "src/locale/index.ts" : "dist/locale/index.d.ts") + .replace(/\\/g, "/") + ); + }, + 30_000 + ); +}); From 11529f8674c8cee71416f58aaec7276b95faad75 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:39:29 +0300 Subject: [PATCH 03/28] test(entrypoint): cover option reader export shapes by functionality Replace the background-named suite, registered through a side-effect import, with a table of export shapes in the OptionFile tests and rename its fixtures to export-shapes. Cover the shapes that were tested only through parsers or not at all: default-over-named precedence with an explicit false, property selection, foreign and locally declared definitions, alternative definitions, generic calls with type arguments, and a call behind satisfies. Use several framework definitions so the suite does not depend on one entrypoint. --- src/cli/entrypoint/file/OptionFile.test.ts | 125 +++++++++++++++++- src/cli/entrypoint/file/tests/background.ts | 111 ---------------- .../export-shapes/alternative-definition.ts | 8 ++ .../config-for-test.ts | 0 .../default-function.ts | 0 .../default-object-assertion.ts | 0 .../default-object-satisfies.ts | 0 .../default-object.ts | 0 .../export-shapes/default-over-named.ts | 15 +++ .../definition-only.ts | 0 .../export-shapes/definition-satisfies.ts | 7 + .../definition-shorthand.ts | 0 .../definition-type-arguments.ts | 11 ++ .../definition-with-named-exports.ts | 0 .../export-shapes/foreign-definition.ts | 9 ++ .../export-shapes/local-definition.ts | 10 ++ 16 files changed, 183 insertions(+), 113 deletions(-) delete mode 100644 src/cli/entrypoint/file/tests/background.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/export-shapes/alternative-definition.ts rename src/cli/entrypoint/file/tests/fixtures/{background => export-shapes}/config-for-test.ts (100%) rename src/cli/entrypoint/file/tests/fixtures/{background => export-shapes}/default-function.ts (100%) rename src/cli/entrypoint/file/tests/fixtures/{background => export-shapes}/default-object-assertion.ts (100%) rename src/cli/entrypoint/file/tests/fixtures/{background => export-shapes}/default-object-satisfies.ts (100%) rename src/cli/entrypoint/file/tests/fixtures/{background => export-shapes}/default-object.ts (100%) create mode 100644 src/cli/entrypoint/file/tests/fixtures/export-shapes/default-over-named.ts rename src/cli/entrypoint/file/tests/fixtures/{background => export-shapes}/definition-only.ts (100%) create mode 100644 src/cli/entrypoint/file/tests/fixtures/export-shapes/definition-satisfies.ts rename src/cli/entrypoint/file/tests/fixtures/{background => export-shapes}/definition-shorthand.ts (100%) create mode 100644 src/cli/entrypoint/file/tests/fixtures/export-shapes/definition-type-arguments.ts rename src/cli/entrypoint/file/tests/fixtures/{background => export-shapes}/definition-with-named-exports.ts (100%) create mode 100644 src/cli/entrypoint/file/tests/fixtures/export-shapes/foreign-definition.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/export-shapes/local-definition.ts diff --git a/src/cli/entrypoint/file/OptionFile.test.ts b/src/cli/entrypoint/file/OptionFile.test.ts index 9b85fc9c..820edb25 100644 --- a/src/cli/entrypoint/file/OptionFile.test.ts +++ b/src/cli/entrypoint/file/OptionFile.test.ts @@ -1,5 +1,3 @@ -import "./tests/background"; - import path from "path"; import OptionFile from "./OptionFile"; @@ -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({ diff --git a/src/cli/entrypoint/file/tests/background.ts b/src/cli/entrypoint/file/tests/background.ts deleted file mode 100644 index bd1b5b4d..00000000 --- a/src/cli/entrypoint/file/tests/background.ts +++ /dev/null @@ -1,111 +0,0 @@ -import path from "path"; -import OptionFile from "../OptionFile"; - -const fixtures = path.resolve(__dirname, "fixtures"); - -describe("background", () => { - test("background with definition function", () => { - const filename = path.join(fixtures, "background", "definition-only.ts"); - - const options = OptionFile.make(filename) - .setProperties(["persistent", "includeBrowser"]) - .setDefinition("defineBackground") - .getOptions(); - - expect(options).toEqual({ - persistent: true, - includeBrowser: ["firefox"], - }); - }); - - test("background with default function and export properties", () => { - const filename = path.join(fixtures, "background", "default-function.ts"); - - const options = OptionFile.make(filename) - .setProperties(["persistent", "excludeBrowser", "excludeApps"]) - .setDefinition("defineBackground") - .getOptions(); - - expect(options).toEqual({ - persistent: true, - excludeBrowser: ["edge"], - excludeApps: ["my_test_app"], - }); - }); - - test("background with combined definition and export properties", () => { - const filename = path.join(fixtures, "background", "definition-with-named-exports.ts"); - - const options = OptionFile.make(filename) - .setProperties(["persistent", "excludeBrowser", "includeBrowser"]) - .setDefinition("defineBackground") - .getOptions(); - - expect(options).toEqual({ - persistent: true, - excludeBrowser: ["edge"], - includeBrowser: ["firefox"], - }); - }); - - test("background with default object and export properties", () => { - const filename = path.join(fixtures, "background", "default-object.ts"); - - const options = OptionFile.make(filename) - .setProperties(["persistent", "excludeBrowser", "includeBrowser"]) - .setDefinition("defineBackground") - .getOptions(); - - expect(options).toEqual({ - persistent: true, - excludeBrowser: ["opera"], - includeBrowser: ["chrome"], - }); - }); - - test("background with default type name", () => { - const filename = path.join(fixtures, "background", "default-object-assertion.ts"); - - const options = OptionFile.make(filename) - .setProperties(["persistent", "excludeBrowser", "includeBrowser"]) - .setDefinition("defineBackground") - .getOptions(); - - expect(options).toEqual({ - persistent: true, - excludeBrowser: ["safari"], - includeBrowser: ["chromium"], - }); - }); - - test("background with default satisfies", () => { - const filename = path.join(fixtures, "background", "default-object-satisfies.ts"); - - const options = OptionFile.make(filename) - .setProperties(["persistent", "excludeBrowser", "includeBrowser"]) - .setDefinition("defineBackground") - .getOptions(); - - expect(options).toEqual({ - persistent: true, - includeBrowser: ["chromium"], - }); - }); - - test("background with definition shorthand", () => { - const filename = path.join(fixtures, "background", "definition-shorthand.ts"); - - const options = OptionFile.make(filename) - .setProperties(["permissions", "persistent", "excludeBrowser", "includeBrowser", "excludeApp"]) - .setDefinition("defineBackground") - .getOptions(); - - expect(options).toEqual({ - permissions: ["storage", "tabs"], - persistent: true, - excludeBrowser: ["edge"], - includeBrowser: ["firefox"], - excludeApp: ["my_test_app"], - }); - }); -}); diff --git a/src/cli/entrypoint/file/tests/fixtures/export-shapes/alternative-definition.ts b/src/cli/entrypoint/file/tests/fixtures/export-shapes/alternative-definition.ts new file mode 100644 index 00000000..9529c587 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/export-shapes/alternative-definition.ts @@ -0,0 +1,8 @@ +import {defineExecuteActionCommand} from "adnbn"; + +export default defineExecuteActionCommand({ + defaultKey: "Ctrl+Shift+K", + execute: () => { + console.log("test command execute"); + }, +}); diff --git a/src/cli/entrypoint/file/tests/fixtures/background/config-for-test.ts b/src/cli/entrypoint/file/tests/fixtures/export-shapes/config-for-test.ts similarity index 100% rename from src/cli/entrypoint/file/tests/fixtures/background/config-for-test.ts rename to src/cli/entrypoint/file/tests/fixtures/export-shapes/config-for-test.ts diff --git a/src/cli/entrypoint/file/tests/fixtures/background/default-function.ts b/src/cli/entrypoint/file/tests/fixtures/export-shapes/default-function.ts similarity index 100% rename from src/cli/entrypoint/file/tests/fixtures/background/default-function.ts rename to src/cli/entrypoint/file/tests/fixtures/export-shapes/default-function.ts diff --git a/src/cli/entrypoint/file/tests/fixtures/background/default-object-assertion.ts b/src/cli/entrypoint/file/tests/fixtures/export-shapes/default-object-assertion.ts similarity index 100% rename from src/cli/entrypoint/file/tests/fixtures/background/default-object-assertion.ts rename to src/cli/entrypoint/file/tests/fixtures/export-shapes/default-object-assertion.ts diff --git a/src/cli/entrypoint/file/tests/fixtures/background/default-object-satisfies.ts b/src/cli/entrypoint/file/tests/fixtures/export-shapes/default-object-satisfies.ts similarity index 100% rename from src/cli/entrypoint/file/tests/fixtures/background/default-object-satisfies.ts rename to src/cli/entrypoint/file/tests/fixtures/export-shapes/default-object-satisfies.ts diff --git a/src/cli/entrypoint/file/tests/fixtures/background/default-object.ts b/src/cli/entrypoint/file/tests/fixtures/export-shapes/default-object.ts similarity index 100% rename from src/cli/entrypoint/file/tests/fixtures/background/default-object.ts rename to src/cli/entrypoint/file/tests/fixtures/export-shapes/default-object.ts diff --git a/src/cli/entrypoint/file/tests/fixtures/export-shapes/default-over-named.ts b/src/cli/entrypoint/file/tests/fixtures/export-shapes/default-over-named.ts new file mode 100644 index 00000000..6bb8fb6a --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/export-shapes/default-over-named.ts @@ -0,0 +1,15 @@ +import {defineBackground, Browser} from "adnbn"; + +export const persistent = true; + +export const includeBrowser = [Browser.Edge]; + +export const excludeBrowser = [Browser.Opera]; + +export default defineBackground({ + persistent: false, + includeBrowser: [Browser.Firefox], + main: async () => { + console.log("test background main"); + }, +}); diff --git a/src/cli/entrypoint/file/tests/fixtures/background/definition-only.ts b/src/cli/entrypoint/file/tests/fixtures/export-shapes/definition-only.ts similarity index 100% rename from src/cli/entrypoint/file/tests/fixtures/background/definition-only.ts rename to src/cli/entrypoint/file/tests/fixtures/export-shapes/definition-only.ts diff --git a/src/cli/entrypoint/file/tests/fixtures/export-shapes/definition-satisfies.ts b/src/cli/entrypoint/file/tests/fixtures/export-shapes/definition-satisfies.ts new file mode 100644 index 00000000..f8d04ec7 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/export-shapes/definition-satisfies.ts @@ -0,0 +1,7 @@ +import {definePopup, Browser, type PopupDefinition} from "adnbn"; + +export default definePopup({ + title: "Popup", + excludeBrowser: [Browser.Safari], + render: () => "Popup", +}) satisfies PopupDefinition; diff --git a/src/cli/entrypoint/file/tests/fixtures/background/definition-shorthand.ts b/src/cli/entrypoint/file/tests/fixtures/export-shapes/definition-shorthand.ts similarity index 100% rename from src/cli/entrypoint/file/tests/fixtures/background/definition-shorthand.ts rename to src/cli/entrypoint/file/tests/fixtures/export-shapes/definition-shorthand.ts diff --git a/src/cli/entrypoint/file/tests/fixtures/export-shapes/definition-type-arguments.ts b/src/cli/entrypoint/file/tests/fixtures/export-shapes/definition-type-arguments.ts new file mode 100644 index 00000000..d505eae5 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/export-shapes/definition-type-arguments.ts @@ -0,0 +1,11 @@ +import {defineService, Browser} from "adnbn"; + +interface MathApi { + sum(a: number, b: number): number; +} + +export default defineService<MathApi>({ + name: "math", + includeBrowser: [Browser.Firefox], + init: () => ({sum: (a, b) => a + b}), +}); diff --git a/src/cli/entrypoint/file/tests/fixtures/background/definition-with-named-exports.ts b/src/cli/entrypoint/file/tests/fixtures/export-shapes/definition-with-named-exports.ts similarity index 100% rename from src/cli/entrypoint/file/tests/fixtures/background/definition-with-named-exports.ts rename to src/cli/entrypoint/file/tests/fixtures/export-shapes/definition-with-named-exports.ts diff --git a/src/cli/entrypoint/file/tests/fixtures/export-shapes/foreign-definition.ts b/src/cli/entrypoint/file/tests/fixtures/export-shapes/foreign-definition.ts new file mode 100644 index 00000000..8d982c3f --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/export-shapes/foreign-definition.ts @@ -0,0 +1,9 @@ +import {defineService, Browser} from "adnbn"; + +export const excludeBrowser = [Browser.Edge]; + +export default defineService({ + persistent: true, + includeBrowser: [Browser.Firefox], + init: () => ({}), +}); diff --git a/src/cli/entrypoint/file/tests/fixtures/export-shapes/local-definition.ts b/src/cli/entrypoint/file/tests/fixtures/export-shapes/local-definition.ts new file mode 100644 index 00000000..3838194a --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/export-shapes/local-definition.ts @@ -0,0 +1,10 @@ +import {Browser} from "adnbn"; + +const defineBackground = <T>(definition: T): T => definition; + +export const excludeBrowser = [Browser.Edge]; + +export default defineBackground({ + persistent: true, + includeBrowser: [Browser.Firefox], +}); From 9a35833c5604358f333e0ed2471494b5e49f158d Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:39:56 +0300 Subject: [PATCH 04/28] test(entrypoint): cover every parser through its own definition Add real-fixture tests for the background, service, popup, sidebar and offscreen parsers and for the common options owned by AbstractParser. Cover the full command options and the execute-action command name. Keep the override parsers to their own concerns, drop the named-export scenario already covered by the option reader, and store parser fixtures by entrypoint. --- .../entrypoint/parser/AbstractParser.test.ts | 55 +++++++++++++++++++ .../parser/BackgroundParser.test.ts | 35 ++++++++++++ .../entrypoint/parser/CommandParser.test.ts | 35 ++++++++++++ .../entrypoint/parser/OffscreenParser.test.ts | 46 ++++++++++++++++ src/cli/entrypoint/parser/PopupParser.test.ts | 35 ++++++++++++ .../entrypoint/parser/ServiceParser.test.ts | 44 +++++++++++++++ .../entrypoint/parser/SidebarParser.test.ts | 35 ++++++++++++ .../parser/override-parsers.test.ts | 9 +-- .../fixtures/background/invalid/persistent.ts | 3 + .../background/options/full/background.ts | 13 +++++ .../options}/foreign-definition/bookmarks.ts | 0 .../options}/full/bookmarks.ts | 0 .../options/execute-action/open.command.ts | 6 ++ .../command/options/full/save.command.ts | 16 ++++++ .../common/invalid/include-browser-unknown.ts | 3 + .../common/invalid/manifest-version.ts | 3 + .../tests/fixtures/common/options/filters.ts | 11 ++++ .../options}/foreign-definition/history.ts | 0 .../options}/full/history.ts | 0 .../options}/foreign-definition/newtab.ts | 0 .../newtab => newtab/options}/full/newtab.ts | 0 .../offscreen/contracts/init-object.ts | 9 +++ .../tests/fixtures/offscreen/invalid/name.ts | 3 + .../fixtures/offscreen/invalid/reasons.ts | 3 + .../offscreen/options/full/audio.offscreen.ts | 14 +++++ .../bookmarks/named-exports/bookmarks.ts | 4 -- .../override/history/named-exports/history.ts | 4 -- .../override/newtab/named-exports/newtab.ts | 4 -- .../tests/fixtures/popup/invalid/apply.ts | 3 + .../tests/fixtures/popup/invalid/icon.ts | 3 + .../fixtures/popup/options/full/popup.ts | 13 +++++ .../fixtures/service/contracts/init-object.ts | 9 +++ .../tests/fixtures/service/invalid/name.ts | 3 + .../service/options/full/math.service.ts | 12 ++++ .../tests/fixtures/sidebar/invalid/apply.ts | 3 + .../tests/fixtures/sidebar/invalid/icon.ts | 3 + .../fixtures/sidebar/options/full/sidebar.ts | 13 +++++ 37 files changed, 434 insertions(+), 18 deletions(-) create mode 100644 src/cli/entrypoint/parser/AbstractParser.test.ts create mode 100644 src/cli/entrypoint/parser/BackgroundParser.test.ts create mode 100644 src/cli/entrypoint/parser/OffscreenParser.test.ts create mode 100644 src/cli/entrypoint/parser/PopupParser.test.ts create mode 100644 src/cli/entrypoint/parser/ServiceParser.test.ts create mode 100644 src/cli/entrypoint/parser/SidebarParser.test.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/background/invalid/persistent.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/background/options/full/background.ts rename src/cli/entrypoint/parser/tests/fixtures/{override/bookmarks => bookmarks/options}/foreign-definition/bookmarks.ts (100%) rename src/cli/entrypoint/parser/tests/fixtures/{override/bookmarks => bookmarks/options}/full/bookmarks.ts (100%) create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/options/execute-action/open.command.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/options/full/save.command.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/common/invalid/include-browser-unknown.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/common/invalid/manifest-version.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/common/options/filters.ts rename src/cli/entrypoint/parser/tests/fixtures/{override/history => history/options}/foreign-definition/history.ts (100%) rename src/cli/entrypoint/parser/tests/fixtures/{override/history => history/options}/full/history.ts (100%) rename src/cli/entrypoint/parser/tests/fixtures/{override/newtab => newtab/options}/foreign-definition/newtab.ts (100%) rename src/cli/entrypoint/parser/tests/fixtures/{override/newtab => newtab/options}/full/newtab.ts (100%) create mode 100644 src/cli/entrypoint/parser/tests/fixtures/offscreen/contracts/init-object.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/offscreen/invalid/name.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/offscreen/invalid/reasons.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/offscreen/options/full/audio.offscreen.ts delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/named-exports/bookmarks.ts delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/override/history/named-exports/history.ts delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/override/newtab/named-exports/newtab.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/popup/invalid/apply.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/popup/invalid/icon.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/service/contracts/init-object.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/service/invalid/name.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/service/options/full/math.service.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sidebar/invalid/apply.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sidebar/invalid/icon.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts diff --git a/src/cli/entrypoint/parser/AbstractParser.test.ts b/src/cli/entrypoint/parser/AbstractParser.test.ts new file mode 100644 index 00000000..32021344 --- /dev/null +++ b/src/cli/entrypoint/parser/AbstractParser.test.ts @@ -0,0 +1,55 @@ +import path from "path"; +import {z} from "zod"; + +import AbstractParser from "./AbstractParser"; + +import type {ReadonlyConfig} from "@typing/config"; +import type {EntrypointFile, EntrypointOptions} from "@typing/entrypoint"; + +const rootDir = path.resolve(__dirname, "../../../.."); +const fixtures = path.resolve(__dirname, "tests", "fixtures", "common"); + +const config = {rootDir} as ReadonlyConfig; + +const file = (...parts: string[]): EntrypointFile => { + const filename = path.join(fixtures, ...parts); + + return {file: filename, import: filename}; +}; + +/** An entrypoint that accepts only the options shared by every entrypoint. */ +class CommonParser extends AbstractParser<EntrypointOptions> { + protected definition(): string { + return "defineBackground"; + } + + protected schema(): z.AnyZodObject { + return this.CommonPropertiesSchema; + } +} + +describe("AbstractParser", () => { + describe("common options", () => { + test("reads the build filters shared by every entrypoint", () => { + expect(new CommonParser(config).options(file("options", "filters.ts"))).toEqual({ + includeApp: ["app"], + excludeApp: ["legacy"], + includeBrowser: ["chrome", "edge"], + excludeBrowser: ["safari"], + mode: "development", + debug: false, + manifestVersion: 2, + }); + }); + + test.each([ + {name: "include-browser-unknown.ts", field: "includeBrowser"}, + {name: "manifest-version.ts", field: "manifestVersion"}, + ])("rejects an invalid $field and names the file", ({name, field}) => { + const source = file("invalid", name); + + expect(() => new CommonParser(config).options(source)).toThrow(`Invalid options ${field}`); + expect(() => new CommonParser(config).options(source)).toThrow(source.file); + }); + }); +}); diff --git a/src/cli/entrypoint/parser/BackgroundParser.test.ts b/src/cli/entrypoint/parser/BackgroundParser.test.ts new file mode 100644 index 00000000..154667f0 --- /dev/null +++ b/src/cli/entrypoint/parser/BackgroundParser.test.ts @@ -0,0 +1,35 @@ +import path from "path"; + +import BackgroundParser from "./BackgroundParser"; + +import type {ReadonlyConfig} from "@typing/config"; + +const rootDir = path.resolve(__dirname, "../../../.."); +const fixtures = path.resolve(__dirname, "tests", "fixtures", "background"); + +const parser = new BackgroundParser({rootDir} as ReadonlyConfig); + +const parseOptions = (...parts: string[]) => { + const filename = path.join(fixtures, ...parts); + + return parser.options({file: filename, import: filename}); +}; + +describe("BackgroundParser", () => { + test("parses defineBackground with persistence, permissions and build filters", () => { + expect(parseOptions("options", "full", "background.ts")).toEqual({ + persistent: true, + permissions: ["storage", "tabs"], + optionalPermissions: ["history"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], + includeApp: ["app"], + excludeBrowser: ["safari"], + mode: "production", + }); + }); + + test("rejects a non-boolean persistent value", () => { + expect(() => parseOptions("invalid", "persistent.ts")).toThrow("Invalid options persistent"); + }); +}); diff --git a/src/cli/entrypoint/parser/CommandParser.test.ts b/src/cli/entrypoint/parser/CommandParser.test.ts index 79e09023..696895a7 100644 --- a/src/cli/entrypoint/parser/CommandParser.test.ts +++ b/src/cli/entrypoint/parser/CommandParser.test.ts @@ -61,6 +61,41 @@ export default defineCommand({ `; describe("CommandParser", () => { + test("parses defineCommand with shortcuts and inherited background options from a real entrypoint file", () => { + const file = path.join(__dirname, "tests", "fixtures", "command", "options", "full", "save.command.ts"); + + expect(new CommandParser({rootDir} as ReadonlyConfig).options({file, import: file})).toEqual({ + name: "save", + description: "Save the page", + global: false, + defaultKey: "Ctrl+Shift+K", + macKey: "Command+Shift+K", + persistent: true, + permissions: ["storage", "tabs"], + optionalPermissions: ["history"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], + excludeBrowser: ["firefox"], + }); + }); + + test("names a defineExecuteActionCommand entrypoint after the browser action command", () => { + const file = path.join( + __dirname, + "tests", + "fixtures", + "command", + "options", + "execute-action", + "open.command.ts" + ); + + expect(new CommandParser({rootDir} as ReadonlyConfig).options({file, import: file})).toEqual({ + name: "_execute_action", + defaultKey: "Ctrl+Shift+O", + }); + }); + test("accepts browser command shortcuts", () => { const options = parse( command(` diff --git a/src/cli/entrypoint/parser/OffscreenParser.test.ts b/src/cli/entrypoint/parser/OffscreenParser.test.ts new file mode 100644 index 00000000..96405d72 --- /dev/null +++ b/src/cli/entrypoint/parser/OffscreenParser.test.ts @@ -0,0 +1,46 @@ +import path from "path"; + +import OffscreenParser from "./OffscreenParser"; + +import type {ReadonlyConfig} from "@typing/config"; + +const rootDir = path.resolve(__dirname, "../../../.."); +const fixtures = path.resolve(__dirname, "tests", "fixtures", "offscreen"); + +const parser = new OffscreenParser({rootDir} as ReadonlyConfig); + +const file = (...parts: string[]) => { + const filename = path.join(fixtures, ...parts); + + return {file: filename, import: filename}; +}; + +describe("OffscreenParser", () => { + describe("options", () => { + test("parses defineOffscreen with its name, reasons and inherited view and CSP options", () => { + expect(parser.options(file("options", "full", "audio.offscreen.ts"))).toEqual({ + name: "audio", + reasons: ["AUDIO_PLAYBACK", "BLOBS"], + justification: "Plays notification sounds", + as: "panel", + title: "Extension panel", + template: "./template.html", + includeBrowser: ["chrome"], + csp: {sources: {connect: ["'self'", "https://api.example.com"]}}, + links: "extra.css", + }); + }); + + test.each(["name", "reasons"])("rejects an invalid %s value", field => { + expect(() => parser.options(file("invalid", `${field}.ts`))).toThrow(`Invalid options ${field}`); + }); + }); + + describe("contract", () => { + test("extracts the API returned from init", () => { + expect(parser.contract(file("contracts", "init-object.ts"))).toBe( + "{ volume: number; play(url: string): void; }" + ); + }); + }); +}); diff --git a/src/cli/entrypoint/parser/PopupParser.test.ts b/src/cli/entrypoint/parser/PopupParser.test.ts new file mode 100644 index 00000000..9526127d --- /dev/null +++ b/src/cli/entrypoint/parser/PopupParser.test.ts @@ -0,0 +1,35 @@ +import path from "path"; + +import PopupParser from "./PopupParser"; + +import type {ReadonlyConfig} from "@typing/config"; + +const rootDir = path.resolve(__dirname, "../../../.."); +const fixtures = path.resolve(__dirname, "tests", "fixtures", "popup"); + +const parser = new PopupParser({rootDir} as ReadonlyConfig); + +const parseOptions = (...parts: string[]) => { + const filename = path.join(fixtures, ...parts); + + return parser.options({file: filename, import: filename}); +}; + +describe("PopupParser", () => { + test("parses definePopup with its icon, apply flag and inherited view and CSP options", () => { + expect(parseOptions("options", "full", "popup.ts")).toEqual({ + as: "panel", + title: "Extension panel", + template: "./template.html", + icon: "toolbar", + apply: false, + includeBrowser: ["chrome"], + csp: {sources: {connect: ["'self'", "https://api.example.com"]}}, + links: "extra.css", + }); + }); + + test.each(["icon", "apply"])("rejects an invalid %s value", field => { + expect(() => parseOptions("invalid", `${field}.ts`)).toThrow(`Invalid options ${field}`); + }); +}); diff --git a/src/cli/entrypoint/parser/ServiceParser.test.ts b/src/cli/entrypoint/parser/ServiceParser.test.ts new file mode 100644 index 00000000..69236de5 --- /dev/null +++ b/src/cli/entrypoint/parser/ServiceParser.test.ts @@ -0,0 +1,44 @@ +import path from "path"; + +import ServiceParser from "./ServiceParser"; + +import type {ReadonlyConfig} from "@typing/config"; + +const rootDir = path.resolve(__dirname, "../../../.."); +const fixtures = path.resolve(__dirname, "tests", "fixtures", "service"); + +const parser = new ServiceParser({rootDir} as ReadonlyConfig); + +const file = (...parts: string[]) => { + const filename = path.join(fixtures, ...parts); + + return {file: filename, import: filename}; +}; + +describe("ServiceParser", () => { + describe("options", () => { + test("parses defineService with its name and inherited background options", () => { + expect(parser.options(file("options", "full", "math.service.ts"))).toEqual({ + name: "math", + persistent: true, + permissions: ["storage", "tabs"], + optionalPermissions: ["history"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], + includeBrowser: ["chrome"], + }); + }); + + test("rejects a name that is not a valid identifier", () => { + expect(() => parser.options(file("invalid", "name.ts"))).toThrow("Invalid options name"); + }); + }); + + describe("contract", () => { + test("extracts the API returned from init", () => { + expect(parser.contract(file("contracts", "init-object.ts"))).toBe( + "{ version: string; sum(a: number, b: number): number; }" + ); + }); + }); +}); diff --git a/src/cli/entrypoint/parser/SidebarParser.test.ts b/src/cli/entrypoint/parser/SidebarParser.test.ts new file mode 100644 index 00000000..f43a82ea --- /dev/null +++ b/src/cli/entrypoint/parser/SidebarParser.test.ts @@ -0,0 +1,35 @@ +import path from "path"; + +import SidebarParser from "./SidebarParser"; + +import type {ReadonlyConfig} from "@typing/config"; + +const rootDir = path.resolve(__dirname, "../../../.."); +const fixtures = path.resolve(__dirname, "tests", "fixtures", "sidebar"); + +const parser = new SidebarParser({rootDir} as ReadonlyConfig); + +const parseOptions = (...parts: string[]) => { + const filename = path.join(fixtures, ...parts); + + return parser.options({file: filename, import: filename}); +}; + +describe("SidebarParser", () => { + test("parses defineSidebar with its icon, apply flag and inherited view and CSP options", () => { + expect(parseOptions("options", "full", "sidebar.ts")).toEqual({ + as: "panel", + title: "Extension panel", + template: "./template.html", + icon: "toolbar", + apply: false, + includeBrowser: ["chrome"], + csp: {sources: {connect: ["'self'", "https://api.example.com"]}}, + links: "extra.css", + }); + }); + + test.each(["icon", "apply"])("rejects an invalid %s value", field => { + expect(() => parseOptions("invalid", `${field}.ts`)).toThrow(`Invalid options ${field}`); + }); +}); diff --git a/src/cli/entrypoint/parser/override-parsers.test.ts b/src/cli/entrypoint/parser/override-parsers.test.ts index 5b83783c..7c642d2f 100644 --- a/src/cli/entrypoint/parser/override-parsers.test.ts +++ b/src/cli/entrypoint/parser/override-parsers.test.ts @@ -7,17 +7,18 @@ import NewtabParser from "./NewtabParser"; import type {ReadonlyConfig} from "@typing/config"; const rootDir = path.resolve(__dirname, "../../../.."); -const fixtures = path.resolve(__dirname, "tests", "fixtures", "override"); +const fixtures = path.resolve(__dirname, "tests", "fixtures"); const config = {rootDir} as ReadonlyConfig; +// The override parsers share one schema and differ only in the definition they recognise. describe.each([ {type: "newtab", label: "New tab", parser: new NewtabParser(config)}, {type: "bookmarks", label: "Bookmarks", parser: new BookmarksParser(config)}, {type: "history", label: "History", parser: new HistoryParser(config)}, ])("$type parser", ({type, label, parser}) => { const parseOptions = (scenario: string) => { - const filename = path.join(fixtures, type, scenario, `${type}.ts`); + const filename = path.join(fixtures, type, "options", scenario, `${type}.ts`); return parser.options({file: filename, import: filename}); }; @@ -52,10 +53,6 @@ describe.each([ }); }); - test("reads named exports alongside a default render function without options-only settings", () => { - expect(parseOptions("named-exports")).toEqual({title: `Named ${type}`}); - }); - test("ignores options wrapped in another override definition", () => { expect(parseOptions("foreign-definition")).toEqual({}); }); diff --git a/src/cli/entrypoint/parser/tests/fixtures/background/invalid/persistent.ts b/src/cli/entrypoint/parser/tests/fixtures/background/invalid/persistent.ts new file mode 100644 index 00000000..fa925342 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/background/invalid/persistent.ts @@ -0,0 +1,3 @@ +export default { + persistent: "yes", +}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/background/options/full/background.ts b/src/cli/entrypoint/parser/tests/fixtures/background/options/full/background.ts new file mode 100644 index 00000000..a3259e46 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/background/options/full/background.ts @@ -0,0 +1,13 @@ +import {Browser, defineBackground, Mode} from "adnbn"; + +export default defineBackground({ + persistent: true, + permissions: ["storage", "tabs"], + optionalPermissions: ["history"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], + includeApp: ["app"], + excludeBrowser: [Browser.Safari], + mode: Mode.Production, + main() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/foreign-definition/bookmarks.ts b/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/foreign-definition/bookmarks.ts similarity index 100% rename from src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/foreign-definition/bookmarks.ts rename to src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/foreign-definition/bookmarks.ts diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/full/bookmarks.ts b/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/full/bookmarks.ts similarity index 100% rename from src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/full/bookmarks.ts rename to src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/full/bookmarks.ts diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/options/execute-action/open.command.ts b/src/cli/entrypoint/parser/tests/fixtures/command/options/execute-action/open.command.ts new file mode 100644 index 00000000..d903ed02 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/options/execute-action/open.command.ts @@ -0,0 +1,6 @@ +import {defineExecuteActionCommand} from "adnbn"; + +export default defineExecuteActionCommand({ + defaultKey: "Ctrl+Shift+O", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/options/full/save.command.ts b/src/cli/entrypoint/parser/tests/fixtures/command/options/full/save.command.ts new file mode 100644 index 00000000..71c0c29d --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/options/full/save.command.ts @@ -0,0 +1,16 @@ +import {Browser, defineCommand} from "adnbn"; + +export default defineCommand({ + name: "save", + description: "Save the page", + global: false, + defaultKey: "Ctrl+Shift+K", + macKey: "Command+Shift+K", + persistent: true, + permissions: ["storage", "tabs"], + optionalPermissions: ["history"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], + excludeBrowser: [Browser.Firefox], + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/common/invalid/include-browser-unknown.ts b/src/cli/entrypoint/parser/tests/fixtures/common/invalid/include-browser-unknown.ts new file mode 100644 index 00000000..7f855e1c --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/common/invalid/include-browser-unknown.ts @@ -0,0 +1,3 @@ +export default { + includeBrowser: ["netscape"], +}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/common/invalid/manifest-version.ts b/src/cli/entrypoint/parser/tests/fixtures/common/invalid/manifest-version.ts new file mode 100644 index 00000000..237d4cba --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/common/invalid/manifest-version.ts @@ -0,0 +1,3 @@ +export default { + manifestVersion: 4, +}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/common/options/filters.ts b/src/cli/entrypoint/parser/tests/fixtures/common/options/filters.ts new file mode 100644 index 00000000..68b4bddd --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/common/options/filters.ts @@ -0,0 +1,11 @@ +import {Browser, defineBackground, Mode} from "adnbn"; + +export default defineBackground({ + includeApp: ["app"], + excludeApp: ["legacy"], + includeBrowser: [Browser.Chrome, Browser.Edge], + excludeBrowser: [Browser.Safari], + mode: Mode.Development, + debug: false, + manifestVersion: 2, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/history/foreign-definition/history.ts b/src/cli/entrypoint/parser/tests/fixtures/history/options/foreign-definition/history.ts similarity index 100% rename from src/cli/entrypoint/parser/tests/fixtures/override/history/foreign-definition/history.ts rename to src/cli/entrypoint/parser/tests/fixtures/history/options/foreign-definition/history.ts diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/history/full/history.ts b/src/cli/entrypoint/parser/tests/fixtures/history/options/full/history.ts similarity index 100% rename from src/cli/entrypoint/parser/tests/fixtures/override/history/full/history.ts rename to src/cli/entrypoint/parser/tests/fixtures/history/options/full/history.ts diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/newtab/foreign-definition/newtab.ts b/src/cli/entrypoint/parser/tests/fixtures/newtab/options/foreign-definition/newtab.ts similarity index 100% rename from src/cli/entrypoint/parser/tests/fixtures/override/newtab/foreign-definition/newtab.ts rename to src/cli/entrypoint/parser/tests/fixtures/newtab/options/foreign-definition/newtab.ts diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/newtab/full/newtab.ts b/src/cli/entrypoint/parser/tests/fixtures/newtab/options/full/newtab.ts similarity index 100% rename from src/cli/entrypoint/parser/tests/fixtures/override/newtab/full/newtab.ts rename to src/cli/entrypoint/parser/tests/fixtures/newtab/options/full/newtab.ts diff --git a/src/cli/entrypoint/parser/tests/fixtures/offscreen/contracts/init-object.ts b/src/cli/entrypoint/parser/tests/fixtures/offscreen/contracts/init-object.ts new file mode 100644 index 00000000..62242b73 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/offscreen/contracts/init-object.ts @@ -0,0 +1,9 @@ +import {defineOffscreen} from "adnbn"; + +export default defineOffscreen({ + name: "audio", + init: () => ({ + volume: 1, + play: (url: string): void => console.log(url), + }), +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/offscreen/invalid/name.ts b/src/cli/entrypoint/parser/tests/fixtures/offscreen/invalid/name.ts new file mode 100644 index 00000000..3fe6f759 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/offscreen/invalid/name.ts @@ -0,0 +1,3 @@ +export default { + name: "1-audio", +}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/offscreen/invalid/reasons.ts b/src/cli/entrypoint/parser/tests/fixtures/offscreen/invalid/reasons.ts new file mode 100644 index 00000000..145cc4cf --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/offscreen/invalid/reasons.ts @@ -0,0 +1,3 @@ +export default { + reasons: ["TELEPATHY"], +}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/full/audio.offscreen.ts b/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/full/audio.offscreen.ts new file mode 100644 index 00000000..3e33e7d2 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/full/audio.offscreen.ts @@ -0,0 +1,14 @@ +import {Browser, CspSource, defineOffscreen, OffscreenReason} from "adnbn"; + +export default defineOffscreen({ + name: "audio", + reasons: [OffscreenReason.AudioPlayback, OffscreenReason.Blobs], + justification: "Plays notification sounds", + as: "panel", + title: "Extension panel", + template: "./template.html", + includeBrowser: [Browser.Chrome], + csp: {sources: {connect: [CspSource.Self, "https://api.example.com"]}}, + links: "extra.css", + init: () => ({play: (url: string): void => console.log(url)}), +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/named-exports/bookmarks.ts b/src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/named-exports/bookmarks.ts deleted file mode 100644 index b22a06dc..00000000 --- a/src/cli/entrypoint/parser/tests/fixtures/override/bookmarks/named-exports/bookmarks.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const title = "Named bookmarks"; -export const openInTab = false; - -export default () => "Bookmarks"; diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/history/named-exports/history.ts b/src/cli/entrypoint/parser/tests/fixtures/override/history/named-exports/history.ts deleted file mode 100644 index d3035a25..00000000 --- a/src/cli/entrypoint/parser/tests/fixtures/override/history/named-exports/history.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const title = "Named history"; -export const openInTab = false; - -export default () => "History"; diff --git a/src/cli/entrypoint/parser/tests/fixtures/override/newtab/named-exports/newtab.ts b/src/cli/entrypoint/parser/tests/fixtures/override/newtab/named-exports/newtab.ts deleted file mode 100644 index 6adee51a..00000000 --- a/src/cli/entrypoint/parser/tests/fixtures/override/newtab/named-exports/newtab.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const title = "Named newtab"; -export const openInTab = false; - -export default () => "New tab"; diff --git a/src/cli/entrypoint/parser/tests/fixtures/popup/invalid/apply.ts b/src/cli/entrypoint/parser/tests/fixtures/popup/invalid/apply.ts new file mode 100644 index 00000000..7e23ecb3 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/popup/invalid/apply.ts @@ -0,0 +1,3 @@ +export default { + apply: "yes", +}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/popup/invalid/icon.ts b/src/cli/entrypoint/parser/tests/fixtures/popup/invalid/icon.ts new file mode 100644 index 00000000..9e60c24d --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/popup/invalid/icon.ts @@ -0,0 +1,3 @@ +export default { + icon: "", +}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts b/src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts new file mode 100644 index 00000000..b1657caf --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts @@ -0,0 +1,13 @@ +import {Browser, CspSource, definePopup} from "adnbn"; + +export default definePopup({ + as: "panel", + title: "Extension panel", + template: "./template.html", + icon: "toolbar", + apply: false, + includeBrowser: [Browser.Chrome], + csp: {sources: {connect: [CspSource.Self, "https://api.example.com"]}}, + links: "extra.css", + render: ({title}) => title, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/service/contracts/init-object.ts b/src/cli/entrypoint/parser/tests/fixtures/service/contracts/init-object.ts new file mode 100644 index 00000000..02a08621 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/service/contracts/init-object.ts @@ -0,0 +1,9 @@ +import {defineService} from "adnbn"; + +export default defineService({ + name: "math", + init: () => ({ + version: "1.0.0", + sum: (a: number, b: number): number => a + b, + }), +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/service/invalid/name.ts b/src/cli/entrypoint/parser/tests/fixtures/service/invalid/name.ts new file mode 100644 index 00000000..e476ae18 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/service/invalid/name.ts @@ -0,0 +1,3 @@ +export const name = "1-math"; + +export default () => ({}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/service/options/full/math.service.ts b/src/cli/entrypoint/parser/tests/fixtures/service/options/full/math.service.ts new file mode 100644 index 00000000..73677d67 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/service/options/full/math.service.ts @@ -0,0 +1,12 @@ +import {Browser, defineService} from "adnbn"; + +export default defineService({ + name: "math", + persistent: true, + permissions: ["storage", "tabs"], + optionalPermissions: ["history"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], + includeBrowser: [Browser.Chrome], + init: () => ({sum: (a: number, b: number) => a + b}), +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/sidebar/invalid/apply.ts b/src/cli/entrypoint/parser/tests/fixtures/sidebar/invalid/apply.ts new file mode 100644 index 00000000..7e23ecb3 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sidebar/invalid/apply.ts @@ -0,0 +1,3 @@ +export default { + apply: "yes", +}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/sidebar/invalid/icon.ts b/src/cli/entrypoint/parser/tests/fixtures/sidebar/invalid/icon.ts new file mode 100644 index 00000000..9e60c24d --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sidebar/invalid/icon.ts @@ -0,0 +1,3 @@ +export default { + icon: "", +}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts b/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts new file mode 100644 index 00000000..22be8c57 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts @@ -0,0 +1,13 @@ +import {Browser, CspSource, defineSidebar} from "adnbn"; + +export default defineSidebar({ + as: "panel", + title: "Extension panel", + template: "./template.html", + icon: "toolbar", + apply: false, + includeBrowser: [Browser.Chrome], + csp: {sources: {connect: [CspSource.Self, "https://api.example.com"]}}, + links: "extra.css", + render: ({title}) => title, +}); From 133940cb9fda82b934fd6e45fb8b6e25ce933f3c Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:40:09 +0300 Subject: [PATCH 05/28] test(options): drop option reader duplicates from the parser tests Named exports, default-object precedence, and the as and satisfies wrappers belong to the option reader and are covered by its export shapes suite. Keep the scenarios owned by the options parser. --- .../entrypoint/parser/OptionsParser.test.ts | 21 ------------------- .../options/options/default-as/options.ts | 6 ------ .../options/options/default-object/options.ts | 6 ------ .../options/default-satisfies/options.ts | 6 ------ .../options/options/named-exports/options.ts | 4 ---- 5 files changed, 43 deletions(-) delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/options/options/default-as/options.ts delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/options/options/default-object/options.ts delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/options/options/default-satisfies/options.ts delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/options/options/named-exports/options.ts diff --git a/src/cli/entrypoint/parser/OptionsParser.test.ts b/src/cli/entrypoint/parser/OptionsParser.test.ts index 2a2ed898..27f2f38a 100644 --- a/src/cli/entrypoint/parser/OptionsParser.test.ts +++ b/src/cli/entrypoint/parser/OptionsParser.test.ts @@ -57,27 +57,6 @@ describe("OptionsParser", () => { expect(parseOptions("options", "defaults", "options.ts")).toEqual({}); }); - test("reads named exports alongside a default render function", () => { - expect(parseOptions("options", "named-exports", "options.ts")).toEqual({ - openInTab: false, - title: "Named options", - }); - }); - - test("keeps explicit false from a default object over a named export", () => { - expect(parseOptions("options", "default-object", "options.ts")).toEqual({ - openInTab: false, - title: "Default options", - }); - }); - - test.each(["default-as", "default-satisfies"])("reads a %s definition", scenario => { - expect(parseOptions("options", scenario, "options.ts")).toEqual({ - openInTab: false, - title: "Typed options", - }); - }); - test("rejects a non-boolean openInTab value", () => { expect(() => parseOptions("invalid", "open-in-tab.ts")).toThrow("Invalid options openInTab"); }); diff --git a/src/cli/entrypoint/parser/tests/fixtures/options/options/default-as/options.ts b/src/cli/entrypoint/parser/tests/fixtures/options/options/default-as/options.ts deleted file mode 100644 index df5998a5..00000000 --- a/src/cli/entrypoint/parser/tests/fixtures/options/options/default-as/options.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type {OptionsDefinition} from "adnbn"; - -export default { - openInTab: false, - title: "Typed options", -} as OptionsDefinition; diff --git a/src/cli/entrypoint/parser/tests/fixtures/options/options/default-object/options.ts b/src/cli/entrypoint/parser/tests/fixtures/options/options/default-object/options.ts deleted file mode 100644 index fa6fc0ed..00000000 --- a/src/cli/entrypoint/parser/tests/fixtures/options/options/default-object/options.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const openInTab = true; - -export default { - openInTab: false, - title: "Default options", -}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/options/options/default-satisfies/options.ts b/src/cli/entrypoint/parser/tests/fixtures/options/options/default-satisfies/options.ts deleted file mode 100644 index 7a0d9ccf..00000000 --- a/src/cli/entrypoint/parser/tests/fixtures/options/options/default-satisfies/options.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type {OptionsDefinition} from "adnbn"; - -export default { - openInTab: false, - title: "Typed options", -} satisfies OptionsDefinition; diff --git a/src/cli/entrypoint/parser/tests/fixtures/options/options/named-exports/options.ts b/src/cli/entrypoint/parser/tests/fixtures/options/options/named-exports/options.ts deleted file mode 100644 index 86eae0a9..00000000 --- a/src/cli/entrypoint/parser/tests/fixtures/options/options/named-exports/options.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const openInTab = false; -export const title = "Named options"; - -export default () => "Options"; From ef29d6edbdfdff94b345d0a442e2a4b6649bbbba Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:40:09 +0300 Subject: [PATCH 06/28] docs: define placement of entrypoint parsing tests Organize option reader tests by what the reader can read and parser tests by entrypoint. Test shared schema fragments once at their owner and keep export shapes out of parser tests. --- AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 9e20d5b8..d1ece7f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,14 @@ - 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`. Do not add a cross-cutting test file with one row per adopting entrypoint. +- 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 From b7338ded770150d3bba233622c92bbd2158afb1e Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:30:26 +0300 Subject: [PATCH 07/28] fix(view): pass only HTML options to the tags plugin Select the tags plugin options from the HTML options schema instead of excluding a few known keys, so view, build and manifest options such as icon, apply, mode, debug or manifestVersion no longer reach it and a view without HTML options adds no tags plugin at all. Move the HTML options schema out of ViewParser into a parser schema module shared by the parser and the view, and cover the selection with real popup fixtures. --- src/cli/entrypoint/parser/ViewParser.ts | 94 +------------------ src/cli/entrypoint/parser/index.ts | 1 + src/cli/entrypoint/parser/schemas/html.ts | 87 +++++++++++++++++ src/cli/plugins/view/View.test.ts | 52 ++++++++++ src/cli/plugins/view/View.ts | 15 +-- .../tests/fixtures/html-options/src/popup.ts | 15 +++ .../tests/fixtures/html-options/tsconfig.json | 1 + .../fixtures/no-html-options/src/popup.ts | 12 +++ .../fixtures/no-html-options/tsconfig.json | 1 + 9 files changed, 174 insertions(+), 104 deletions(-) create mode 100644 src/cli/entrypoint/parser/schemas/html.ts create mode 100644 src/cli/plugins/view/View.test.ts create mode 100644 src/cli/plugins/view/tests/fixtures/html-options/src/popup.ts create mode 100644 src/cli/plugins/view/tests/fixtures/html-options/tsconfig.json create mode 100644 src/cli/plugins/view/tests/fixtures/no-html-options/src/popup.ts create mode 100644 src/cli/plugins/view/tests/fixtures/no-html-options/tsconfig.json diff --git a/src/cli/entrypoint/parser/ViewParser.ts b/src/cli/entrypoint/parser/ViewParser.ts index 842786ad..ae88d03b 100644 --- a/src/cli/entrypoint/parser/ViewParser.ts +++ b/src/cli/entrypoint/parser/ViewParser.ts @@ -1,103 +1,13 @@ import {z} from "zod"; import AbstractParser from "./AbstractParser"; +import {HtmlOptionsSchema} from "./schemas/html"; import {ViewEntrypointOptions} from "@typing/view"; export default abstract class<O extends ViewEntrypointOptions> extends AbstractParser<O> { - protected HtmlTypeStringSchema = z.enum(["css", "js"]); - protected HtmlAttributesObjectSchema = z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])); - - protected HtmlAddHashFunctionSchema = z.function().args(z.string(), z.string()).returns(z.string()); - - protected HtmlAddPublicPathFunctionSchema = z.function().args(z.string(), z.string()).returns(z.string()); - - protected HtmlCommonOptionsSchema = z.object({ - append: z.boolean().optional(), - useHash: z.boolean().optional(), - addHash: this.HtmlAddHashFunctionSchema.optional(), - hash: z.union([z.boolean(), z.string(), this.HtmlAddHashFunctionSchema]).optional(), - usePublicPath: z.boolean().optional(), - addPublicPath: this.HtmlAddPublicPathFunctionSchema.optional(), - publicPath: z.union([z.boolean(), z.string(), this.HtmlAddPublicPathFunctionSchema]).optional(), - }); - - protected HtmlExternalObjectSchema = z.object({ - packageName: z.string(), - variableName: z.string(), - }); - - protected HtmlBaseTagOptionsSchema = this.HtmlCommonOptionsSchema.extend({ - glob: z.string().optional(), - globPath: z.string().optional(), - globFlatten: z.boolean().optional(), - sourcePath: z.string().optional(), - }); - - protected HtmlLinkTagOptionsSchema = this.HtmlBaseTagOptionsSchema.extend({ - path: z.string(), - attributes: this.HtmlAttributesObjectSchema.optional(), - }); - - protected HtmlScriptTagOptionsSchema = this.HtmlBaseTagOptionsSchema.extend({ - path: z.string(), - attributes: this.HtmlAttributesObjectSchema.optional(), - external: this.HtmlExternalObjectSchema.optional(), - }); - - protected HtmlMaybeLinkTagOptionsSchema = this.HtmlLinkTagOptionsSchema.extend({ - type: this.HtmlTypeStringSchema.optional(), - }); - - protected HtmlMaybeScriptTagOptionsSchema = this.HtmlScriptTagOptionsSchema.extend({ - type: this.HtmlTypeStringSchema.optional(), - }); - - protected HtmlMetaTagOptionsSchema = this.HtmlBaseTagOptionsSchema.extend({ - path: z.string().optional(), - attributes: this.HtmlAttributesObjectSchema, - }); - - protected HtmlOptionsSchema = this.HtmlCommonOptionsSchema.extend({ - append: z.boolean().optional(), - prependExternals: z.boolean().optional(), - jsExtensions: z.union([z.string(), z.array(z.string())]).optional(), - cssExtensions: z.union([z.string(), z.array(z.string())]).optional(), - tags: z - .union([ - z.string(), - this.HtmlMaybeLinkTagOptionsSchema, - this.HtmlMaybeScriptTagOptionsSchema, - z.array( - z.union([z.string(), this.HtmlMaybeLinkTagOptionsSchema, this.HtmlMaybeScriptTagOptionsSchema]) - ), - ]) - .optional(), - links: z - .union([ - z.string(), - this.HtmlLinkTagOptionsSchema, - z.array(z.union([z.string(), this.HtmlLinkTagOptionsSchema])), - ]) - .optional(), - scripts: z - .union([ - z.string(), - this.HtmlScriptTagOptionsSchema, - z.array(z.union([z.string(), this.HtmlScriptTagOptionsSchema])), - ]) - .optional(), - metas: z - .union([ - z.string(), - this.HtmlMetaTagOptionsSchema, - z.array(z.union([z.string(), this.HtmlMetaTagOptionsSchema])), - ]) - .optional(), - }); - protected schema(): typeof this.CommonPropertiesSchema { - return this.CommonPropertiesSchema.merge(this.HtmlOptionsSchema).extend({ + return this.CommonPropertiesSchema.merge(HtmlOptionsSchema).extend({ as: z.string().nonempty().optional(), title: z.string().nonempty().optional(), template: z.string().nonempty().optional(), diff --git a/src/cli/entrypoint/parser/index.ts b/src/cli/entrypoint/parser/index.ts index 97c3df9c..da5bde4d 100644 --- a/src/cli/entrypoint/parser/index.ts +++ b/src/cli/entrypoint/parser/index.ts @@ -12,3 +12,4 @@ export {default as SandboxParser} from "./SandboxParser"; export {default as ServiceParser} from "./ServiceParser"; export {default as SidebarParser} from "./SidebarParser"; export {default as OffscreenParser} from "./OffscreenParser"; +export {HtmlOptionKeys} from "./schemas/html"; diff --git a/src/cli/entrypoint/parser/schemas/html.ts b/src/cli/entrypoint/parser/schemas/html.ts new file mode 100644 index 00000000..2cb6efce --- /dev/null +++ b/src/cli/entrypoint/parser/schemas/html.ts @@ -0,0 +1,87 @@ +import {z} from "zod"; + +const HtmlTypeStringSchema = z.enum(["css", "js"]); + +const HtmlAttributesObjectSchema = z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])); + +const HtmlAddHashFunctionSchema = z.function().args(z.string(), z.string()).returns(z.string()); + +const HtmlAddPublicPathFunctionSchema = z.function().args(z.string(), z.string()).returns(z.string()); + +const HtmlCommonOptionsSchema = z.object({ + append: z.boolean().optional(), + useHash: z.boolean().optional(), + addHash: HtmlAddHashFunctionSchema.optional(), + hash: z.union([z.boolean(), z.string(), HtmlAddHashFunctionSchema]).optional(), + usePublicPath: z.boolean().optional(), + addPublicPath: HtmlAddPublicPathFunctionSchema.optional(), + publicPath: z.union([z.boolean(), z.string(), HtmlAddPublicPathFunctionSchema]).optional(), +}); + +const HtmlExternalObjectSchema = z.object({ + packageName: z.string(), + variableName: z.string(), +}); + +const HtmlBaseTagOptionsSchema = HtmlCommonOptionsSchema.extend({ + glob: z.string().optional(), + globPath: z.string().optional(), + globFlatten: z.boolean().optional(), + sourcePath: z.string().optional(), +}); + +const HtmlLinkTagOptionsSchema = HtmlBaseTagOptionsSchema.extend({ + path: z.string(), + attributes: HtmlAttributesObjectSchema.optional(), +}); + +const HtmlScriptTagOptionsSchema = HtmlBaseTagOptionsSchema.extend({ + path: z.string(), + attributes: HtmlAttributesObjectSchema.optional(), + external: HtmlExternalObjectSchema.optional(), +}); + +const HtmlMaybeLinkTagOptionsSchema = HtmlLinkTagOptionsSchema.extend({ + type: HtmlTypeStringSchema.optional(), +}); + +const HtmlMaybeScriptTagOptionsSchema = HtmlScriptTagOptionsSchema.extend({ + type: HtmlTypeStringSchema.optional(), +}); + +const HtmlMetaTagOptionsSchema = HtmlBaseTagOptionsSchema.extend({ + path: z.string().optional(), + attributes: HtmlAttributesObjectSchema, +}); + +/** + * Options a view passes to the HTML tags plugin. + * + * The same list validates what an entrypoint may declare and selects what reaches the HTML plugin, + * so manifest-only options such as permissions or CSP never leak into it. + */ +export const HtmlOptionsSchema = HtmlCommonOptionsSchema.extend({ + append: z.boolean().optional(), + prependExternals: z.boolean().optional(), + jsExtensions: z.union([z.string(), z.array(z.string())]).optional(), + cssExtensions: z.union([z.string(), z.array(z.string())]).optional(), + tags: z + .union([ + z.string(), + HtmlMaybeLinkTagOptionsSchema, + HtmlMaybeScriptTagOptionsSchema, + z.array(z.union([z.string(), HtmlMaybeLinkTagOptionsSchema, HtmlMaybeScriptTagOptionsSchema])), + ]) + .optional(), + links: z + .union([z.string(), HtmlLinkTagOptionsSchema, z.array(z.union([z.string(), HtmlLinkTagOptionsSchema]))]) + .optional(), + scripts: z + .union([z.string(), HtmlScriptTagOptionsSchema, z.array(z.union([z.string(), HtmlScriptTagOptionsSchema]))]) + .optional(), + metas: z + .union([z.string(), HtmlMetaTagOptionsSchema, z.array(z.union([z.string(), HtmlMetaTagOptionsSchema]))]) + .optional(), +}); + +export const HtmlOptionKeys: readonly string[] = Object.keys(HtmlOptionsSchema.shape); diff --git a/src/cli/plugins/view/View.test.ts b/src/cli/plugins/view/View.test.ts new file mode 100644 index 00000000..e4c8d4e9 --- /dev/null +++ b/src/cli/plugins/view/View.test.ts @@ -0,0 +1,52 @@ +import path from "path"; + +import View from "./View"; + +import {PopupFinder} from "@cli/entrypoint"; + +import {Mode} from "@typing/app"; +import {Browser} from "@typing/browser"; +import type {ReadonlyConfig} from "@typing/config"; + +const fixtures = path.resolve(__dirname, "tests", "fixtures"); + +const makeView = (fixture: string) => { + const config = { + app: "myapp", + appSrcDir: ".", + appsDir: "apps", + browser: Browser.Chrome, + debug: false, + htmlDir: "pages", + manifestVersion: 3, + mergePopup: false, + mode: Mode.Production, + multiplePopup: false, + plugins: [], + rootDir: path.join(fixtures, fixture), + sharedDir: ".", + srcDir: "src", + } as Partial<ReadonlyConfig> as ReadonlyConfig; + + const finder = new PopupFinder(config); + + config.plugins.push({name: "adnbn:popup", popup: () => finder.files()}); + + return new View(config, finder); +}; + +describe("View", () => { + test("passes only HTML options to the tags plugin", async () => { + await expect(makeView("html-options").tags()).resolves.toEqual([ + { + links: "popup.css", + metas: {attributes: {name: "viewport", content: "width=device-width"}}, + files: ["pages/popup.html"], + }, + ]); + }); + + test("adds no tags for a view whose options only configure the view, the build or the manifest", async () => { + await expect(makeView("no-html-options").tags()).resolves.toEqual([]); + }); +}); diff --git a/src/cli/plugins/view/View.ts b/src/cli/plugins/view/View.ts index 0949572a..9e6405bc 100644 --- a/src/cli/plugins/view/View.ts +++ b/src/cli/plugins/view/View.ts @@ -4,7 +4,7 @@ import _ from "lodash"; import type {HtmlRspackPluginOptions} from "@rspack/core"; import type {Options as HtmlRspackTagsPluginOptions} from "html-rspack-tags-plugin"; -import {AbstractViewFinder} from "@cli/entrypoint"; +import {AbstractViewFinder, HtmlOptionKeys} from "@cli/entrypoint"; import {EntrypointEntries} from "@typing/entrypoint"; import {ViewEntrypointOptions} from "@typing/view"; @@ -51,17 +51,8 @@ export default class<O extends ViewEntrypointOptions> { const views = await this.finder.views(); for (const {filename, options} of views.values()) { - // prettier-ignore - const { - as, - title, - template, - excludeApp, - includeApp, - excludeBrowser, - includeBrowser, - ...tagOptions - } = options; + // Only HTML options reach the tags plugin; view, build and manifest options stay out of it. + const tagOptions = _.pick(options, HtmlOptionKeys); if (!_.isEmpty(tagOptions)) { tags.push({ diff --git a/src/cli/plugins/view/tests/fixtures/html-options/src/popup.ts b/src/cli/plugins/view/tests/fixtures/html-options/src/popup.ts new file mode 100644 index 00000000..87386604 --- /dev/null +++ b/src/cli/plugins/view/tests/fixtures/html-options/src/popup.ts @@ -0,0 +1,15 @@ +import {Browser, definePopup, Mode} from "adnbn"; + +export default definePopup({ + title: "Popup", + icon: "toolbar", + apply: true, + mode: Mode.Production, + debug: false, + manifestVersion: 3, + includeBrowser: [Browser.Chrome], + csp: {sources: {connect: ["https://api.example.com"]}}, + links: "popup.css", + metas: {attributes: {name: "viewport", content: "width=device-width"}}, + render: () => "Popup", +}); diff --git a/src/cli/plugins/view/tests/fixtures/html-options/tsconfig.json b/src/cli/plugins/view/tests/fixtures/html-options/tsconfig.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/cli/plugins/view/tests/fixtures/html-options/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/src/cli/plugins/view/tests/fixtures/no-html-options/src/popup.ts b/src/cli/plugins/view/tests/fixtures/no-html-options/src/popup.ts new file mode 100644 index 00000000..36a36b48 --- /dev/null +++ b/src/cli/plugins/view/tests/fixtures/no-html-options/src/popup.ts @@ -0,0 +1,12 @@ +import {Browser, definePopup, Mode} from "adnbn"; + +export default definePopup({ + title: "Popup", + icon: "toolbar", + mode: Mode.Production, + debug: false, + manifestVersion: 3, + includeBrowser: [Browser.Chrome], + csp: {wasm: true}, + render: () => "Popup", +}); diff --git a/src/cli/plugins/view/tests/fixtures/no-html-options/tsconfig.json b/src/cli/plugins/view/tests/fixtures/no-html-options/tsconfig.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/cli/plugins/view/tests/fixtures/no-html-options/tsconfig.json @@ -0,0 +1 @@ +{} From 7366e6ad586b9a42ab8f05d7f9ce22f6fdf492f0 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:30:36 +0300 Subject: [PATCH 08/28] feat(entrypoint): add a shared permissions options contract Describe install-time and optional API and host permissions once as PermissionsOptions with a matching schema module that a parser merges only when its entrypoint opts in. Apply it to the background family, which already declared these fields, without changing its behavior, and export the contract publicly. Document that schemas shared by unrelated parsers are mixin modules rather than members of AbstractParser or intermediate parser classes. --- AGENTS.md | 3 +- src/cli/entrypoint/parser/BackgroundParser.ts | 7 +-- .../parser/schemas/permissions.test.ts | 32 +++++++++++++ .../entrypoint/parser/schemas/permissions.ts | 14 ++++++ src/main/index.ts | 1 + src/main/permissions.ts | 1 + src/types/background.ts | 11 +---- src/types/permissions.ts | 46 +++++++++++++++++++ 8 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 src/cli/entrypoint/parser/schemas/permissions.test.ts create mode 100644 src/cli/entrypoint/parser/schemas/permissions.ts create mode 100644 src/main/permissions.ts create mode 100644 src/types/permissions.ts diff --git a/AGENTS.md b/AGENTS.md index d1ece7f1..43c7d573 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,8 @@ - `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`. Do not add a cross-cutting test file with one row per adopting entrypoint. +- 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 diff --git a/src/cli/entrypoint/parser/BackgroundParser.ts b/src/cli/entrypoint/parser/BackgroundParser.ts index f7eedd03..5d571c2e 100644 --- a/src/cli/entrypoint/parser/BackgroundParser.ts +++ b/src/cli/entrypoint/parser/BackgroundParser.ts @@ -1,6 +1,7 @@ import {z} from "zod"; import AbstractParser from "./AbstractParser"; +import {PermissionsSchema} from "./schemas/permissions"; import {BackgroundEntrypointOptions} from "@typing/background"; @@ -10,12 +11,8 @@ export default class<T extends BackgroundEntrypointOptions = BackgroundEntrypoin } protected schema(): typeof this.CommonPropertiesSchema { - return this.CommonPropertiesSchema.extend({ + return this.CommonPropertiesSchema.merge(PermissionsSchema).extend({ persistent: z.boolean().optional(), - permissions: z.array(z.string()).optional(), - optionalPermissions: z.array(z.string()).optional(), - hostPermissions: z.array(z.string()).optional(), - optionalHostPermissions: z.array(z.string()).optional(), }); } } diff --git a/src/cli/entrypoint/parser/schemas/permissions.test.ts b/src/cli/entrypoint/parser/schemas/permissions.test.ts new file mode 100644 index 00000000..52653054 --- /dev/null +++ b/src/cli/entrypoint/parser/schemas/permissions.test.ts @@ -0,0 +1,32 @@ +import {PermissionsSchema} from "./permissions"; + +import type {PermissionsOptions} from "@typing/permissions"; + +describe("PermissionsSchema", () => { + test("accepts install-time and optional API and host permissions", () => { + const permissions: Required<PermissionsOptions> = { + permissions: ["storage", "tabs"], + optionalPermissions: ["history"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], + }; + + expect(PermissionsSchema.parse(permissions)).toEqual(permissions); + }); + + test("leaves every field optional", () => { + expect(PermissionsSchema.parse({})).toEqual({}); + }); + + test.each([ + {field: "permissions", value: "tabs"}, + {field: "optionalPermissions", value: {history: true}}, + {field: "hostPermissions", value: [1]}, + {field: "optionalHostPermissions", value: [null]}, + ])("rejects $field that is not an array of strings", ({field, value}) => { + const result = PermissionsSchema.safeParse({[field]: value}); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe(field); + }); +}); diff --git a/src/cli/entrypoint/parser/schemas/permissions.ts b/src/cli/entrypoint/parser/schemas/permissions.ts new file mode 100644 index 00000000..cb73bcd4 --- /dev/null +++ b/src/cli/entrypoint/parser/schemas/permissions.ts @@ -0,0 +1,14 @@ +import {z} from "zod"; + +/** + * Schema of `PermissionsOptions`. + * + * It is a mixin, not a part of any parser class: an entrypoint opts into the contract + * when its parser imports and merges this schema, and entrypoints that do not never read these keys. + */ +export const PermissionsSchema = z.object({ + permissions: z.array(z.string()).optional(), + optionalPermissions: z.array(z.string()).optional(), + hostPermissions: z.array(z.string()).optional(), + optionalHostPermissions: z.array(z.string()).optional(), +}); diff --git a/src/main/index.ts b/src/main/index.ts index f99da880..c92906e3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,6 +13,7 @@ export * from "./offscreen"; export * from "./options"; export * from "./override"; export * from "./page"; +export * from "./permissions"; export * from "./plugin"; export * from "./popup"; export * from "./relay"; diff --git a/src/main/permissions.ts b/src/main/permissions.ts new file mode 100644 index 00000000..c924a482 --- /dev/null +++ b/src/main/permissions.ts @@ -0,0 +1 @@ +export type {PermissionsOptions} from "@typing/permissions"; diff --git a/src/types/background.ts b/src/types/background.ts index 8d513765..0990f37c 100644 --- a/src/types/background.ts +++ b/src/types/background.ts @@ -1,18 +1,11 @@ import {EntrypointBuilder, EntrypointOptions} from "@typing/entrypoint"; import {Awaiter} from "@typing/helpers"; +import {PermissionsOptions} from "@typing/permissions"; export const BackgroundEntryName = "background"; -type ManifestPermissions = chrome.runtime.ManifestPermission; - -type ManifestOptionalPermissions = chrome.runtime.ManifestOptionalPermission; - -export interface BackgroundConfig { +export interface BackgroundConfig extends PermissionsOptions { persistent?: boolean; - permissions?: ManifestPermissions[]; - optionalPermissions?: ManifestOptionalPermissions[]; - hostPermissions?: string[]; - optionalHostPermissions?: string[]; } export type BackgroundOptions = BackgroundConfig & EntrypointOptions; diff --git a/src/types/permissions.ts b/src/types/permissions.ts new file mode 100644 index 00000000..f3592aa8 --- /dev/null +++ b/src/types/permissions.ts @@ -0,0 +1,46 @@ +type ManifestPermission = chrome.runtime.ManifestPermission; + +type ManifestOptionalPermission = chrome.runtime.ManifestOptionalPermission; + +/** + * Manifest permissions required by an entrypoint. + * + * Browsers grant permissions to the whole extension, never to a single entrypoint. + * Declaring them here keeps a requirement beside the code that needs it + * and adds it to the manifest only in builds that include the entrypoint. + * + * An entrypoint type opts into this contract explicitly; it is not part of every entrypoint. + */ +export interface PermissionsOptions { + /** + * API permissions granted at install time. Written to `permissions`. + */ + permissions?: ManifestPermission[]; + + /** + * API permissions requested at runtime. Written to `optional_permissions`. + */ + optionalPermissions?: ManifestOptionalPermission[]; + + /** + * Host match patterns granted at install time. + * Written to `host_permissions`, or to `permissions` in Manifest V2. + */ + hostPermissions?: string[]; + + /** + * Host match patterns requested at runtime. + * Written to `optional_host_permissions`, or to `optional_permissions` in Manifest V2. + */ + optionalHostPermissions?: string[]; +} + +/** + * Permissions collected from the entrypoints that reach a build, ready to be appended to the manifest. + */ +export interface EntrypointPermissions { + permissions: Set<ManifestPermission>; + optionalPermissions: Set<ManifestOptionalPermission>; + hostPermissions: Set<string>; + optionalHostPermissions: Set<string>; +} From 05a0a51bc25da9172cd83ac07fcb73420c470a74 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:30:48 +0300 Subject: [PATCH 09/28] feat(override): declare permissions in override entrypoints Let the new tab, bookmarks and history pages declare the permissions their code needs. The manifest receives them only from the page that reaches the build: a candidate that lost the selection or a page the target browser does not support contributes nothing. Expose the parsed options of the selected views from the view finder and collect permissions from them with a pure helper, so other view entrypoints can adopt the contract the same way. Cover the schema adoption, winner-only selection, the plugin policy and real builds across browsers, where a skipped History page no longer requests the browsing history permission. --- .../finder/AbstractOverrideFinder.test.ts | 11 ++++++++ .../entrypoint/finder/AbstractViewFinder.ts | 19 +++++++++++++ src/cli/entrypoint/finder/index.ts | 1 + .../plugins/third-party/bookmarks.ts | 1 + .../workspace/plugins/third-party/history.ts | 1 + .../workspace/plugins/third-party/newtab.ts | 1 + .../workspace/src/apps/app/bookmarks.ts | 1 + .../workspace/src/apps/app/history.ts | 1 + .../override/workspace/src/apps/app/newtab.ts | 1 + .../finder/utils/permissions.test.ts | 28 +++++++++++++++++++ .../entrypoint/finder/utils/permissions.ts | 23 +++++++++++++++ .../parser/AbstractOverrideParser.ts | 14 ++++++++++ src/cli/entrypoint/parser/BookmarksParser.ts | 4 +-- src/cli/entrypoint/parser/HistoryParser.ts | 4 +-- src/cli/entrypoint/parser/NewtabParser.ts | 4 +-- .../parser/override-parsers.test.ts | 9 ++++-- .../bookmarks/options/full/bookmarks.ts | 4 +++ .../fixtures/history/options/full/history.ts | 4 +++ .../fixtures/newtab/options/full/newtab.ts | 4 +++ src/cli/plugins/override/Override.test.ts | 28 +++++++++++++++++-- src/cli/plugins/override/Override.ts | 16 ++++++++++- src/cli/plugins/override/index.ts | 11 +++++++- .../tests/fixtures/bookmarks/src/bookmarks.ts | 1 + .../fixtures/conflict/src/history/index.ts | 1 + .../tests/fixtures/conflict/src/newtab.ts | 4 +++ .../tests/fixtures/history/src/history.ts | 1 + .../tests/fixtures/newtab/src/newtab.ts | 4 +++ .../tests/fixtures/split/src/history.ts | 1 + .../tests/fixtures/split/src/newtab.ts | 4 +++ src/types/override.ts | 3 +- tests/integration/README.md | 2 +- .../build/override/bookmarks/src/bookmarks.ts | 1 + .../build/override/history/src/history.ts | 1 + .../build/override/newtab/src/newtab.ts | 1 + .../override/override.integration.test.ts | 23 +++++++++++---- 35 files changed, 218 insertions(+), 19 deletions(-) create mode 100644 src/cli/entrypoint/finder/utils/permissions.test.ts create mode 100644 src/cli/entrypoint/finder/utils/permissions.ts create mode 100644 src/cli/entrypoint/parser/AbstractOverrideParser.ts diff --git a/src/cli/entrypoint/finder/AbstractOverrideFinder.test.ts b/src/cli/entrypoint/finder/AbstractOverrideFinder.test.ts index fc81d9b5..98d08d43 100644 --- a/src/cli/entrypoint/finder/AbstractOverrideFinder.test.ts +++ b/src/cli/entrypoint/finder/AbstractOverrideFinder.test.ts @@ -98,6 +98,16 @@ describe.each([ await expect(finder.csp()).resolves.toEqual([{sources: {connect: ["https://app.example.com"]}}]); }); + test("reports the parsed options of the selected candidate only, with its manifest settings", async () => { + await expect(makeFinder().selectedOptions()).resolves.toEqual([ + { + title: `App ${type}`, + csp: {sources: {connect: ["https://app.example.com"]}}, + permissions: ["storage"], + }, + ]); + }); + test("uses the plugin candidate when the workspace has none", async () => { const finder = makeFinder({srcDir: "missing"}); const [view] = (await finder.views()).values(); @@ -108,5 +118,6 @@ describe.each([ options: {title: `Plugin ${type}`}, }); await expect(finder.csp()).resolves.toEqual([{sources: {connect: ["https://plugin.example.com"]}}]); + await expect(finder.selectedOptions()).resolves.toMatchObject([{permissions: ["topSites"]}]); }); }); diff --git a/src/cli/entrypoint/finder/AbstractViewFinder.ts b/src/cli/entrypoint/finder/AbstractViewFinder.ts index e9cbdd08..8c22da27 100644 --- a/src/cli/entrypoint/finder/AbstractViewFinder.ts +++ b/src/cli/entrypoint/finder/AbstractViewFinder.ts @@ -99,6 +99,25 @@ export default abstract class<O extends ViewEntrypointOptions> extends AbstractP return true; } + /** + * Parsed options of the entrypoints that became views. + * Candidates that lost the selection are excluded, and the options keep manifest-only settings. + */ + public async selectedOptions(): Promise<O[]> { + const options = await this.plugin().options(); + const selected: O[] = []; + + for (const {file} of (await this.views()).values()) { + const option = options.get(file); + + if (option) { + selected.push(option); + } + } + + return selected; + } + public async getAliasToFilename(): Promise<ViewAliasToFilename> { return Array.from(await this.views()).reduce((aliases, [_, item]) => { return { diff --git a/src/cli/entrypoint/finder/index.ts b/src/cli/entrypoint/finder/index.ts index 2852eb4f..5a09dde9 100644 --- a/src/cli/entrypoint/finder/index.ts +++ b/src/cli/entrypoint/finder/index.ts @@ -30,3 +30,4 @@ export {default as SandboxViewFinder} from "./SandboxViewFinder"; export {default as ServiceFinder} from "./ServiceFinder"; export {default as PluginFinder} from "./PluginFinder"; export {default as SidebarFinder} from "./SidebarFinder"; +export {collectPermissions} from "./utils/permissions"; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/bookmarks.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/bookmarks.ts index 3e352bfb..03f12514 100644 --- a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/bookmarks.ts +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/bookmarks.ts @@ -1,4 +1,5 @@ export const title = "Plugin bookmarks"; export const csp = {sources: {connect: ["https://plugin.example.com"]}}; +export const permissions = ["topSites"]; export default () => "Plugin bookmarks"; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/history.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/history.ts index f39700f0..f1fd7d8f 100644 --- a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/history.ts +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/history.ts @@ -1,4 +1,5 @@ export const title = "Plugin history"; export const csp = {sources: {connect: ["https://plugin.example.com"]}}; +export const permissions = ["topSites"]; export default () => "Plugin history"; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/newtab.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/newtab.ts index 37d09b87..38e6c281 100644 --- a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/newtab.ts +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/newtab.ts @@ -1,4 +1,5 @@ export const title = "Plugin newtab"; export const csp = {sources: {connect: ["https://plugin.example.com"]}}; +export const permissions = ["topSites"]; export default () => "Plugin newtab"; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/bookmarks.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/bookmarks.ts index 21b9b2fc..2d623d22 100644 --- a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/bookmarks.ts +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/bookmarks.ts @@ -3,5 +3,6 @@ import {defineBookmarks} from "adnbn"; export default defineBookmarks({ title: "App bookmarks", csp: {sources: {connect: ["https://app.example.com"]}}, + permissions: ["storage"], render: () => "App bookmarks", }); diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/history.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/history.ts index 37502540..d6e5afb8 100644 --- a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/history.ts +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/history.ts @@ -3,5 +3,6 @@ import {defineHistory} from "adnbn"; export default defineHistory({ title: "App history", csp: {sources: {connect: ["https://app.example.com"]}}, + permissions: ["storage"], render: () => "App history", }); diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/newtab.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/newtab.ts index 10a3678f..71a816be 100644 --- a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/newtab.ts +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/newtab.ts @@ -3,5 +3,6 @@ import {defineNewtab} from "adnbn"; export default defineNewtab({ title: "App newtab", csp: {sources: {connect: ["https://app.example.com"]}}, + permissions: ["storage"], render: () => "App newtab", }); diff --git a/src/cli/entrypoint/finder/utils/permissions.test.ts b/src/cli/entrypoint/finder/utils/permissions.test.ts new file mode 100644 index 00000000..ca8d29ef --- /dev/null +++ b/src/cli/entrypoint/finder/utils/permissions.test.ts @@ -0,0 +1,28 @@ +import {collectPermissions} from "./permissions"; + +describe("collectPermissions", () => { + test("unites every kind of permission across entrypoints without duplicates", () => { + expect( + collectPermissions([ + {permissions: ["storage", "tabs"], hostPermissions: ["https://*.example.com/*"]}, + {permissions: ["tabs", "history"], optionalPermissions: ["topSites"]}, + {optionalHostPermissions: ["https://other.test/*"], hostPermissions: ["https://*.example.com/*"]}, + ]) + ).toEqual({ + permissions: new Set(["storage", "tabs", "history"]), + optionalPermissions: new Set(["topSites"]), + hostPermissions: new Set(["https://*.example.com/*"]), + optionalHostPermissions: new Set(["https://other.test/*"]), + }); + }); + + test("returns empty sets when no entrypoint declares permissions", () => { + expect(collectPermissions([{}, {}])).toEqual({ + permissions: new Set(), + optionalPermissions: new Set(), + hostPermissions: new Set(), + optionalHostPermissions: new Set(), + }); + expect(collectPermissions([])).toEqual(collectPermissions([{}])); + }); +}); diff --git a/src/cli/entrypoint/finder/utils/permissions.ts b/src/cli/entrypoint/finder/utils/permissions.ts new file mode 100644 index 00000000..58e3f107 --- /dev/null +++ b/src/cli/entrypoint/finder/utils/permissions.ts @@ -0,0 +1,23 @@ +import type {EntrypointPermissions, PermissionsOptions} from "@typing/permissions"; + +/** + * Unites the permissions declared by entrypoints that reach the build. + * Pass only the options of those entrypoints: candidates that lost the selection must not contribute. + */ +export const collectPermissions = (options: Iterable<PermissionsOptions>): EntrypointPermissions => { + const collected: EntrypointPermissions = { + permissions: new Set(), + optionalPermissions: new Set(), + hostPermissions: new Set(), + optionalHostPermissions: new Set(), + }; + + for (const {permissions, optionalPermissions, hostPermissions, optionalHostPermissions} of options) { + permissions?.forEach(permission => collected.permissions.add(permission)); + optionalPermissions?.forEach(permission => collected.optionalPermissions.add(permission)); + hostPermissions?.forEach(permission => collected.hostPermissions.add(permission)); + optionalHostPermissions?.forEach(permission => collected.optionalHostPermissions.add(permission)); + } + + return collected; +}; diff --git a/src/cli/entrypoint/parser/AbstractOverrideParser.ts b/src/cli/entrypoint/parser/AbstractOverrideParser.ts new file mode 100644 index 00000000..23905017 --- /dev/null +++ b/src/cli/entrypoint/parser/AbstractOverrideParser.ts @@ -0,0 +1,14 @@ +import ViewCspParser from "./ViewCspParser"; +import {PermissionsSchema} from "./schemas/permissions"; + +import {OverrideEntrypointOptions} from "@typing/override"; + +/** + * Override pages rely on browser APIs, so the family opts into the permissions contract + * on top of the view and CSP options. + */ +export default abstract class AbstractOverrideParser<O extends OverrideEntrypointOptions> extends ViewCspParser<O> { + protected schema(): typeof this.CommonPropertiesSchema { + return super.schema().merge(PermissionsSchema); + } +} diff --git a/src/cli/entrypoint/parser/BookmarksParser.ts b/src/cli/entrypoint/parser/BookmarksParser.ts index 8f8ca8ef..f6420d50 100644 --- a/src/cli/entrypoint/parser/BookmarksParser.ts +++ b/src/cli/entrypoint/parser/BookmarksParser.ts @@ -1,8 +1,8 @@ -import ViewCspParser from "./ViewCspParser"; +import AbstractOverrideParser from "./AbstractOverrideParser"; import {BookmarksEntrypointOptions} from "@typing/override"; -export default class BookmarksParser extends ViewCspParser<BookmarksEntrypointOptions> { +export default class BookmarksParser extends AbstractOverrideParser<BookmarksEntrypointOptions> { protected definition(): string { return "defineBookmarks"; } diff --git a/src/cli/entrypoint/parser/HistoryParser.ts b/src/cli/entrypoint/parser/HistoryParser.ts index a9a494db..331514ca 100644 --- a/src/cli/entrypoint/parser/HistoryParser.ts +++ b/src/cli/entrypoint/parser/HistoryParser.ts @@ -1,8 +1,8 @@ -import ViewCspParser from "./ViewCspParser"; +import AbstractOverrideParser from "./AbstractOverrideParser"; import {HistoryEntrypointOptions} from "@typing/override"; -export default class HistoryParser extends ViewCspParser<HistoryEntrypointOptions> { +export default class HistoryParser extends AbstractOverrideParser<HistoryEntrypointOptions> { protected definition(): string { return "defineHistory"; } diff --git a/src/cli/entrypoint/parser/NewtabParser.ts b/src/cli/entrypoint/parser/NewtabParser.ts index 6c2fba6e..0802d025 100644 --- a/src/cli/entrypoint/parser/NewtabParser.ts +++ b/src/cli/entrypoint/parser/NewtabParser.ts @@ -1,8 +1,8 @@ -import ViewCspParser from "./ViewCspParser"; +import AbstractOverrideParser from "./AbstractOverrideParser"; import {NewtabEntrypointOptions} from "@typing/override"; -export default class NewtabParser extends ViewCspParser<NewtabEntrypointOptions> { +export default class NewtabParser extends AbstractOverrideParser<NewtabEntrypointOptions> { protected definition(): string { return "defineNewtab"; } diff --git a/src/cli/entrypoint/parser/override-parsers.test.ts b/src/cli/entrypoint/parser/override-parsers.test.ts index 7c642d2f..9ed3f7ab 100644 --- a/src/cli/entrypoint/parser/override-parsers.test.ts +++ b/src/cli/entrypoint/parser/override-parsers.test.ts @@ -11,7 +11,8 @@ const fixtures = path.resolve(__dirname, "tests", "fixtures"); const config = {rootDir} as ReadonlyConfig; -// The override parsers share one schema and differ only in the definition they recognise. +// The override parsers share one schema, including the permissions contract adopted by the family, +// and differ only in the definition they recognise. describe.each([ {type: "newtab", label: "New tab", parser: new NewtabParser(config)}, {type: "bookmarks", label: "Bookmarks", parser: new BookmarksParser(config)}, @@ -23,7 +24,7 @@ describe.each([ return parser.options({file: filename, import: filename}); }; - test("parses its own definition with inherited view, CSP and build filters", () => { + test("parses its own definition with permissions and inherited view, CSP and build filters", () => { expect(parseOptions("full")).toEqual({ as: "dashboard", title: `${label} override`, @@ -42,6 +43,10 @@ describe.each([ image: ["'self'", "data:", "blob:"], }, }, + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], scripts: "extra.js", links: "extra.css", metas: { diff --git a/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/full/bookmarks.ts b/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/full/bookmarks.ts index dcdaddba..2b9ec7c1 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/full/bookmarks.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/full/bookmarks.ts @@ -18,6 +18,10 @@ export default defineBookmarks({ image: [CspSource.Self, "data:", "blob:"], }, }, + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], scripts: "extra.js", links: "extra.css", metas: { diff --git a/src/cli/entrypoint/parser/tests/fixtures/history/options/full/history.ts b/src/cli/entrypoint/parser/tests/fixtures/history/options/full/history.ts index 991a9b6a..dc301db5 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/history/options/full/history.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/history/options/full/history.ts @@ -18,6 +18,10 @@ export default defineHistory({ image: [CspSource.Self, "data:", "blob:"], }, }, + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], scripts: "extra.js", links: "extra.css", metas: { diff --git a/src/cli/entrypoint/parser/tests/fixtures/newtab/options/full/newtab.ts b/src/cli/entrypoint/parser/tests/fixtures/newtab/options/full/newtab.ts index db36d500..bac4dfb3 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/newtab/options/full/newtab.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/newtab/options/full/newtab.ts @@ -18,6 +18,10 @@ export default defineNewtab({ image: [CspSource.Self, "data:", "blob:"], }, }, + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], scripts: "extra.js", links: "extra.css", metas: { diff --git a/src/cli/plugins/override/Override.test.ts b/src/cli/plugins/override/Override.test.ts index 3a04db55..e7a91753 100644 --- a/src/cli/plugins/override/Override.test.ts +++ b/src/cli/plugins/override/Override.test.ts @@ -40,18 +40,37 @@ const makeOverride = (fixture: string, config: Partial<ReadonlyConfig> = {}): Ov const chromium = [Browser.Chrome, Browser.Chromium, Browser.Edge]; +const none = { + permissions: new Set(), + optionalPermissions: new Set(), + hostPermissions: new Set(), + optionalHostPermissions: new Set(), +}; + +const declared = { + newtab: { + permissions: new Set(["search"]), + optionalPermissions: new Set(["topSites"]), + hostPermissions: new Set(["https://*.example.com/*"]), + optionalHostPermissions: new Set(["https://other.test/*"]), + }, + bookmarks: {...none, permissions: new Set(["bookmarks"])}, + history: {...none, permissions: new Set(["history"])}, +}; + describe.each([ {page: "newtab", supported: [...chromium, Browser.Firefox, Browser.Safari], unsupported: [Browser.Opera]}, {page: "bookmarks", supported: chromium, unsupported: [Browser.Firefox, Browser.Safari, Browser.Opera]}, {page: "history", supported: chromium, unsupported: [Browser.Firefox, Browser.Safari, Browser.Opera]}, -])("Override with a $page entrypoint", ({page, supported, unsupported}) => { - test.each(supported)("builds the page, its manifest override and CSP for %s", async browser => { +] as const)("Override with a $page entrypoint", ({page, supported, unsupported}) => { + test.each(supported)("builds the page, its manifest override, CSP and permissions for %s", async browser => { const override = makeOverride(page, {browser}); const entry = {file: path.join(fixtures, page, "src", `${page}.ts`)}; const view = await override.view(); await expect(override.manifest()).resolves.toEqual({page, path: `${page}.html`}); await expect(override.csp()).resolves.toEqual([{sources: {connect: [`https://${page}.example.com`]}}]); + await expect(override.permissions()).resolves.toEqual(declared[page]); await expect(view?.entries()).resolves.toEqual(new Map([[page, new Set([expect.objectContaining(entry)])]])); await expect(view?.html()).resolves.toMatchObject([ {filename: `${page}.html`, title: `Custom ${page}`, chunks: [page]}, @@ -64,6 +83,7 @@ describe.each([ await expect(override.view()).resolves.toBeUndefined(); await expect(override.manifest()).resolves.toBeUndefined(); await expect(override.csp()).resolves.toEqual([]); + await expect(override.permissions()).resolves.toEqual(none); }); }); @@ -81,6 +101,7 @@ describe("Override with competing entrypoints", () => { await expect(override.view()).rejects.toThrow(message); await expect(override.manifest()).rejects.toThrow(message); await expect(override.csp()).rejects.toThrow(message); + await expect(override.permissions()).rejects.toThrow(message); }); test.each([Browser.Firefox, Browser.Safari])( @@ -90,6 +111,8 @@ describe("Override with competing entrypoints", () => { await expect(override.manifest()).resolves.toEqual({page: "newtab", path: "newtab.html"}); await expect(override.csp()).resolves.toEqual([{sources: {connect: ["https://newtab.example.com"]}}]); + // The skipped History page must not request its browsing-history permission. + await expect(override.permissions()).resolves.toEqual(declared.newtab); } ); @@ -112,6 +135,7 @@ describe("Override without entrypoints", () => { await expect(override.view()).resolves.toBeUndefined(); await expect(override.manifest()).resolves.toBeUndefined(); await expect(override.csp()).resolves.toEqual([]); + await expect(override.permissions()).resolves.toEqual(none); }); }); diff --git a/src/cli/plugins/override/Override.ts b/src/cli/plugins/override/Override.ts index dd33a665..8d78be5b 100644 --- a/src/cli/plugins/override/Override.ts +++ b/src/cli/plugins/override/Override.ts @@ -2,7 +2,13 @@ import path from "path"; import View from "../view/View"; -import {AbstractOverrideFinder, BookmarksFinder, HistoryFinder, NewtabFinder} from "@cli/entrypoint"; +import { + AbstractOverrideFinder, + BookmarksFinder, + collectPermissions, + HistoryFinder, + NewtabFinder, +} from "@cli/entrypoint"; import {resolveRootPath} from "@cli/resolvers/path"; import {toPosix} from "@cli/utils/path"; @@ -12,6 +18,7 @@ import type {ReadonlyConfig} from "@typing/config"; import type {CspConfig} from "@typing/csp"; import type {ManifestOverride} from "@typing/manifest"; import type {OverrideEntrypointOptions, OverrideEntrypointType} from "@typing/override"; +import type {EntrypointPermissions} from "@typing/permissions"; type OverrideFinder = AbstractOverrideFinder<OverrideEntrypointOptions>; @@ -69,6 +76,13 @@ export default class Override { return finder ? finder.csp() : []; } + /** Permissions of the override that reaches the build; skipped and losing candidates contribute nothing. */ + public async permissions(): Promise<EntrypointPermissions> { + const finder = await this.finder(); + + return collectPermissions(finder ? await finder.selectedOptions() : []); + } + public clear(): this { this._finder = undefined; diff --git a/src/cli/plugins/override/index.ts b/src/cli/plugins/override/index.ts index 005f5577..152e9575 100644 --- a/src/cli/plugins/override/index.ts +++ b/src/cli/plugins/override/index.ts @@ -46,7 +46,16 @@ export default definePlugin(() => { } satisfies RspackConfig; }, manifest: async ({manifest}) => { - manifest.setOverride(await override.manifest()).appendCsp(await override.csp()); + const {permissions, optionalPermissions, hostPermissions, optionalHostPermissions} = + await override.permissions(); + + manifest + .setOverride(await override.manifest()) + .appendCsp(await override.csp()) + .appendPermissions(permissions) + .appendOptionalPermissions(optionalPermissions) + .appendHostPermissions(hostPermissions) + .appendOptionalHostPermissions(optionalHostPermissions); }, }; }); diff --git a/src/cli/plugins/override/tests/fixtures/bookmarks/src/bookmarks.ts b/src/cli/plugins/override/tests/fixtures/bookmarks/src/bookmarks.ts index 2bd6fb95..73ee2902 100644 --- a/src/cli/plugins/override/tests/fixtures/bookmarks/src/bookmarks.ts +++ b/src/cli/plugins/override/tests/fixtures/bookmarks/src/bookmarks.ts @@ -3,5 +3,6 @@ import {defineBookmarks} from "adnbn"; export default defineBookmarks({ title: "Custom bookmarks", csp: {sources: {connect: ["https://bookmarks.example.com"]}}, + permissions: ["bookmarks"], render: () => "Custom bookmarks", }); diff --git a/src/cli/plugins/override/tests/fixtures/conflict/src/history/index.ts b/src/cli/plugins/override/tests/fixtures/conflict/src/history/index.ts index 94b3af6b..0cbdf3fd 100644 --- a/src/cli/plugins/override/tests/fixtures/conflict/src/history/index.ts +++ b/src/cli/plugins/override/tests/fixtures/conflict/src/history/index.ts @@ -3,5 +3,6 @@ import {defineHistory} from "adnbn"; export default defineHistory({ title: "Custom history", csp: {sources: {connect: ["https://history.example.com"]}}, + permissions: ["history"], render: () => "Custom history", }); diff --git a/src/cli/plugins/override/tests/fixtures/conflict/src/newtab.ts b/src/cli/plugins/override/tests/fixtures/conflict/src/newtab.ts index bb6c262d..9df53497 100644 --- a/src/cli/plugins/override/tests/fixtures/conflict/src/newtab.ts +++ b/src/cli/plugins/override/tests/fixtures/conflict/src/newtab.ts @@ -3,5 +3,9 @@ import {defineNewtab} from "adnbn"; export default defineNewtab({ title: "Custom newtab", csp: {sources: {connect: ["https://newtab.example.com"]}}, + permissions: ["search"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], render: () => "Custom newtab", }); diff --git a/src/cli/plugins/override/tests/fixtures/history/src/history.ts b/src/cli/plugins/override/tests/fixtures/history/src/history.ts index 94b3af6b..0cbdf3fd 100644 --- a/src/cli/plugins/override/tests/fixtures/history/src/history.ts +++ b/src/cli/plugins/override/tests/fixtures/history/src/history.ts @@ -3,5 +3,6 @@ import {defineHistory} from "adnbn"; export default defineHistory({ title: "Custom history", csp: {sources: {connect: ["https://history.example.com"]}}, + permissions: ["history"], render: () => "Custom history", }); diff --git a/src/cli/plugins/override/tests/fixtures/newtab/src/newtab.ts b/src/cli/plugins/override/tests/fixtures/newtab/src/newtab.ts index bb6c262d..9df53497 100644 --- a/src/cli/plugins/override/tests/fixtures/newtab/src/newtab.ts +++ b/src/cli/plugins/override/tests/fixtures/newtab/src/newtab.ts @@ -3,5 +3,9 @@ import {defineNewtab} from "adnbn"; export default defineNewtab({ title: "Custom newtab", csp: {sources: {connect: ["https://newtab.example.com"]}}, + permissions: ["search"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], render: () => "Custom newtab", }); diff --git a/src/cli/plugins/override/tests/fixtures/split/src/history.ts b/src/cli/plugins/override/tests/fixtures/split/src/history.ts index 94b3af6b..0cbdf3fd 100644 --- a/src/cli/plugins/override/tests/fixtures/split/src/history.ts +++ b/src/cli/plugins/override/tests/fixtures/split/src/history.ts @@ -3,5 +3,6 @@ import {defineHistory} from "adnbn"; export default defineHistory({ title: "Custom history", csp: {sources: {connect: ["https://history.example.com"]}}, + permissions: ["history"], render: () => "Custom history", }); diff --git a/src/cli/plugins/override/tests/fixtures/split/src/newtab.ts b/src/cli/plugins/override/tests/fixtures/split/src/newtab.ts index 7fd494c5..581a0c85 100644 --- a/src/cli/plugins/override/tests/fixtures/split/src/newtab.ts +++ b/src/cli/plugins/override/tests/fixtures/split/src/newtab.ts @@ -4,5 +4,9 @@ export default defineNewtab({ title: "Custom newtab", excludeBrowser: ["chrome", "chromium", "edge"], csp: {sources: {connect: ["https://newtab.example.com"]}}, + permissions: ["search"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], render: () => "Custom newtab", }); diff --git a/src/types/override.ts b/src/types/override.ts index 0e3d1c85..d04cba64 100644 --- a/src/types/override.ts +++ b/src/types/override.ts @@ -1,6 +1,7 @@ import {ViewDefinition, ViewOptions} from "@typing/view"; import {CspOptions} from "@typing/csp"; import {EntrypointType} from "@typing/entrypoint"; +import {PermissionsOptions} from "@typing/permissions"; /** * Entrypoints that replace a built-in browser page through `chrome_url_overrides`. @@ -9,7 +10,7 @@ import {EntrypointType} from "@typing/entrypoint"; */ export type OverrideEntrypointType = EntrypointType.Newtab | EntrypointType.Bookmarks | EntrypointType.History; -export type OverrideEntrypointOptions = CspOptions & ViewOptions; +export type OverrideEntrypointOptions = PermissionsOptions & CspOptions & ViewOptions; export type OverrideProps = OverrideEntrypointOptions; diff --git a/tests/integration/README.md b/tests/integration/README.md index 0aa35278..870d5ef6 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -125,7 +125,7 @@ Tests copy application inputs to unique directories under `.cache/integration`. - `types/registries`: generated registry augmentation, empty fallbacks, public and internal type agreement, and message contracts against source and built package APIs. Compiler-host path checks cover both slash styles. - `build/options/embedded`: ten manifest checks covering explicit `openInTab: false` across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. No browser is launched. - `browser/options`: two Chrome MV3 cases covering Vanilla and React rendering, CSS, state/events, opening Options from background, and a View chunk shared with a Page. -- `build/override`: thirty manifest checks covering the New Tab, Bookmarks, and History applications across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. Supporting browsers receive `chrome_url_overrides`, the page HTML, and its CSP; the others receive none of them. Three more cases add the History entrypoint to a copy of the New Tab application: Chrome and Edge builds fail naming both entrypoints, while Firefox keeps the New Tab. No browser is launched. +- `build/override`: thirty manifest checks covering the New Tab, Bookmarks, and History applications across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. Supporting browsers receive `chrome_url_overrides`, the page HTML, its CSP, and the permission declared by the entrypoint; the others receive none of them. Three more cases add the History entrypoint to a copy of the New Tab application: Chrome and Edge builds fail naming both entrypoints, while Firefox keeps the New Tab with its own permission and without the History one. No browser is launched. - `browser/override`: three Chrome MV3 cases opening `chrome://newtab`, `chrome://bookmarks`, and `chrome://history`. They verify that Chrome serves the extension page, React and Vanilla rendering, CSS, state/events, custom `as` and `htmlDir` naming, and a View chunk shared with a Page. - `browser/offscreen/service`: one Chrome MV3 round trip from background through Offscreen to a registered background service. - `browser/content/entrypoint-assets`: one Chrome MV3 case and two Firefox cases (MV2 and MV3) using the same application and probe assertions. They cover current asset getters, rejecting the full-map getter outside background, common chunks, dynamic imports, CSS/SVG resource URLs, world separation, and top/child frames. The Chrome case also reads the full-map readiness flag in background. diff --git a/tests/integration/build/override/bookmarks/src/bookmarks.ts b/tests/integration/build/override/bookmarks/src/bookmarks.ts index 5bb9a9b9..8ba084c7 100644 --- a/tests/integration/build/override/bookmarks/src/bookmarks.ts +++ b/tests/integration/build/override/bookmarks/src/bookmarks.ts @@ -3,5 +3,6 @@ import {defineBookmarks} from "adnbn"; export default defineBookmarks({ title: "Custom bookmarks", csp: {sources: {connect: ["https://bookmarks.example.com"]}}, + permissions: ["bookmarks"], render: "<main>Custom bookmarks</main>", }); diff --git a/tests/integration/build/override/history/src/history.ts b/tests/integration/build/override/history/src/history.ts index befe68a0..6ea5a4c5 100644 --- a/tests/integration/build/override/history/src/history.ts +++ b/tests/integration/build/override/history/src/history.ts @@ -3,5 +3,6 @@ import {defineHistory} from "adnbn"; export default defineHistory({ title: "Custom history", csp: {sources: {connect: ["https://history.example.com"]}}, + permissions: ["history"], render: "<main>Custom history</main>", }); diff --git a/tests/integration/build/override/newtab/src/newtab.ts b/tests/integration/build/override/newtab/src/newtab.ts index 8012c26b..b272ac57 100644 --- a/tests/integration/build/override/newtab/src/newtab.ts +++ b/tests/integration/build/override/newtab/src/newtab.ts @@ -3,5 +3,6 @@ import {defineNewtab} from "adnbn"; export default defineNewtab({ title: "Custom new tab", csp: {sources: {connect: ["https://newtab.example.com"]}}, + permissions: ["search"], render: "<main>Custom new tab</main>", }); diff --git a/tests/integration/build/override/override.integration.test.ts b/tests/integration/build/override/override.integration.test.ts index 5ce1c697..200e8171 100644 --- a/tests/integration/build/override/override.integration.test.ts +++ b/tests/integration/build/override/override.integration.test.ts @@ -30,10 +30,20 @@ const createCompetingFixture = async (): Promise<IntegrationFixture> => { }; describe.each([ - {page: "newtab", supported: ["chrome", "edge", "firefox", "safari"], unsupported: ["opera"]}, - {page: "bookmarks", supported: ["chrome", "edge"], unsupported: ["firefox", "safari", "opera"]}, - {page: "history", supported: ["chrome", "edge"], unsupported: ["firefox", "safari", "opera"]}, -])("$page override", ({page, supported, unsupported}) => { + {page: "newtab", permission: "search", supported: ["chrome", "edge", "firefox", "safari"], unsupported: ["opera"]}, + { + page: "bookmarks", + permission: "bookmarks", + supported: ["chrome", "edge"], + unsupported: ["firefox", "safari", "opera"], + }, + { + page: "history", + permission: "history", + supported: ["chrome", "edge"], + unsupported: ["firefox", "safari", "opera"], + }, +])("$page override", ({page, permission, supported, unsupported}) => { const fixtureDir = path.join(__dirname, page); const source = `https://${page}.example.com`; @@ -49,6 +59,7 @@ describe.each([ expect(manifest.manifest_version).toBe(manifestVersion); expect(manifest.chrome_url_overrides).toEqual({[page]: `${page}.html`}); expect(JSON.stringify(manifest.content_security_policy)).toContain(source); + expect(manifest.permissions).toContain(permission); expect(html).toContain("<title>Custom"); expect(html).toContain("<script"); } finally { @@ -68,6 +79,7 @@ describe.each([ expect(manifest.manifest_version).toBe(manifestVersion); expect(manifest).not.toHaveProperty("chrome_url_overrides"); expect(JSON.stringify(manifest)).not.toContain(source); + expect(manifest.permissions ?? []).not.toContain(permission); expect(await readdir(extensionDir)).not.toContain(`${page}.html`); } finally { await fixture.dispose(); @@ -93,7 +105,7 @@ describe("competing overrides", () => { } }); - test("firefox build keeps the new tab because it does not support the history page", async () => { + test("firefox build keeps the new tab and its permission without the unsupported history page", async () => { const fixture = await createCompetingFixture(); try { @@ -101,6 +113,7 @@ describe("competing overrides", () => { const manifest = await readManifest(extensionDir); expect(manifest.chrome_url_overrides).toEqual({newtab: "newtab.html"}); + expect(manifest.permissions).toEqual(["search"]); expect(await readdir(extensionDir)).not.toContain("history.html"); } finally { await fixture.dispose(); From 90c51b0321ddea864af422aa6889d64e2ce7ce6b Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:36:55 +0300 Subject: [PATCH 10/28] feat(entrypoint): declare permissions in popup, sidebar and options Let popup, sidebar and options entrypoints declare the permissions their code needs. Whatever reaches the build contributes: every built popup or sidebar when several are allowed, because any of them can be switched to at runtime, and the single winning file otherwise. A sidebar that is unavailable for the target requests nothing. Collect the permissions in a view finder layer that only adopting entrypoints extend, next to the CSP layer, and expose one getter per kind typed with the manifest permission types. Plugins pass these values to the manifest builder explicitly; the override plugin follows the same shape and the standalone collecting helper is removed. Cover the adoption in each parser, the selection and caching on real directories, and real builds across Chrome and Firefox in MV2 and MV3. --- .../finder/AbstractOverrideFinder.ts | 6 +- .../finder/AbstractViewFinder.test.ts | 45 +++++++++ src/cli/entrypoint/finder/OptionsFinder.ts | 4 +- src/cli/entrypoint/finder/PopupFinder.ts | 4 +- src/cli/entrypoint/finder/SidebarFinder.ts | 4 +- src/cli/entrypoint/finder/ViewCspFinder.ts | 2 +- .../finder/ViewPermissionsFinder.test.ts | 92 +++++++++++++++++++ .../finder/ViewPermissionsFinder.ts | 64 +++++++++++++ src/cli/entrypoint/finder/index.ts | 2 +- .../tests/fixtures/selection/src/popup.ts | 8 ++ .../fixtures/selection/src/settings.popup.ts | 10 ++ .../tests/fixtures/selection/tsconfig.json | 1 + .../finder/utils/permissions.test.ts | 28 ------ .../entrypoint/finder/utils/permissions.ts | 23 ----- .../entrypoint/parser/OptionsParser.test.ts | 6 +- src/cli/entrypoint/parser/OptionsParser.ts | 3 +- src/cli/entrypoint/parser/PopupParser.test.ts | 6 +- src/cli/entrypoint/parser/PopupParser.ts | 3 +- .../entrypoint/parser/SidebarParser.test.ts | 6 +- src/cli/entrypoint/parser/SidebarParser.ts | 3 +- .../fixtures/options/options/full/options.ts | 4 + .../fixtures/popup/options/full/popup.ts | 4 + .../fixtures/sidebar/options/full/sidebar.ts | 4 + src/cli/plugins/options/index.ts | 8 +- src/cli/plugins/override/Override.test.ts | 17 +++- src/cli/plugins/override/Override.ts | 34 ++++--- src/cli/plugins/override/index.ts | 11 +-- src/cli/plugins/popup/index.ts | 9 +- src/cli/plugins/sidebar/index.ts | 10 +- src/types/manifest.ts | 4 +- src/types/options.ts | 3 +- src/types/permissions.ts | 18 ++-- src/types/popup.ts | 3 +- src/types/sidebar.ts | 3 +- tests/integration/README.md | 4 + .../permissions.integration.test.ts | 60 ++++++++++++ .../build/permissions/views/adnbn.config.ts | 9 ++ .../build/permissions/views/package.json | 9 ++ .../permissions/views/src/notes.sidebar.ts | 9 ++ .../build/permissions/views/src/options.ts | 9 ++ .../build/permissions/views/src/popup.ts | 7 ++ .../permissions/views/src/settings.popup.ts | 9 ++ .../build/permissions/views/src/sidebar.ts | 7 ++ .../build/permissions/views/tsconfig.json | 3 + 44 files changed, 470 insertions(+), 108 deletions(-) create mode 100644 src/cli/entrypoint/finder/AbstractViewFinder.test.ts create mode 100644 src/cli/entrypoint/finder/ViewPermissionsFinder.test.ts create mode 100644 src/cli/entrypoint/finder/ViewPermissionsFinder.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/selection/src/popup.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/selection/src/settings.popup.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/selection/tsconfig.json delete mode 100644 src/cli/entrypoint/finder/utils/permissions.test.ts delete mode 100644 src/cli/entrypoint/finder/utils/permissions.ts create mode 100644 tests/integration/build/permissions/permissions.integration.test.ts create mode 100644 tests/integration/build/permissions/views/adnbn.config.ts create mode 100644 tests/integration/build/permissions/views/package.json create mode 100644 tests/integration/build/permissions/views/src/notes.sidebar.ts create mode 100644 tests/integration/build/permissions/views/src/options.ts create mode 100644 tests/integration/build/permissions/views/src/popup.ts create mode 100644 tests/integration/build/permissions/views/src/settings.popup.ts create mode 100644 tests/integration/build/permissions/views/src/sidebar.ts create mode 100644 tests/integration/build/permissions/views/tsconfig.json diff --git a/src/cli/entrypoint/finder/AbstractOverrideFinder.ts b/src/cli/entrypoint/finder/AbstractOverrideFinder.ts index a89196af..9e6a9a87 100644 --- a/src/cli/entrypoint/finder/AbstractOverrideFinder.ts +++ b/src/cli/entrypoint/finder/AbstractOverrideFinder.ts @@ -1,4 +1,4 @@ -import ViewCspFinder from "./ViewCspFinder"; +import ViewPermissionsFinder from "./ViewPermissionsFinder"; import PluginFinder from "./PluginFinder"; import {ReadonlyConfig} from "@typing/config"; @@ -9,7 +9,9 @@ import {OverrideEntrypointOptions, OverrideEntrypointType} from "@typing/overrid * Override pages are singletons: app files replace shared ones instead of merging with them, * and only the highest-precedence candidate becomes a view. */ -export default abstract class AbstractOverrideFinder<O extends OverrideEntrypointOptions> extends ViewCspFinder<O> { +export default abstract class AbstractOverrideFinder< + O extends OverrideEntrypointOptions, +> extends ViewPermissionsFinder<O> { protected constructor(config: ReadonlyConfig) { super(config); } diff --git a/src/cli/entrypoint/finder/AbstractViewFinder.test.ts b/src/cli/entrypoint/finder/AbstractViewFinder.test.ts new file mode 100644 index 00000000..d8c9e2ef --- /dev/null +++ b/src/cli/entrypoint/finder/AbstractViewFinder.test.ts @@ -0,0 +1,45 @@ +import path from "path"; + +import PopupFinder from "./PopupFinder"; + +import type {ReadonlyConfig} from "@typing/config"; + +const rootDir = path.resolve(__dirname, "tests", "fixtures", "selection"); + +const makeFinder = (multiplePopup: boolean): PopupFinder => { + const config = { + app: "myapp", + appSrcDir: ".", + appsDir: "apps", + debug: false, + htmlDir: ".", + mergePopup: false, + multiplePopup, + plugins: [], + rootDir, + sharedDir: ".", + srcDir: "src", + } as Partial<ReadonlyConfig> as ReadonlyConfig; + + const finder = new PopupFinder(config); + + config.plugins.push({name: "adnbn:popup", popup: () => finder.files()}); + + return finder; +}; + +describe("AbstractViewFinder selected options", () => { + test("reports every built view when several are allowed, including one that is not applied by default", async () => { + await expect(makeFinder(true).selectedOptions()).resolves.toMatchObject([ + {title: "Main popup"}, + {title: "Settings popup", apply: false}, + ]); + }); + + test("reports only the winning candidate when a single view is allowed", async () => { + const selected = await makeFinder(false).selectedOptions(); + + expect(selected).toHaveLength(1); + expect(selected).toMatchObject([{title: "Settings popup"}]); + }); +}); diff --git a/src/cli/entrypoint/finder/OptionsFinder.ts b/src/cli/entrypoint/finder/OptionsFinder.ts index c79689ad..5c142145 100644 --- a/src/cli/entrypoint/finder/OptionsFinder.ts +++ b/src/cli/entrypoint/finder/OptionsFinder.ts @@ -1,4 +1,4 @@ -import ViewCspFinder from "./ViewCspFinder"; +import ViewPermissionsFinder from "./ViewPermissionsFinder"; import PluginFinder from "./PluginFinder"; import {OptionsParser} from "../parser"; @@ -8,7 +8,7 @@ import type {ReadonlyConfig} from "@typing/config"; import type {OptionsEntrypointOptions} from "@typing/options"; import {EntrypointOptionsFinder, EntrypointParser, EntrypointType} from "@typing/entrypoint"; -export default class extends ViewCspFinder<OptionsEntrypointOptions> { +export default class extends ViewPermissionsFinder<OptionsEntrypointOptions> { public constructor(config: ReadonlyConfig) { super(config); } diff --git a/src/cli/entrypoint/finder/PopupFinder.ts b/src/cli/entrypoint/finder/PopupFinder.ts index befb88e4..0a0cc6ca 100644 --- a/src/cli/entrypoint/finder/PopupFinder.ts +++ b/src/cli/entrypoint/finder/PopupFinder.ts @@ -1,4 +1,4 @@ -import ViewCspFinder from "./ViewCspFinder"; +import ViewPermissionsFinder from "./ViewPermissionsFinder"; import PluginFinder from "./PluginFinder"; import {PopupParser} from "../parser"; @@ -7,7 +7,7 @@ import {ReadonlyConfig} from "@typing/config"; import {PopupEntrypointOptions} from "@typing/popup"; import {EntrypointOptionsFinder, EntrypointParser, EntrypointType} from "@typing/entrypoint"; -export default class extends ViewCspFinder<PopupEntrypointOptions> { +export default class extends ViewPermissionsFinder<PopupEntrypointOptions> { constructor(config: ReadonlyConfig) { super(config); } diff --git a/src/cli/entrypoint/finder/SidebarFinder.ts b/src/cli/entrypoint/finder/SidebarFinder.ts index a9f4f1b2..32d18ab7 100644 --- a/src/cli/entrypoint/finder/SidebarFinder.ts +++ b/src/cli/entrypoint/finder/SidebarFinder.ts @@ -1,4 +1,4 @@ -import ViewCspFinder from "./ViewCspFinder"; +import ViewPermissionsFinder from "./ViewPermissionsFinder"; import PluginFinder from "./PluginFinder"; import {SidebarParser} from "../parser"; @@ -7,7 +7,7 @@ import {ReadonlyConfig} from "@typing/config"; import {SidebarEntrypointOptions} from "@typing/sidebar"; import {EntrypointOptionsFinder, EntrypointParser, EntrypointType} from "@typing/entrypoint"; -export default class extends ViewCspFinder<SidebarEntrypointOptions> { +export default class extends ViewPermissionsFinder<SidebarEntrypointOptions> { constructor(config: ReadonlyConfig) { super(config); } diff --git a/src/cli/entrypoint/finder/ViewCspFinder.ts b/src/cli/entrypoint/finder/ViewCspFinder.ts index 5f7d6f2a..996129ae 100644 --- a/src/cli/entrypoint/finder/ViewCspFinder.ts +++ b/src/cli/entrypoint/finder/ViewCspFinder.ts @@ -3,7 +3,7 @@ import AbstractViewFinder, {ViewItems} from "./AbstractViewFinder"; import type {CspConfig} from "@typing/csp"; import type {ViewEntrypointOptions} from "@typing/view"; -type CspEntrypointOptions = ViewEntrypointOptions & {csp?: unknown}; +export type CspEntrypointOptions = ViewEntrypointOptions & {csp?: unknown}; export default abstract class<O extends CspEntrypointOptions, Csp = CspConfig> extends AbstractViewFinder<O> { protected _csp?: Csp[]; diff --git a/src/cli/entrypoint/finder/ViewPermissionsFinder.test.ts b/src/cli/entrypoint/finder/ViewPermissionsFinder.test.ts new file mode 100644 index 00000000..01c3b654 --- /dev/null +++ b/src/cli/entrypoint/finder/ViewPermissionsFinder.test.ts @@ -0,0 +1,92 @@ +import {cp, mkdtemp, rm} from "fs/promises"; +import os from "os"; +import path from "path"; + +import PopupFinder from "./PopupFinder"; + +import type {ReadonlyConfig} from "@typing/config"; + +const fixtures = path.resolve(__dirname, "tests", "fixtures"); + +const makeFinder = (rootDir: string, overrides: Partial<ReadonlyConfig> = {}): PopupFinder => { + const config = { + app: "myapp", + appSrcDir: ".", + appsDir: "apps", + debug: false, + htmlDir: ".", + mergePopup: false, + multiplePopup: false, + plugins: [], + rootDir, + sharedDir: ".", + srcDir: "src", + ...overrides, + } as Partial<ReadonlyConfig> as ReadonlyConfig; + + const finder = new PopupFinder(config); + + config.plugins.push({name: "adnbn:popup", popup: () => finder.files()}); + + return finder; +}; + +const read = async (finder: PopupFinder) => ({ + permissions: await finder.permissions(), + optionalPermissions: await finder.optionalPermissions(), + hostPermissions: await finder.hostPermissions(), + optionalHostPermissions: await finder.optionalHostPermissions(), +}); + +describe("ViewPermissionsFinder", () => { + const selection = path.join(fixtures, "selection"); + + test("unites the permissions of every built view without duplicates", async () => { + await expect(read(makeFinder(selection, {multiplePopup: true}))).resolves.toEqual({ + permissions: new Set(["tabs", "storage", "downloads"]), + optionalPermissions: new Set(["topSites"]), + hostPermissions: new Set(["https://*.example.com/*"]), + optionalHostPermissions: new Set(["https://other.test/*"]), + }); + }); + + test("takes the permissions of the winning view only when a single view is allowed", async () => { + await expect(read(makeFinder(selection))).resolves.toEqual({ + permissions: new Set(["tabs", "downloads"]), + optionalPermissions: new Set(["topSites"]), + hostPermissions: new Set(), + optionalHostPermissions: new Set(["https://other.test/*"]), + }); + }); + + test("returns empty sets when the built views declare no permissions", async () => { + const project = path.join(fixtures, "precedence", "project"); + + await expect(read(makeFinder(project, {sharedDir: "shared"}))).resolves.toEqual({ + permissions: new Set(), + optionalPermissions: new Set(), + hostPermissions: new Set(), + optionalHostPermissions: new Set(), + }); + }); + + test("clear drops the permissions of a view that no longer exists", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "adnbn-view-permissions-")); + + try { + await cp(selection, rootDir, {recursive: true}); + + const finder = makeFinder(rootDir, {multiplePopup: true}); + + await expect(finder.permissions()).resolves.toEqual(new Set(["tabs", "storage", "downloads"])); + + await rm(path.join(rootDir, "src", "settings.popup.ts")); + + await expect(finder.permissions()).resolves.toEqual(new Set(["tabs", "storage", "downloads"])); + await expect(finder.clear().permissions()).resolves.toEqual(new Set(["tabs", "storage"])); + await expect(finder.optionalPermissions()).resolves.toEqual(new Set()); + } finally { + await rm(rootDir, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } + }); +}); diff --git a/src/cli/entrypoint/finder/ViewPermissionsFinder.ts b/src/cli/entrypoint/finder/ViewPermissionsFinder.ts new file mode 100644 index 00000000..47b9aaf6 --- /dev/null +++ b/src/cli/entrypoint/finder/ViewPermissionsFinder.ts @@ -0,0 +1,64 @@ +import ViewCspFinder, {type CspEntrypointOptions} from "./ViewCspFinder"; + +import type {CspConfig} from "@typing/csp"; +import type {ManifestHostPermissions, ManifestOptionalPermissions, ManifestPermissions} from "@typing/manifest"; +import type {EntrypointPermissions, PermissionsOptions} from "@typing/permissions"; + +type PermissionsEntrypointOptions = CspEntrypointOptions & PermissionsOptions; + +/** + * Views that opt into the permissions contract. + * + * Only views that reach the build contribute: every built view when several are allowed, + * because any of them can be switched to at runtime, and the single winner otherwise. + */ +export default abstract class ViewPermissionsFinder< + O extends PermissionsEntrypointOptions, + Csp = CspConfig, +> extends ViewCspFinder<O, Csp> { + protected _permissions?: Promise<EntrypointPermissions>; + + public async permissions(): Promise<ManifestPermissions> { + return (await this.getPermissions()).permissions; + } + + public async optionalPermissions(): Promise<ManifestOptionalPermissions> { + return (await this.getPermissions()).optionalPermissions; + } + + public async hostPermissions(): Promise<ManifestHostPermissions> { + return (await this.getPermissions()).hostPermissions; + } + + public async optionalHostPermissions(): Promise<ManifestHostPermissions> { + return (await this.getPermissions()).optionalHostPermissions; + } + + protected getPermissions(): Promise<EntrypointPermissions> { + return (this._permissions ??= this.collectPermissions()); + } + + protected async collectPermissions(): Promise<EntrypointPermissions> { + const collected: EntrypointPermissions = { + permissions: new Set(), + optionalPermissions: new Set(), + hostPermissions: new Set(), + optionalHostPermissions: new Set(), + }; + + for (const options of await this.selectedOptions()) { + options.permissions?.forEach(permission => collected.permissions.add(permission)); + options.optionalPermissions?.forEach(permission => collected.optionalPermissions.add(permission)); + options.hostPermissions?.forEach(permission => collected.hostPermissions.add(permission)); + options.optionalHostPermissions?.forEach(permission => collected.optionalHostPermissions.add(permission)); + } + + return collected; + } + + public clear(): this { + this._permissions = undefined; + + return super.clear(); + } +} diff --git a/src/cli/entrypoint/finder/index.ts b/src/cli/entrypoint/finder/index.ts index 5a09dde9..ed27cfee 100644 --- a/src/cli/entrypoint/finder/index.ts +++ b/src/cli/entrypoint/finder/index.ts @@ -10,6 +10,7 @@ export { type ViewFileToFilename, } from "./AbstractViewFinder"; export {default as ViewCspFinder} from "./ViewCspFinder"; +export {default as ViewPermissionsFinder} from "./ViewPermissionsFinder"; export {default as AbstractParsedFinder} from "./AbstractParsedFinder"; export {default as BackgroundFinder} from "./BackgroundFinder"; export {default as BookmarksFinder} from "./BookmarksFinder"; @@ -30,4 +31,3 @@ export {default as SandboxViewFinder} from "./SandboxViewFinder"; export {default as ServiceFinder} from "./ServiceFinder"; export {default as PluginFinder} from "./PluginFinder"; export {default as SidebarFinder} from "./SidebarFinder"; -export {collectPermissions} from "./utils/permissions"; diff --git a/src/cli/entrypoint/finder/tests/fixtures/selection/src/popup.ts b/src/cli/entrypoint/finder/tests/fixtures/selection/src/popup.ts new file mode 100644 index 00000000..7cc89709 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/selection/src/popup.ts @@ -0,0 +1,8 @@ +import {definePopup} from "adnbn"; + +export default definePopup({ + title: "Main popup", + permissions: ["tabs", "storage"], + hostPermissions: ["https://*.example.com/*"], + render: () => "Main popup", +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/selection/src/settings.popup.ts b/src/cli/entrypoint/finder/tests/fixtures/selection/src/settings.popup.ts new file mode 100644 index 00000000..a8daf071 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/selection/src/settings.popup.ts @@ -0,0 +1,10 @@ +import {definePopup} from "adnbn"; + +export default definePopup({ + title: "Settings popup", + apply: false, + permissions: ["tabs", "downloads"], + optionalPermissions: ["topSites"], + optionalHostPermissions: ["https://other.test/*"], + render: () => "Settings popup", +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/selection/tsconfig.json b/src/cli/entrypoint/finder/tests/fixtures/selection/tsconfig.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/selection/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/src/cli/entrypoint/finder/utils/permissions.test.ts b/src/cli/entrypoint/finder/utils/permissions.test.ts deleted file mode 100644 index ca8d29ef..00000000 --- a/src/cli/entrypoint/finder/utils/permissions.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {collectPermissions} from "./permissions"; - -describe("collectPermissions", () => { - test("unites every kind of permission across entrypoints without duplicates", () => { - expect( - collectPermissions([ - {permissions: ["storage", "tabs"], hostPermissions: ["https://*.example.com/*"]}, - {permissions: ["tabs", "history"], optionalPermissions: ["topSites"]}, - {optionalHostPermissions: ["https://other.test/*"], hostPermissions: ["https://*.example.com/*"]}, - ]) - ).toEqual({ - permissions: new Set(["storage", "tabs", "history"]), - optionalPermissions: new Set(["topSites"]), - hostPermissions: new Set(["https://*.example.com/*"]), - optionalHostPermissions: new Set(["https://other.test/*"]), - }); - }); - - test("returns empty sets when no entrypoint declares permissions", () => { - expect(collectPermissions([{}, {}])).toEqual({ - permissions: new Set(), - optionalPermissions: new Set(), - hostPermissions: new Set(), - optionalHostPermissions: new Set(), - }); - expect(collectPermissions([])).toEqual(collectPermissions([{}])); - }); -}); diff --git a/src/cli/entrypoint/finder/utils/permissions.ts b/src/cli/entrypoint/finder/utils/permissions.ts deleted file mode 100644 index 58e3f107..00000000 --- a/src/cli/entrypoint/finder/utils/permissions.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type {EntrypointPermissions, PermissionsOptions} from "@typing/permissions"; - -/** - * Unites the permissions declared by entrypoints that reach the build. - * Pass only the options of those entrypoints: candidates that lost the selection must not contribute. - */ -export const collectPermissions = (options: Iterable<PermissionsOptions>): EntrypointPermissions => { - const collected: EntrypointPermissions = { - permissions: new Set(), - optionalPermissions: new Set(), - hostPermissions: new Set(), - optionalHostPermissions: new Set(), - }; - - for (const {permissions, optionalPermissions, hostPermissions, optionalHostPermissions} of options) { - permissions?.forEach(permission => collected.permissions.add(permission)); - optionalPermissions?.forEach(permission => collected.optionalPermissions.add(permission)); - hostPermissions?.forEach(permission => collected.hostPermissions.add(permission)); - optionalHostPermissions?.forEach(permission => collected.optionalHostPermissions.add(permission)); - } - - return collected; -}; diff --git a/src/cli/entrypoint/parser/OptionsParser.test.ts b/src/cli/entrypoint/parser/OptionsParser.test.ts index 27f2f38a..081e3f66 100644 --- a/src/cli/entrypoint/parser/OptionsParser.test.ts +++ b/src/cli/entrypoint/parser/OptionsParser.test.ts @@ -21,7 +21,7 @@ const file = (...parts: string[]) => { const parseOptions = (...parts: string[]) => parser.options(file(...parts)); describe("OptionsParser", () => { - test("parses defineOptions with inherited view, CSP and build filters", () => { + test("parses defineOptions with permissions and inherited view, CSP and build filters", () => { expect(parseOptions("options", "full", "options.ts")).toEqual({ openInTab: true, as: "settings", @@ -42,6 +42,10 @@ describe("OptionsParser", () => { style: ["'self'", "'unsafe-inline'"], }, }, + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], scripts: "extra.js", links: "extra.css", metas: { diff --git a/src/cli/entrypoint/parser/OptionsParser.ts b/src/cli/entrypoint/parser/OptionsParser.ts index 3ea72173..99c1bbda 100644 --- a/src/cli/entrypoint/parser/OptionsParser.ts +++ b/src/cli/entrypoint/parser/OptionsParser.ts @@ -1,6 +1,7 @@ import {z} from "zod"; import ViewCspParser from "./ViewCspParser"; +import {PermissionsSchema} from "./schemas/permissions"; import {OptionsEntrypointOptions} from "@typing/options"; @@ -10,7 +11,7 @@ export default class extends ViewCspParser<OptionsEntrypointOptions> { } protected schema(): typeof this.CommonPropertiesSchema { - return super.schema().extend({ + return super.schema().merge(PermissionsSchema).extend({ openInTab: z.boolean().optional(), }); } diff --git a/src/cli/entrypoint/parser/PopupParser.test.ts b/src/cli/entrypoint/parser/PopupParser.test.ts index 9526127d..a6fbb0e5 100644 --- a/src/cli/entrypoint/parser/PopupParser.test.ts +++ b/src/cli/entrypoint/parser/PopupParser.test.ts @@ -16,7 +16,7 @@ const parseOptions = (...parts: string[]) => { }; describe("PopupParser", () => { - test("parses definePopup with its icon, apply flag and inherited view and CSP options", () => { + test("parses definePopup with its icon, apply flag, permissions and inherited view and CSP options", () => { expect(parseOptions("options", "full", "popup.ts")).toEqual({ as: "panel", title: "Extension panel", @@ -25,6 +25,10 @@ describe("PopupParser", () => { apply: false, includeBrowser: ["chrome"], csp: {sources: {connect: ["'self'", "https://api.example.com"]}}, + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], links: "extra.css", }); }); diff --git a/src/cli/entrypoint/parser/PopupParser.ts b/src/cli/entrypoint/parser/PopupParser.ts index 24f0412c..e0da962f 100644 --- a/src/cli/entrypoint/parser/PopupParser.ts +++ b/src/cli/entrypoint/parser/PopupParser.ts @@ -1,6 +1,7 @@ import {z} from "zod"; import ViewCspParser from "./ViewCspParser"; +import {PermissionsSchema} from "./schemas/permissions"; import {PopupEntrypointOptions} from "@typing/popup"; @@ -10,7 +11,7 @@ export default class extends ViewCspParser<PopupEntrypointOptions> { } protected schema(): typeof this.CommonPropertiesSchema { - return super.schema().extend({ + return super.schema().merge(PermissionsSchema).extend({ icon: z.string().nonempty().optional(), apply: z.boolean().optional(), }); diff --git a/src/cli/entrypoint/parser/SidebarParser.test.ts b/src/cli/entrypoint/parser/SidebarParser.test.ts index f43a82ea..a9afa0c6 100644 --- a/src/cli/entrypoint/parser/SidebarParser.test.ts +++ b/src/cli/entrypoint/parser/SidebarParser.test.ts @@ -16,7 +16,7 @@ const parseOptions = (...parts: string[]) => { }; describe("SidebarParser", () => { - test("parses defineSidebar with its icon, apply flag and inherited view and CSP options", () => { + test("parses defineSidebar with its icon, apply flag, permissions and inherited view and CSP options", () => { expect(parseOptions("options", "full", "sidebar.ts")).toEqual({ as: "panel", title: "Extension panel", @@ -25,6 +25,10 @@ describe("SidebarParser", () => { apply: false, includeBrowser: ["chrome"], csp: {sources: {connect: ["'self'", "https://api.example.com"]}}, + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], links: "extra.css", }); }); diff --git a/src/cli/entrypoint/parser/SidebarParser.ts b/src/cli/entrypoint/parser/SidebarParser.ts index fe357752..72a36eda 100644 --- a/src/cli/entrypoint/parser/SidebarParser.ts +++ b/src/cli/entrypoint/parser/SidebarParser.ts @@ -1,6 +1,7 @@ import {z} from "zod"; import ViewCspParser from "./ViewCspParser"; +import {PermissionsSchema} from "./schemas/permissions"; import {SidebarEntrypointOptions} from "@typing/sidebar"; @@ -10,7 +11,7 @@ export default class extends ViewCspParser<SidebarEntrypointOptions> { } protected schema(): typeof this.CommonPropertiesSchema { - return super.schema().extend({ + return super.schema().merge(PermissionsSchema).extend({ icon: z.string().nonempty().optional(), apply: z.boolean().optional(), }); diff --git a/src/cli/entrypoint/parser/tests/fixtures/options/options/full/options.ts b/src/cli/entrypoint/parser/tests/fixtures/options/options/full/options.ts index 554bfe91..fb9ae3b5 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/options/options/full/options.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/options/options/full/options.ts @@ -22,6 +22,10 @@ export default defineOptions({ style: [CspSource.Self, CspSource.UnsafeInline], }, }, + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], scripts: "extra.js", links: "extra.css", metas: { diff --git a/src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts b/src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts index b1657caf..f291ec63 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts @@ -8,6 +8,10 @@ export default definePopup({ apply: false, includeBrowser: [Browser.Chrome], csp: {sources: {connect: [CspSource.Self, "https://api.example.com"]}}, + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], links: "extra.css", render: ({title}) => title, }); diff --git a/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts b/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts index 22be8c57..e7e1eb80 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts @@ -8,6 +8,10 @@ export default defineSidebar({ apply: false, includeBrowser: [Browser.Chrome], csp: {sources: {connect: [CspSource.Self, "https://api.example.com"]}}, + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], links: "extra.css", render: ({title}) => title, }); diff --git a/src/cli/plugins/options/index.ts b/src/cli/plugins/options/index.ts index 2d7289d3..f0d5ab4f 100644 --- a/src/cli/plugins/options/index.ts +++ b/src/cli/plugins/options/index.ts @@ -41,7 +41,13 @@ export default definePlugin(() => { } satisfies RspackConfig; }, manifest: async ({manifest}) => { - manifest.setOptions(await options.manifest()).appendCsp(await options.csp()); + manifest + .setOptions(await options.manifest()) + .appendCsp(await options.csp()) + .appendPermissions(await options.permissions()) + .appendOptionalPermissions(await options.optionalPermissions()) + .appendHostPermissions(await options.hostPermissions()) + .appendOptionalHostPermissions(await options.optionalHostPermissions()); }, }; }); diff --git a/src/cli/plugins/override/Override.test.ts b/src/cli/plugins/override/Override.test.ts index e7a91753..65704be9 100644 --- a/src/cli/plugins/override/Override.test.ts +++ b/src/cli/plugins/override/Override.test.ts @@ -40,6 +40,13 @@ const makeOverride = (fixture: string, config: Partial<ReadonlyConfig> = {}): Ov const chromium = [Browser.Chrome, Browser.Chromium, Browser.Edge]; +const readPermissions = async (override: Override) => ({ + permissions: await override.permissions(), + optionalPermissions: await override.optionalPermissions(), + hostPermissions: await override.hostPermissions(), + optionalHostPermissions: await override.optionalHostPermissions(), +}); + const none = { permissions: new Set(), optionalPermissions: new Set(), @@ -70,7 +77,7 @@ describe.each([ await expect(override.manifest()).resolves.toEqual({page, path: `${page}.html`}); await expect(override.csp()).resolves.toEqual([{sources: {connect: [`https://${page}.example.com`]}}]); - await expect(override.permissions()).resolves.toEqual(declared[page]); + await expect(readPermissions(override)).resolves.toEqual(declared[page]); await expect(view?.entries()).resolves.toEqual(new Map([[page, new Set([expect.objectContaining(entry)])]])); await expect(view?.html()).resolves.toMatchObject([ {filename: `${page}.html`, title: `Custom ${page}`, chunks: [page]}, @@ -83,7 +90,7 @@ describe.each([ await expect(override.view()).resolves.toBeUndefined(); await expect(override.manifest()).resolves.toBeUndefined(); await expect(override.csp()).resolves.toEqual([]); - await expect(override.permissions()).resolves.toEqual(none); + await expect(readPermissions(override)).resolves.toEqual(none); }); }); @@ -101,7 +108,7 @@ describe("Override with competing entrypoints", () => { await expect(override.view()).rejects.toThrow(message); await expect(override.manifest()).rejects.toThrow(message); await expect(override.csp()).rejects.toThrow(message); - await expect(override.permissions()).rejects.toThrow(message); + await expect(readPermissions(override)).rejects.toThrow(message); }); test.each([Browser.Firefox, Browser.Safari])( @@ -112,7 +119,7 @@ describe("Override with competing entrypoints", () => { await expect(override.manifest()).resolves.toEqual({page: "newtab", path: "newtab.html"}); await expect(override.csp()).resolves.toEqual([{sources: {connect: ["https://newtab.example.com"]}}]); // The skipped History page must not request its browsing-history permission. - await expect(override.permissions()).resolves.toEqual(declared.newtab); + await expect(readPermissions(override)).resolves.toEqual(declared.newtab); } ); @@ -135,7 +142,7 @@ describe("Override without entrypoints", () => { await expect(override.view()).resolves.toBeUndefined(); await expect(override.manifest()).resolves.toBeUndefined(); await expect(override.csp()).resolves.toEqual([]); - await expect(override.permissions()).resolves.toEqual(none); + await expect(readPermissions(override)).resolves.toEqual(none); }); }); diff --git a/src/cli/plugins/override/Override.ts b/src/cli/plugins/override/Override.ts index 8d78be5b..635c3483 100644 --- a/src/cli/plugins/override/Override.ts +++ b/src/cli/plugins/override/Override.ts @@ -2,13 +2,7 @@ import path from "path"; import View from "../view/View"; -import { - AbstractOverrideFinder, - BookmarksFinder, - collectPermissions, - HistoryFinder, - NewtabFinder, -} from "@cli/entrypoint"; +import {AbstractOverrideFinder, BookmarksFinder, HistoryFinder, NewtabFinder} from "@cli/entrypoint"; import {resolveRootPath} from "@cli/resolvers/path"; import {toPosix} from "@cli/utils/path"; @@ -16,9 +10,13 @@ import {Browser} from "@typing/browser"; import {EntrypointFile, EntrypointType} from "@typing/entrypoint"; import type {ReadonlyConfig} from "@typing/config"; import type {CspConfig} from "@typing/csp"; -import type {ManifestOverride} from "@typing/manifest"; +import type { + ManifestHostPermissions, + ManifestOptionalPermissions, + ManifestOverride, + ManifestPermissions, +} from "@typing/manifest"; import type {OverrideEntrypointOptions, OverrideEntrypointType} from "@typing/override"; -import type {EntrypointPermissions} from "@typing/permissions"; type OverrideFinder = AbstractOverrideFinder<OverrideEntrypointOptions>; @@ -76,11 +74,21 @@ export default class Override { return finder ? finder.csp() : []; } - /** Permissions of the override that reaches the build; skipped and losing candidates contribute nothing. */ - public async permissions(): Promise<EntrypointPermissions> { - const finder = await this.finder(); + // Permissions of the override that reaches the build; skipped and losing candidates contribute nothing. + public async permissions(): Promise<ManifestPermissions> { + return (await (await this.finder())?.permissions()) ?? new Set(); + } + + public async optionalPermissions(): Promise<ManifestOptionalPermissions> { + return (await (await this.finder())?.optionalPermissions()) ?? new Set(); + } + + public async hostPermissions(): Promise<ManifestHostPermissions> { + return (await (await this.finder())?.hostPermissions()) ?? new Set(); + } - return collectPermissions(finder ? await finder.selectedOptions() : []); + public async optionalHostPermissions(): Promise<ManifestHostPermissions> { + return (await (await this.finder())?.optionalHostPermissions()) ?? new Set(); } public clear(): this { diff --git a/src/cli/plugins/override/index.ts b/src/cli/plugins/override/index.ts index 152e9575..25495b0a 100644 --- a/src/cli/plugins/override/index.ts +++ b/src/cli/plugins/override/index.ts @@ -46,16 +46,13 @@ export default definePlugin(() => { } satisfies RspackConfig; }, manifest: async ({manifest}) => { - const {permissions, optionalPermissions, hostPermissions, optionalHostPermissions} = - await override.permissions(); - manifest .setOverride(await override.manifest()) .appendCsp(await override.csp()) - .appendPermissions(permissions) - .appendOptionalPermissions(optionalPermissions) - .appendHostPermissions(hostPermissions) - .appendOptionalHostPermissions(optionalHostPermissions); + .appendPermissions(await override.permissions()) + .appendOptionalPermissions(await override.optionalPermissions()) + .appendHostPermissions(await override.hostPermissions()) + .appendOptionalHostPermissions(await override.optionalHostPermissions()); }, }; }); diff --git a/src/cli/plugins/popup/index.ts b/src/cli/plugins/popup/index.ts index 86e97e93..7ed53b55 100644 --- a/src/cli/plugins/popup/index.ts +++ b/src/cli/plugins/popup/index.ts @@ -66,7 +66,14 @@ export default definePlugin(() => { } as RspackConfig; }, manifest: async ({manifest}) => { - manifest.setPopup(await popup.manifest()).appendCsp(await popup.csp()); + // Every built popup can be switched to at runtime, so all of them contribute their permissions. + manifest + .setPopup(await popup.manifest()) + .appendCsp(await popup.csp()) + .appendPermissions(await popup.permissions()) + .appendOptionalPermissions(await popup.optionalPermissions()) + .appendHostPermissions(await popup.hostPermissions()) + .appendOptionalHostPermissions(await popup.optionalHostPermissions()); }, }; }); diff --git a/src/cli/plugins/sidebar/index.ts b/src/cli/plugins/sidebar/index.ts index 2a1bc9e6..265a33fc 100644 --- a/src/cli/plugins/sidebar/index.ts +++ b/src/cli/plugins/sidebar/index.ts @@ -87,8 +87,14 @@ export default definePlugin(() => { return; } - manifest.setSidebar(await sidebar.manifest()); - manifest.appendCsp(await sidebar.csp()); + // Every built sidebar can be switched to at runtime, so all of them contribute their permissions. + manifest + .setSidebar(await sidebar.manifest()) + .appendCsp(await sidebar.csp()) + .appendPermissions(await sidebar.permissions()) + .appendOptionalPermissions(await sidebar.optionalPermissions()) + .appendHostPermissions(await sidebar.hostPermissions()) + .appendOptionalHostPermissions(await sidebar.optionalHostPermissions()); if ((await sidebar.exists()) && !SidebarAlternativeBrowsers.has(config.browser)) { manifest.addPermission("sidePanel"); diff --git a/src/types/manifest.ts b/src/types/manifest.ts index 1953ca12..bcbb1a66 100644 --- a/src/types/manifest.ts +++ b/src/types/manifest.ts @@ -29,9 +29,9 @@ type ManifestCommon = chrome.runtime.Manifest; type ManifestBase = chrome.runtime.ManifestBase; -type ManifestPermission = chrome.runtime.ManifestPermission; +export type ManifestPermission = chrome.runtime.ManifestPermission; -type ManifestOptionalPermission = chrome.runtime.ManifestOptionalPermission; +export type ManifestOptionalPermission = chrome.runtime.ManifestOptionalPermission; type ManifestFixed<T extends ManifestBase> = Omit<T, "manifest_version"> & { manifest_version: ManifestVersion; diff --git a/src/types/options.ts b/src/types/options.ts index 59ec4b0e..4927b597 100644 --- a/src/types/options.ts +++ b/src/types/options.ts @@ -1,12 +1,13 @@ import {ViewDefinition, ViewOptions} from "@typing/view"; import {CspOptions} from "@typing/csp"; +import {PermissionsOptions} from "@typing/permissions"; export interface OptionsConfig { /** Open in a browser tab. Defaults to true; embedded mode depends on browser support. */ openInTab?: boolean; } -export type OptionsEntrypointOptions = OptionsConfig & CspOptions & ViewOptions; +export type OptionsEntrypointOptions = OptionsConfig & PermissionsOptions & CspOptions & ViewOptions; export type OptionsProps = OptionsEntrypointOptions; diff --git a/src/types/permissions.ts b/src/types/permissions.ts index f3592aa8..e8ba2cd0 100644 --- a/src/types/permissions.ts +++ b/src/types/permissions.ts @@ -1,6 +1,10 @@ -type ManifestPermission = chrome.runtime.ManifestPermission; - -type ManifestOptionalPermission = chrome.runtime.ManifestOptionalPermission; +import type { + ManifestHostPermissions, + ManifestOptionalPermission, + ManifestOptionalPermissions, + ManifestPermission, + ManifestPermissions, +} from "@typing/manifest"; /** * Manifest permissions required by an entrypoint. @@ -39,8 +43,8 @@ export interface PermissionsOptions { * Permissions collected from the entrypoints that reach a build, ready to be appended to the manifest. */ export interface EntrypointPermissions { - permissions: Set<ManifestPermission>; - optionalPermissions: Set<ManifestOptionalPermission>; - hostPermissions: Set<string>; - optionalHostPermissions: Set<string>; + permissions: ManifestPermissions; + optionalPermissions: ManifestOptionalPermissions; + hostPermissions: ManifestHostPermissions; + optionalHostPermissions: ManifestHostPermissions; } diff --git a/src/types/popup.ts b/src/types/popup.ts index b4775409..1d11b310 100644 --- a/src/types/popup.ts +++ b/src/types/popup.ts @@ -1,5 +1,6 @@ import {ViewDefinition, ViewOptions} from "@typing/view"; import {CspOptions} from "@typing/csp"; +import {PermissionsOptions} from "@typing/permissions"; import type {ManifestPopup} from "@typing/manifest"; /** @@ -17,7 +18,7 @@ export interface PopupConfig { apply?: boolean; } -export type PopupEntrypointOptions = PopupConfig & CspOptions & ViewOptions; +export type PopupEntrypointOptions = PopupConfig & PermissionsOptions & CspOptions & ViewOptions; export type PopupProps = PopupEntrypointOptions; diff --git a/src/types/sidebar.ts b/src/types/sidebar.ts index ce2e3869..2a7bf5e4 100644 --- a/src/types/sidebar.ts +++ b/src/types/sidebar.ts @@ -1,6 +1,7 @@ import {ViewDefinition, ViewOptions} from "@typing/view"; import {Browser} from "@typing/browser"; import {CspOptions} from "@typing/csp"; +import {PermissionsOptions} from "@typing/permissions"; import type {ManifestSidebar} from "@typing/manifest"; export const SidebarAlternativeBrowsers: ReadonlySet<Browser> = new Set([Browser.Opera, Browser.Firefox]); @@ -22,7 +23,7 @@ export interface SidebarConfig { apply?: boolean; } -export type SidebarEntrypointOptions = SidebarConfig & CspOptions & ViewOptions; +export type SidebarEntrypointOptions = SidebarConfig & PermissionsOptions & CspOptions & ViewOptions; export type SidebarProps = SidebarEntrypointOptions; diff --git a/tests/integration/README.md b/tests/integration/README.md index 870d5ef6..557d6ded 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -27,6 +27,9 @@ tests/integration/ │ │ ├── newtab/ │ │ ├── bookmarks/ │ │ └── history/ +│ ├── permissions/ +│ │ ├── permissions.integration.test.ts +│ │ └── views/ │ └── … ├── browser/ │ ├── content/ @@ -126,6 +129,7 @@ Tests copy application inputs to unique directories under `.cache/integration`. - `build/options/embedded`: ten manifest checks covering explicit `openInTab: false` across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. No browser is launched. - `browser/options`: two Chrome MV3 cases covering Vanilla and React rendering, CSS, state/events, opening Options from background, and a View chunk shared with a Page. - `build/override`: thirty manifest checks covering the New Tab, Bookmarks, and History applications across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. Supporting browsers receive `chrome_url_overrides`, the page HTML, its CSP, and the permission declared by the entrypoint; the others receive none of them. Three more cases add the History entrypoint to a copy of the New Tab application: Chrome and Edge builds fail naming both entrypoints, while Firefox keeps the New Tab with its own permission and without the History one. No browser is launched. +- `build/permissions/views`: four manifest checks of one application with two popups, two sidebars, and an options page that each declare permissions. Every built view contributes, including a popup and a sidebar that are not applied by default; Chrome MV2 has no sidebar, so its permissions are not requested; Firefox builds the sidebar action without `sidePanel`; MV2 declares hosts as permissions. No browser is launched. - `browser/override`: three Chrome MV3 cases opening `chrome://newtab`, `chrome://bookmarks`, and `chrome://history`. They verify that Chrome serves the extension page, React and Vanilla rendering, CSS, state/events, custom `as` and `htmlDir` naming, and a View chunk shared with a Page. - `browser/offscreen/service`: one Chrome MV3 round trip from background through Offscreen to a registered background service. - `browser/content/entrypoint-assets`: one Chrome MV3 case and two Firefox cases (MV2 and MV3) using the same application and probe assertions. They cover current asset getters, rejecting the full-map getter outside background, common chunks, dynamic imports, CSS/SVG resource URLs, world separation, and top/child frames. The Chrome case also reads the full-map readiness flag in background. diff --git a/tests/integration/build/permissions/permissions.integration.test.ts b/tests/integration/build/permissions/permissions.integration.test.ts new file mode 100644 index 00000000..f0b76d85 --- /dev/null +++ b/tests/integration/build/permissions/permissions.integration.test.ts @@ -0,0 +1,60 @@ +import {readFile} from "fs/promises"; +import path from "path"; + +import {createIntegrationFixture} from "../../utils/fixture"; + +const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); +const fixtureDir = path.join(__dirname, "views"); + +jest.setTimeout(90_000); + +const host = "https://*.example.com/*"; + +/** + * The application builds two popups and two sidebars (one of each is not applied by default) + * and an options page, and every entrypoint declares its own permissions. + */ +test.each([ + { + target: "chrome MV3 collects every built view and the side panel permission", + browser: "chrome", + manifestVersion: 3, + permissions: ["bookmarks", "downloads", "history", "sidePanel", "storage", "tabs"], + hostPermissions: [host], + }, + { + target: "chrome MV2 has no sidebar, so sidebar permissions are not requested", + browser: "chrome", + manifestVersion: 2, + permissions: ["downloads", host, "storage", "tabs"], + hostPermissions: undefined, + }, + { + target: "firefox MV3 builds the sidebar action without the side panel permission", + browser: "firefox", + manifestVersion: 3, + permissions: ["bookmarks", "downloads", "history", "storage", "tabs"], + hostPermissions: [host], + }, + { + target: "firefox MV2 keeps sidebar permissions and declares hosts as permissions", + browser: "firefox", + manifestVersion: 2, + permissions: ["bookmarks", "downloads", "history", host, "storage", "tabs"], + hostPermissions: undefined, + }, +] as const)("$target", async ({browser, manifestVersion, permissions, hostPermissions}) => { + const fixture = await createIntegrationFixture(rootDir, fixtureDir); + + try { + const extensionDir = await fixture.build({browser, manifestVersion}); + const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); + + expect(manifest.manifest_version).toBe(manifestVersion); + expect([...manifest.permissions].sort()).toEqual([...permissions].sort()); + expect(manifest.optional_permissions).toEqual(["topSites"]); + expect(manifest.host_permissions).toEqual(hostPermissions); + } finally { + await fixture.dispose(); + } +}); diff --git a/tests/integration/build/permissions/views/adnbn.config.ts b/tests/integration/build/permissions/views/adnbn.config.ts new file mode 100644 index 00000000..d417f2a4 --- /dev/null +++ b/tests/integration/build/permissions/views/adnbn.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "View Permissions Integration", + description: "Build fixture collecting permissions declared by popup, sidebar and options entrypoints.", + version: "1.0.0", + multiplePopup: true, + multipleSidebar: true, +}); diff --git a/tests/integration/build/permissions/views/package.json b/tests/integration/build/permissions/views/package.json new file mode 100644 index 00000000..3b141791 --- /dev/null +++ b/tests/integration/build/permissions/views/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-permissions-views", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../.." + } +} diff --git a/tests/integration/build/permissions/views/src/notes.sidebar.ts b/tests/integration/build/permissions/views/src/notes.sidebar.ts new file mode 100644 index 00000000..70619700 --- /dev/null +++ b/tests/integration/build/permissions/views/src/notes.sidebar.ts @@ -0,0 +1,9 @@ +import {defineSidebar} from "adnbn"; + +// Not applied by default, but still built and reachable through changeSidebar(). +export default defineSidebar({ + title: "Notes sidebar", + apply: false, + permissions: ["history"], + render: "<main>Notes sidebar</main>", +}); diff --git a/tests/integration/build/permissions/views/src/options.ts b/tests/integration/build/permissions/views/src/options.ts new file mode 100644 index 00000000..f6b5dfbc --- /dev/null +++ b/tests/integration/build/permissions/views/src/options.ts @@ -0,0 +1,9 @@ +import {defineOptions} from "adnbn"; + +export default defineOptions({ + title: "Options", + permissions: ["storage"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + render: "<main>Options</main>", +}); diff --git a/tests/integration/build/permissions/views/src/popup.ts b/tests/integration/build/permissions/views/src/popup.ts new file mode 100644 index 00000000..61ffb51d --- /dev/null +++ b/tests/integration/build/permissions/views/src/popup.ts @@ -0,0 +1,7 @@ +import {definePopup} from "adnbn"; + +export default definePopup({ + title: "Main popup", + permissions: ["tabs"], + render: "<main>Main popup</main>", +}); diff --git a/tests/integration/build/permissions/views/src/settings.popup.ts b/tests/integration/build/permissions/views/src/settings.popup.ts new file mode 100644 index 00000000..2631f898 --- /dev/null +++ b/tests/integration/build/permissions/views/src/settings.popup.ts @@ -0,0 +1,9 @@ +import {definePopup} from "adnbn"; + +// Not applied by default, but still built and reachable through changePopup(). +export default definePopup({ + title: "Settings popup", + apply: false, + permissions: ["downloads"], + render: "<main>Settings popup</main>", +}); diff --git a/tests/integration/build/permissions/views/src/sidebar.ts b/tests/integration/build/permissions/views/src/sidebar.ts new file mode 100644 index 00000000..2ecd8ba1 --- /dev/null +++ b/tests/integration/build/permissions/views/src/sidebar.ts @@ -0,0 +1,7 @@ +import {defineSidebar} from "adnbn"; + +export default defineSidebar({ + title: "Main sidebar", + permissions: ["bookmarks"], + render: "<main>Main sidebar</main>", +}); diff --git a/tests/integration/build/permissions/views/tsconfig.json b/tests/integration/build/permissions/views/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/build/permissions/views/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} From d9ee6688ec4cab52825b20e38fb688a9de03e0a4 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:36:56 +0300 Subject: [PATCH 11/28] docs: define type declaration and reuse conventions Refer to types by name instead of indexing into other declarations, reuse an existing named type before declaring a new one, declare a type where its concept is owned, and compose contracts from named contracts. --- AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 43c7d573..39c79cca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,6 +119,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`. From 4bc0e8883bb5e6de3e4cbbe005ac1af3dafc0b13 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:14:35 +0300 Subject: [PATCH 12/28] fix(command)!: reject duplicate command names Fail the build when two commands resolve to the same name instead of renaming the later one with a numeric suffix. A command name is its identity in the manifest and in the browser's shortcut settings, so the silent rename broke the runtime subscription and could move a user's custom shortcut to another command. Report a dedicated error for a second action command, and derive file-based names through NameGenerator.derive() without claiming them. Cover explicit, merged app/shared and action collisions with real fixtures in CommandFinder.test.ts, and derive() in NameGenerator.test.ts. BREAKING CHANGE: Projects whose commands share a name, explicitly or through their file names, now fail to build. Give each command a distinct "name" option or file name. --- .../entrypoint/finder/CommandFinder.test.ts | 67 +++++++++++++++++++ src/cli/entrypoint/finder/CommandFinder.ts | 32 ++++++--- .../fixtures/command/action/open.command.ts | 6 ++ .../fixtures/command/action/toggle.command.ts | 6 ++ .../command/explicit/launch.command.ts | 7 ++ .../fixtures/command/explicit/open.command.ts | 7 ++ .../command/merge/apps/app/open.command.ts | 6 ++ .../command/merge/shared/open.command.ts | 6 ++ .../tests/fixtures/command/tsconfig.json | 1 + .../fixtures/command/unique/action.command.ts | 6 ++ .../fixtures/command/unique/close.command.ts | 6 ++ .../fixtures/command/unique/open.command.ts | 7 ++ src/cli/entrypoint/name/NameGenerator.test.ts | 8 +++ src/cli/entrypoint/name/NameGenerator.ts | 7 +- src/types/entrypoint.ts | 2 + 15 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 src/cli/entrypoint/finder/CommandFinder.test.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/action/open.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/action/toggle.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/explicit/launch.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/explicit/open.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/merge/apps/app/open.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/merge/shared/open.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/tsconfig.json create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/unique/action.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/unique/close.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/unique/open.command.ts diff --git a/src/cli/entrypoint/finder/CommandFinder.test.ts b/src/cli/entrypoint/finder/CommandFinder.test.ts new file mode 100644 index 00000000..6ec802f1 --- /dev/null +++ b/src/cli/entrypoint/finder/CommandFinder.test.ts @@ -0,0 +1,67 @@ +import path from "path"; + +import CommandFinder from "./CommandFinder"; + +import {toPosix} from "@cli/utils/path"; + +import type {ReadonlyConfig} from "@typing/config"; + +const fixtures = path.resolve(__dirname, "tests", "fixtures", "command"); + +const makeFinder = (scenario: string, overrides: Partial<ReadonlyConfig> = {}): CommandFinder => { + const config = { + app: "app", + appSrcDir: ".", + appsDir: "apps", + debug: false, + mergeCommands: false, + plugins: [], + rootDir: fixtures, + sharedDir: ".", + srcDir: scenario, + ...overrides, + } as Partial<ReadonlyConfig> as ReadonlyConfig; + + const finder = new CommandFinder(config); + + config.plugins.push({name: "adnbn:background", command: () => finder.files()}); + + return finder; +}; + +const source = (scenario: string, file: string): string => { + return path.join(fixtures, scenario, file); +}; + +describe("CommandFinder", () => { + test("keeps explicit, file-derived and action command names as they are", async () => { + const root = path.join(fixtures, "unique"); + const commands = await makeFinder("unique").commands(); + + expect( + Object.fromEntries(Array.from(commands, ([{file}, {name}]) => [toPosix(path.relative(root, file)), name])) + ).toEqual({ + "action.command.ts": "_execute_action", + "close.command.ts": "close", + "open.command.ts": "open", + }); + }); + + test("rejects two commands with the same explicit name", async () => { + await expect(makeFinder("explicit").commands()).rejects.toThrow( + `Invalid command options in "${source("explicit", "open.command.ts")}": Command name "open" is already used by "${source("explicit", "launch.command.ts")}". Command names must be unique, set a distinct "name"` + ); + }); + + test("rejects app and shared commands that derive the same name when merged", async () => { + await expect(makeFinder("merge", {sharedDir: "shared", mergeCommands: true}).commands()).rejects.toThrow( + `Invalid command options in "${source("merge", "apps/app/open.command.ts")}": Command name "open" is already used by "${source("merge", "shared/open.command.ts")}". Command names must be unique, set a distinct "name"` + ); + }); + + test("rejects a second action command", async () => { + await expect(makeFinder("action").commands()).rejects.toThrow( + `Invalid command options in "${source("action", "toggle.command.ts")}": An action command is already defined in "${source("action", "open.command.ts")}". Only one action command is allowed` + ); + }); +}); diff --git a/src/cli/entrypoint/finder/CommandFinder.ts b/src/cli/entrypoint/finder/CommandFinder.ts index 259ee2ed..7fcccc1e 100644 --- a/src/cli/entrypoint/finder/CommandFinder.ts +++ b/src/cli/entrypoint/finder/CommandFinder.ts @@ -5,7 +5,7 @@ import {CommandParser} from "../parser"; import {InlineNameGenerator} from "../name"; import {ReadonlyConfig} from "@typing/config"; -import {CommandEntrypointOptions, CommandOptions} from "@typing/command"; +import {CommandEntrypointOptions, CommandExecuteActionName, CommandOptions} from "@typing/command"; import {EntrypointFile, EntrypointOptionsFinder, EntrypointParser, EntrypointType} from "@typing/entrypoint"; export default class extends AbstractPluginFinder<CommandEntrypointOptions> { @@ -31,16 +31,32 @@ export default class extends AbstractPluginFinder<CommandEntrypointOptions> { return new PluginFinder(this.config, "command", this); } + /** + * A command name is its identity in the manifest and in the browser's shortcut settings, + * so a collision fails the build instead of renaming one of the commands. + */ protected async getCommands(): Promise<Map<EntrypointFile, CommandOptions>> { const commands = new Map<EntrypointFile, CommandOptions>(); + const owners = new Map<string, EntrypointFile>(); for (const [file, option] of await this.plugin().options()) { - const {name, ...definition} = option; - - commands.set(file, { - name: name ? this.names.name(name) : this.names.file(file), - ...definition, - }); + const {name = this.names.derive(file), ...definition} = option; + const owner = owners.get(name); + + if (owner && name === CommandExecuteActionName) { + throw new Error( + `Invalid command options in "${file.file}": An action command is already defined in "${owner.file}". Only one action command is allowed` + ); + } + + if (owner) { + throw new Error( + `Invalid command options in "${file.file}": Command name "${name}" is already used by "${owner.file}". Command names must be unique, set a distinct "name"` + ); + } + + owners.set(name, file); + commands.set(file, {name, ...definition}); } return commands; @@ -55,8 +71,6 @@ export default class extends AbstractPluginFinder<CommandEntrypointOptions> { } public clear(): this { - this.names.reset(); - this._commands = undefined; return super.clear(); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/action/open.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/action/open.command.ts new file mode 100644 index 00000000..d903ed02 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/action/open.command.ts @@ -0,0 +1,6 @@ +import {defineExecuteActionCommand} from "adnbn"; + +export default defineExecuteActionCommand({ + defaultKey: "Ctrl+Shift+O", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/action/toggle.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/action/toggle.command.ts new file mode 100644 index 00000000..98ca1dbb --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/action/toggle.command.ts @@ -0,0 +1,6 @@ +import {defineExecuteActionCommand} from "adnbn"; + +export default defineExecuteActionCommand({ + defaultKey: "Ctrl+Shift+T", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/explicit/launch.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/explicit/launch.command.ts new file mode 100644 index 00000000..cdcef359 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/explicit/launch.command.ts @@ -0,0 +1,7 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + name: "open", + defaultKey: "Ctrl+Shift+L", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/explicit/open.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/explicit/open.command.ts new file mode 100644 index 00000000..b5c5c1d7 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/explicit/open.command.ts @@ -0,0 +1,7 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + name: "open", + defaultKey: "Ctrl+Shift+O", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/merge/apps/app/open.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/merge/apps/app/open.command.ts new file mode 100644 index 00000000..e85a2523 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/merge/apps/app/open.command.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl+Shift+O", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/merge/shared/open.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/merge/shared/open.command.ts new file mode 100644 index 00000000..7834fb1a --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/merge/shared/open.command.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl+Shift+P", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/tsconfig.json b/src/cli/entrypoint/finder/tests/fixtures/command/tsconfig.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/unique/action.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/unique/action.command.ts new file mode 100644 index 00000000..51d406f3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/unique/action.command.ts @@ -0,0 +1,6 @@ +import {defineExecuteActionCommand} from "adnbn"; + +export default defineExecuteActionCommand({ + defaultKey: "Ctrl+Shift+A", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/unique/close.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/unique/close.command.ts new file mode 100644 index 00000000..d04d677b --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/unique/close.command.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl+Shift+C", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/unique/open.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/unique/open.command.ts new file mode 100644 index 00000000..b5c5c1d7 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/unique/open.command.ts @@ -0,0 +1,7 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + name: "open", + defaultKey: "Ctrl+Shift+O", + execute() {}, +}); diff --git a/src/cli/entrypoint/name/NameGenerator.test.ts b/src/cli/entrypoint/name/NameGenerator.test.ts index 318f3ceb..4308e54d 100644 --- a/src/cli/entrypoint/name/NameGenerator.test.ts +++ b/src/cli/entrypoint/name/NameGenerator.test.ts @@ -40,6 +40,14 @@ describe("NameGenerator", () => { expect(new NameGenerator(EntrypointType.Page).file(file("/project/src/docs/index.ts"))).toBe("docs.page"); }); + test("derives a file name without claiming it", () => { + const generator = new NameGenerator(EntrypointType.Page); + + expect(generator.derive(file("/project/src/docs.page.ts"))).toBe("docs"); + expect(generator.has("docs.page")).toBe(false); + expect(generator.file(file("/project/src/docs.page.ts"))).toBe("docs.page"); + }); + test("skips reserved names", () => { expect(new NameGenerator(EntrypointType.Page).reserve("docs.page").name("docs")).toBe("docs1.page"); }); diff --git a/src/cli/entrypoint/name/NameGenerator.ts b/src/cli/entrypoint/name/NameGenerator.ts index 21411530..33cc07f8 100644 --- a/src/cli/entrypoint/name/NameGenerator.ts +++ b/src/cli/entrypoint/name/NameGenerator.ts @@ -41,6 +41,11 @@ export default class implements EntrypointNameGenerator { } public file(file: EntrypointFile): string { + return this.name(this.derive(file)); + } + + /** Derive a name from a file without claiming it; `file()` claims the derived name. */ + public derive(file: EntrypointFile): string { const key = "." + this.entrypoint; let {name, dir} = path.parse(file.file); @@ -53,7 +58,7 @@ export default class implements EntrypointNameGenerator { name = name.split(key)[0]; } - return this.name(name); + return name; } public reset(): this { diff --git a/src/types/entrypoint.ts b/src/types/entrypoint.ts index 9170c30f..4e5c3ecd 100644 --- a/src/types/entrypoint.ts +++ b/src/types/entrypoint.ts @@ -149,6 +149,8 @@ export interface EntrypointNameGenerator { file(file: EntrypointFile): string; + derive(file: EntrypointFile): string; + likely(name?: string): boolean; has(name: string): boolean; From 70c305c307789a845ec936d476e2af432fb6f145 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:43:43 +0300 Subject: [PATCH 13/28] refactor(transport): share definition resolution across transport entrypoints Move the Relay export merging into mergeDefinition() in the transport resolvers, so every transport entrypoint interprets a default options object or init function the same way and takes its name from the build. Relay and Service expose typed resolveDefinition() wrappers, and the generated Service module becomes a single service(resolveDefinition(...)) call. Replace the generic transport template and its ":entry" placeholder, ts-ignore stripping and "adnbn/entry/:entry" declaration with a dedicated service template. Cover the shared merge rules in the transport resolver test, keep Relay's init-versus-render rule in its own test, and check the Service template in virtual.test.ts alongside Content and Relay. --- src/cli/virtual/index.ts | 18 ++---- src/cli/virtual/service.ts | 9 +++ src/cli/virtual/transport.ts | 27 --------- src/cli/virtual/virtual.d.ts | 15 ++--- src/cli/virtual/virtual.test.ts | 60 +++++++++++-------- src/entry/relay/resolvers/definition.test.ts | 39 ------------ src/entry/relay/resolvers/definition.ts | 20 ++----- src/entry/service/index.ts | 2 + src/entry/service/resolvers/definition.ts | 11 ++++ .../transport/resolvers/definition.test.ts | 57 ++++++++++++++++++ src/entry/transport/resolvers/definition.ts | 24 ++++++++ 11 files changed, 149 insertions(+), 133 deletions(-) create mode 100644 src/cli/virtual/service.ts delete mode 100644 src/cli/virtual/transport.ts create mode 100644 src/entry/service/resolvers/definition.ts create mode 100644 src/entry/transport/resolvers/definition.test.ts diff --git a/src/cli/virtual/index.ts b/src/cli/virtual/index.ts index 8b25ef5b..1333362f 100644 --- a/src/cli/virtual/index.ts +++ b/src/cli/virtual/index.ts @@ -1,11 +1,11 @@ import background from "./background.ts?raw"; import command from "./command.ts?raw"; import content from "./content.ts?raw"; -import transport from "./transport.ts?raw"; import offscreen from "./offscreen.ts?raw"; import offscreenBackground from "./offscreen.background.ts?raw"; import relay from "./relay.ts?raw"; import sandbox from "./sandbox.ts?raw"; +import service from "./service.ts?raw"; import view from "./view.ts?raw"; import {inferEntrypointFramework} from "@cli/entrypoint"; @@ -13,24 +13,14 @@ import {inferEntrypointFramework} from "@cli/entrypoint"; import {PackageName} from "@typing/app"; import {EntrypointFile} from "@typing/entrypoint"; -const templates = {background, command, content, offscreen, relay, sandbox, view, transport}; +const templates = {background, command, content, offscreen, relay, sandbox, service, view}; const getEntryFramework = (file: EntrypointFile, entry: "content" | "view"): string => { return `${PackageName}/entry/${entry}/${inferEntrypointFramework(file)}`; }; const getVirtualModule = (file: EntrypointFile, template: keyof typeof templates): string => { - // prettier-ignore - return templates[template] - .replaceAll("//@ts-ignore", "") - .replace(`virtual:${template}-entrypoint`, file.import); -}; - -const getTransportModule = (file: EntrypointFile, name: string, layer: string): string => { - // prettier-ignore - return getVirtualModule(file, "transport") - .replace("virtual:transport-name", name) - .replaceAll(":entry", layer); + return templates[template].replace(`virtual:${template}-entrypoint`, file.import); }; export const virtualBackgroundModule = (file: EntrypointFile): string => { @@ -73,7 +63,7 @@ export const virtualSandboxModule = (file: EntrypointFile, name: string): string }; export const virtualServiceModule = (file: EntrypointFile, name: string): string => { - return getTransportModule(file, name, "service"); + return getVirtualModule(file, "service").replace("virtual:service-name", name); }; export const virtualViewModule = (file: EntrypointFile): string => { diff --git a/src/cli/virtual/service.ts b/src/cli/virtual/service.ts new file mode 100644 index 00000000..6dad7b40 --- /dev/null +++ b/src/cli/virtual/service.ts @@ -0,0 +1,9 @@ +import service, {resolveDefinition} from "adnbn/entry/service"; + +import * as module from "virtual:service-entrypoint"; + +try { + service(resolveDefinition(module, "virtual:service-name")); +} catch (e) { + console.error("The service crashed on startup:", e); +} diff --git a/src/cli/virtual/transport.ts b/src/cli/virtual/transport.ts deleted file mode 100644 index 7d03a812..00000000 --- a/src/cli/virtual/transport.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type {TransportOptions, TransportType, TransportUnresolvedDefinition} from "adnbn/transport"; -import {isValidTransportDefinition, isValidTransportInitFunction} from "adnbn/entry/transport"; - -//@ts-ignore -import transport from "adnbn/entry/:entry"; - -import * as module from "virtual:transport-entrypoint"; - -try { - const transportName = "virtual:transport-name"; - - const {default: defaultDefinition, ...otherDefinition} = module; - - let definition: TransportUnresolvedDefinition<TransportOptions, TransportType> = otherDefinition; - - if (isValidTransportDefinition(defaultDefinition)) { - definition = {...definition, ...defaultDefinition}; - } else if (isValidTransportInitFunction(defaultDefinition)) { - definition = {...definition, init: defaultDefinition}; - } - - const {init, name, ...options} = definition; - - transport({name: transportName, init, ...options}); -} catch (e) { - console.error("The :entry crashed on startup:", e); -} diff --git a/src/cli/virtual/virtual.d.ts b/src/cli/virtual/virtual.d.ts index 67bef56f..731250aa 100644 --- a/src/cli/virtual/virtual.d.ts +++ b/src/cli/virtual/virtual.d.ts @@ -136,20 +136,13 @@ declare module "virtual:view-framework" { export default view; } -declare module "virtual:transport-entrypoint" { - type TransportDefinition = import("@typing/transport").TransportDefinition<any, any>; +declare module "virtual:service-entrypoint" { + type ServiceDefinition = import("@typing/service").ServiceDefinition<import("@typing/transport").TransportType>; - interface ModuleType extends TransportDefinition { - default: TransportDefinition | TransportDefinition["init"] | undefined; + interface ModuleType extends ServiceDefinition { + default: ServiceDefinition | ServiceDefinition["init"] | undefined; } const module: ModuleType; export = module; } - -declare module "adnbn/entry/:entry" { - import type {TransportUnresolvedDefinition, TransportOptions, TransportType} from "@typing/transport"; - - const transport: (definition: TransportUnresolvedDefinition<TransportOptions, TransportType>) => void; - export default transport; -} diff --git a/src/cli/virtual/virtual.test.ts b/src/cli/virtual/virtual.test.ts index 50c5f257..df5380ac 100644 --- a/src/cli/virtual/virtual.test.ts +++ b/src/cli/virtual/virtual.test.ts @@ -24,7 +24,7 @@ describe("Built virtual modules", () => { }, { generator: "virtualServiceModule", - imports: ["adnbn/transport", "adnbn/entry/transport", "adnbn/entry/service", "{entry}"], + imports: ["adnbn/entry/service", "{entry}"], }, { generator: "virtualOffscreenModule", @@ -167,34 +167,43 @@ describe("Built virtual modules", () => { {extension: "ts" as const, framework: "vanilla"}, {extension: "tsx" as const, framework: "react"}, ])("with $framework entrypoints", ({extension, framework}) => { - test("loads definition normalization from the selected content entrypoint", () => { - for (const source of [ - generated[extension].virtualContentScriptModule, - navigation[extension].virtualContentScriptModule, - ]) { - const file = ts.createSourceFile("content.ts", source, ts.ScriptTarget.Latest, true); - const adapterImport = file.statements.find(ts.isImportDeclaration)!; - const bindings = adapterImport.importClause?.namedBindings; - expect( - bindings && ts.isNamedImports(bindings) && bindings.elements.map(element => element.name.text) - ).toEqual(["resolveDefinition"]); - expect(source).toContain("contentScript(resolveDefinition(module))"); - } - }); - - test("delegates Relay normalization and startup to its runtime entrypoint", () => { - for (const source of [generated[extension].virtualRelayModule, navigation[extension].virtualRelayModule]) { - const file = ts.createSourceFile("relay.ts", source, ts.ScriptTarget.Latest, true); - const relayImport = file.statements.find(ts.isImportDeclaration)!; - expect((relayImport.moduleSpecifier as ts.StringLiteral).text).toBe("adnbn/entry/relay"); - expect(relayImport.importClause?.name?.text).toBe("relay"); - const bindings = relayImport.importClause?.namedBindings; + test.each([ + { + generator: "virtualContentScriptModule", + specifier: "adnbn/entry/content/{framework}", + startup: "contentScript", + call: "contentScript(resolveDefinition(module))", + }, + { + generator: "virtualRelayModule", + specifier: "adnbn/entry/relay", + startup: "relay", + call: 'relay(resolveDefinition(module, "example"), ContentBuilder)', + }, + { + generator: "virtualServiceModule", + specifier: "adnbn/entry/service", + startup: "service", + call: 'service(resolveDefinition(module, "example"))', + }, + ] as const)( + "$generator delegates normalization and startup to its runtime entrypoint", + ({generator, specifier, startup, call}) => { + const source = generated[extension][generator]; + const file = ts.createSourceFile("entry.ts", source, ts.ScriptTarget.Latest, true); + const entryImport = file.statements.find(ts.isImportDeclaration)!; + const bindings = entryImport.importClause?.namedBindings; + + expect((entryImport.moduleSpecifier as ts.StringLiteral).text).toBe( + specifier.replace("{framework}", framework) + ); + expect(entryImport.importClause?.name?.text).toBe(startup); expect( bindings && ts.isNamedImports(bindings) && bindings.elements.map(element => element.name.text) ).toEqual(["resolveDefinition"]); - expect(source).toContain('relay(resolveDefinition(module, "example"), ContentBuilder)'); + expect(source).toContain(call); } - }); + ); test.each(cases)("$generator preserves package imports and resolves placeholders", ({generator, imports}) => { const source = generated[extension][generator]; @@ -206,7 +215,6 @@ describe("Built virtual modules", () => { ) ); expect(source).not.toContain("virtual:"); - expect(source).not.toContain(":entry"); }); test.each(["virtualContentScriptModule", "virtualRelayModule"] as const)( diff --git a/src/entry/relay/resolvers/definition.test.ts b/src/entry/relay/resolvers/definition.test.ts index ec1978d3..06581d71 100644 --- a/src/entry/relay/resolvers/definition.test.ts +++ b/src/entry/relay/resolvers/definition.test.ts @@ -1,32 +1,6 @@ import {resolveDefinition} from "./definition"; describe("Relay definitions", () => { - test("merges default options over named exports and uses the build name without executing callbacks", () => { - const init = jest.fn(() => ({ready: true})); - const main = jest.fn(); - const defaults = Object.freeze({name: "default-name", init, matches: ["https://default.example/*"]}); - const module = Object.freeze({ - default: defaults, - name: "named-name", - main, - matches: ["https://named.example/*"], - anchor: "article", - }); - - expect(resolveDefinition(module, "build-name")).toEqual({ - name: "build-name", - init, - main, - matches: defaults.matches, - anchor: "article", - }); - expect(module.name).toBe("named-name"); - expect(module.matches).toEqual(["https://named.example/*"]); - expect(defaults.name).toBe("default-name"); - expect(init).not.toHaveBeenCalled(); - expect(main).not.toHaveBeenCalled(); - }); - test("treats a default function as init while preserving named render and main", () => { const namedInit = jest.fn(() => ({named: true})); const init = jest.fn(() => ({ready: true})); @@ -53,17 +27,4 @@ describe("Relay definitions", () => { expect(definition).not.toHaveProperty("render"); expect(init).not.toHaveBeenCalled(); }); - - test.each([undefined, null, false, true, "", "ignored", 0, []])( - "preserves named exports when default is not a definition or init: %p", - value => { - const init = jest.fn(() => ({ready: true})); - expect(resolveDefinition({default: value, init, name: "ignored", allFrames: false}, "panel")).toEqual({ - name: "panel", - init, - main: undefined, - allFrames: false, - }); - } - ); }); diff --git a/src/entry/relay/resolvers/definition.ts b/src/entry/relay/resolvers/definition.ts index 3489f551..1757152b 100644 --- a/src/entry/relay/resolvers/definition.ts +++ b/src/entry/relay/resolvers/definition.ts @@ -1,24 +1,12 @@ -import {isValidTransportDefinition, isValidTransportInitFunction} from "@entry/transport/resolvers/definition"; +import {mergeDefinition} from "@entry/transport/resolvers/definition"; -import type {RelayOptions, RelayUnresolvedDefinition} from "@typing/relay"; +import type {RelayUnresolvedDefinition} from "@typing/relay"; import type {TransportType} from "@typing/transport"; -/** Resolve Relay exports without executing init; the build supplies the authoritative name. */ +/** A default function is Relay's transport init, never a content render. */ export const resolveDefinition = <T extends TransportType = TransportType>( module: object, name: string ): RelayUnresolvedDefinition<T> => { - const {default: defaultDefinition, ...otherDefinition} = module as Record<string, unknown>; - - let definition = otherDefinition as RelayUnresolvedDefinition<T>; - - if (isValidTransportDefinition<RelayOptions, T>(defaultDefinition)) { - definition = {...definition, ...defaultDefinition}; - } else if (isValidTransportInitFunction<RelayOptions, T>(defaultDefinition)) { - definition = {...definition, init: defaultDefinition}; - } - - const {init, main, name: _name, ...options} = definition; - - return {name, init, main, ...options}; + return mergeDefinition(module, name) as RelayUnresolvedDefinition<T>; }; diff --git a/src/entry/service/index.ts b/src/entry/service/index.ts index 797ccb07..e66357b1 100644 --- a/src/entry/service/index.ts +++ b/src/entry/service/index.ts @@ -1,3 +1,5 @@ import Builder from "./Builder"; +export {resolveDefinition} from "./resolvers/definition"; + export default Builder.resolver(); diff --git a/src/entry/service/resolvers/definition.ts b/src/entry/service/resolvers/definition.ts new file mode 100644 index 00000000..e5a97fb6 --- /dev/null +++ b/src/entry/service/resolvers/definition.ts @@ -0,0 +1,11 @@ +import {mergeDefinition} from "@entry/transport/resolvers/definition"; + +import type {ServiceUnresolvedDefinition} from "@typing/service"; +import type {TransportType} from "@typing/transport"; + +export const resolveDefinition = <T extends TransportType = TransportType>( + module: object, + name: string +): ServiceUnresolvedDefinition<T> => { + return mergeDefinition(module, name) as ServiceUnresolvedDefinition<T>; +}; diff --git a/src/entry/transport/resolvers/definition.test.ts b/src/entry/transport/resolvers/definition.test.ts new file mode 100644 index 00000000..f7db9792 --- /dev/null +++ b/src/entry/transport/resolvers/definition.test.ts @@ -0,0 +1,57 @@ +import {mergeDefinition} from "./definition"; + +describe("Transport definitions", () => { + test("merges default options over named exports and uses the build name without executing callbacks", () => { + const init = jest.fn(() => ({ready: true})); + const main = jest.fn(); + const defaults = Object.freeze({name: "default-name", init, excludeBrowser: ["firefox"]}); + const module = Object.freeze({ + default: defaults, + name: "named-name", + main, + excludeBrowser: ["edge"], + persistent: true, + }); + + expect(mergeDefinition(module, "build-name")).toEqual({ + name: "build-name", + init, + main, + excludeBrowser: defaults.excludeBrowser, + persistent: true, + }); + expect(module.name).toBe("named-name"); + expect(module.excludeBrowser).toEqual(["edge"]); + expect(defaults.name).toBe("default-name"); + expect(init).not.toHaveBeenCalled(); + expect(main).not.toHaveBeenCalled(); + }); + + test("treats a default function as init over a named init", () => { + const namedInit = jest.fn(() => ({named: true})); + const init = jest.fn(() => ({ready: true})); + const main = jest.fn(); + + expect(mergeDefinition({default: init, init: namedInit, main}, "worker")).toEqual({ + name: "worker", + init, + main, + }); + expect(namedInit).not.toHaveBeenCalled(); + expect(init).not.toHaveBeenCalled(); + }); + + test.each([undefined, null, false, true, "", "ignored", 0, []])( + "preserves named exports when default is not a definition or init: %p", + value => { + const init = jest.fn(() => ({ready: true})); + + expect(mergeDefinition({default: value, init, name: "ignored", persistent: false}, "worker")).toEqual({ + name: "worker", + init, + main: undefined, + persistent: false, + }); + } + ); +}); diff --git a/src/entry/transport/resolvers/definition.ts b/src/entry/transport/resolvers/definition.ts index 715cb377..b0e9c7dc 100644 --- a/src/entry/transport/resolvers/definition.ts +++ b/src/entry/transport/resolvers/definition.ts @@ -4,6 +4,7 @@ import { TransportMainHandler, TransportOptions, TransportType, + TransportUnresolvedDefinition, } from "@typing/transport"; import {RelayDefinition, RelayMainHandler} from "@typing/relay"; import {OffscreenDefinition, OffscreenMainHandler} from "@typing/offscreen"; @@ -29,3 +30,26 @@ export const isValidTransportMainFunction = <O extends TransportOptions, T exten export const isValidTransportName = (name: any): name is string => { return name && typeof name === "string" && name.trim().length > 0; }; + +/** + * Merge transport exports without executing init: default options override named exports, + * and a default function is the init. The build supplies the authoritative name. + */ +export const mergeDefinition = ( + module: object, + name: string +): TransportUnresolvedDefinition<TransportOptions, TransportType> => { + const {default: defaultDefinition, ...namedDefinition} = module as Record<string, unknown>; + + let definition = namedDefinition as TransportUnresolvedDefinition<TransportOptions, TransportType>; + + if (isValidTransportDefinition(defaultDefinition)) { + definition = {...definition, ...defaultDefinition}; + } else if (isValidTransportInitFunction(defaultDefinition)) { + definition = {...definition, init: defaultDefinition}; + } + + const {init, main, name: _, ...options} = definition; + + return {name, init, main, ...options}; +}; From 17c9f95c5e8c50794bdf60c165ea685e48121c16 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:16:46 +0300 Subject: [PATCH 14/28] refactor(background)!: resolve background definitions in the runtime entry Move default-export interpretation out of the generated background module into resolveDefinition() in the background resolvers: default options override named exports, a default function becomes main, and any other default value is ignored. The generated module becomes a single background(resolveDefinition(module)) call, and adnbn/entry/background now exports only the startup function and resolveDefinition(). Cover the merge rules in the background resolver test and check the template in virtual.test.ts alongside the other thin templates. BREAKING CHANGE: adnbn/entry/background no longer exports isValidBackgroundDefinition() and isValidBackgroundMainHandler(). --- src/cli/virtual/background.ts | 17 +------- src/cli/virtual/virtual.test.ts | 8 +++- src/entry/background/index.ts | 2 +- .../background/resolvers/definition.test.ts | 41 +++++++++++++++++++ src/entry/background/resolvers/definition.ts | 23 ++++++++++- 5 files changed, 72 insertions(+), 19 deletions(-) create mode 100644 src/entry/background/resolvers/definition.test.ts diff --git a/src/cli/virtual/background.ts b/src/cli/virtual/background.ts index 436bbedc..9aec5ecf 100644 --- a/src/cli/virtual/background.ts +++ b/src/cli/virtual/background.ts @@ -1,22 +1,9 @@ -import type {BackgroundDefinition} from "adnbn"; -import background, {isValidBackgroundDefinition, isValidBackgroundMainHandler} from "adnbn/entry/background"; +import background, {resolveDefinition} from "adnbn/entry/background"; import * as module from "virtual:background-entrypoint"; try { - const {default: defaultDefinition, ...otherDefinition} = module; - - let definition: BackgroundDefinition = otherDefinition; - - if (isValidBackgroundDefinition(defaultDefinition)) { - definition = {...definition, ...defaultDefinition}; - } else if (isValidBackgroundMainHandler(defaultDefinition)) { - definition = {...definition, main: defaultDefinition}; - } - - const {main, ...options} = definition; - - background({main, ...options}); + background(resolveDefinition(module)); } catch (e) { console.error("The background crashed on startup:", e); } diff --git a/src/cli/virtual/virtual.test.ts b/src/cli/virtual/virtual.test.ts index df5380ac..3dd5e844 100644 --- a/src/cli/virtual/virtual.test.ts +++ b/src/cli/virtual/virtual.test.ts @@ -12,7 +12,7 @@ describe("Built virtual modules", () => { const cases: {generator: Generator; imports: string[]}[] = [ { generator: "virtualBackgroundModule", - imports: ["adnbn", "adnbn/entry/background", "{entry}"], + imports: ["adnbn/entry/background", "{entry}"], }, { generator: "virtualCommandModule", @@ -168,6 +168,12 @@ describe("Built virtual modules", () => { {extension: "tsx" as const, framework: "react"}, ])("with $framework entrypoints", ({extension, framework}) => { test.each([ + { + generator: "virtualBackgroundModule", + specifier: "adnbn/entry/background", + startup: "background", + call: "background(resolveDefinition(module))", + }, { generator: "virtualContentScriptModule", specifier: "adnbn/entry/content/{framework}", diff --git a/src/entry/background/index.ts b/src/entry/background/index.ts index 718808fa..26e7851c 100644 --- a/src/entry/background/index.ts +++ b/src/entry/background/index.ts @@ -1,5 +1,5 @@ import Builder from "./Builder"; -export * from "./resolvers"; +export {resolveDefinition} from "./resolvers"; export default Builder.resolver(); diff --git a/src/entry/background/resolvers/definition.test.ts b/src/entry/background/resolvers/definition.test.ts new file mode 100644 index 00000000..3dcefee0 --- /dev/null +++ b/src/entry/background/resolvers/definition.test.ts @@ -0,0 +1,41 @@ +import {resolveDefinition} from "./definition"; + +describe("Background definitions", () => { + test("merges default options over named exports without executing main", () => { + const main = jest.fn(); + const namedMain = jest.fn(); + const defaults = Object.freeze({main, persistent: true}); + const module = Object.freeze({ + default: defaults, + main: namedMain, + persistent: false, + excludeBrowser: ["edge"], + }); + + expect(resolveDefinition(module)).toEqual({main, persistent: true, excludeBrowser: ["edge"]}); + expect(module.persistent).toBe(false); + expect(main).not.toHaveBeenCalled(); + expect(namedMain).not.toHaveBeenCalled(); + }); + + test("treats a default function as main over a named main", () => { + const main = jest.fn(); + const namedMain = jest.fn(); + + expect(resolveDefinition({default: main, main: namedMain, persistent: true})).toEqual({ + main, + persistent: true, + }); + expect(main).not.toHaveBeenCalled(); + expect(namedMain).not.toHaveBeenCalled(); + }); + + test.each([undefined, null, false, true, "", "ignored", 0, []])( + "preserves named exports when default is not a definition or main: %p", + value => { + const main = jest.fn(); + + expect(resolveDefinition({default: value, main, persistent: false})).toEqual({main, persistent: false}); + } + ); +}); diff --git a/src/entry/background/resolvers/definition.ts b/src/entry/background/resolvers/definition.ts index 98879af8..0eb4a8f2 100644 --- a/src/entry/background/resolvers/definition.ts +++ b/src/entry/background/resolvers/definition.ts @@ -1,9 +1,28 @@ import {BackgroundDefinition, BackgroundMainHandler} from "@typing/background"; -export const isValidBackgroundDefinition = (definition: any): definition is BackgroundDefinition => { - return definition && typeof definition === "object" && definition.constructor === Object; +const isDefinitionObject = (value: unknown): value is BackgroundDefinition => { + return value !== null && typeof value === "object" && value.constructor === Object; }; export const isValidBackgroundMainHandler = (main: unknown): main is BackgroundMainHandler => { return typeof main === "function"; }; + +/** + * Merge background exports without executing main: default options override named exports, + * and a default function is the main handler. Any other default value is ignored. + */ +export const resolveDefinition = (module: object): BackgroundDefinition => { + const {default: defaultDefinition, ...namedDefinition} = module as Record<string, unknown>; + const definition = namedDefinition as BackgroundDefinition; + + if (isDefinitionObject(defaultDefinition)) { + return {...definition, ...defaultDefinition}; + } + + if (isValidBackgroundMainHandler(defaultDefinition)) { + return {...definition, main: defaultDefinition}; + } + + return definition; +}; From d5b90d1115a53758685addc4ea1b0f8f69607f3f Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:16:47 +0300 Subject: [PATCH 15/28] test(command): resolve package imports in command finder fixtures Extend the root TypeScript configuration from the command finder fixture project instead of using an empty one. The empty configuration had no paths or bundler resolution, so editors reported "adnbn" imports in the fixtures as missing even though the parser and the tests worked. Override include and exclude so the fixtures are not dropped by the root exclusion of tests/fixtures. --- .../entrypoint/finder/tests/fixtures/command/tsconfig.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/tsconfig.json b/src/cli/entrypoint/finder/tests/fixtures/command/tsconfig.json index 0967ef42..a135b733 100644 --- a/src/cli/entrypoint/finder/tests/fixtures/command/tsconfig.json +++ b/src/cli/entrypoint/finder/tests/fixtures/command/tsconfig.json @@ -1 +1,5 @@ -{} +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} From 954382adb3423b8ba811c418bbfc39de80418856 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:47:25 +0300 Subject: [PATCH 16/28] refactor(command)!: resolve command definitions in the runtime entry Move default-export interpretation out of the generated command module into resolveDefinition() in the command resolvers: default options override named exports, a default function becomes execute, and the build always supplies the name, as for Relay and Service. The generated module becomes a single command(resolveDefinition(module, name)) call, and adnbn/entry/command now exports only the startup function and resolveDefinition(). Cover the merge and naming rules in the command resolver test and check the template in virtual.test.ts alongside the other thin templates. BREAKING CHANGE: adnbn/entry/command no longer exports isValidCommandDefinition(), isValidCommandExecuteFunction() and isValidCommandName(). --- src/cli/virtual/command.ts | 23 +------- src/cli/virtual/virtual.test.ts | 8 ++- src/entry/command/index.ts | 2 +- .../command/resolvers/definition.test.ts | 55 +++++++++++++++++++ src/entry/command/resolvers/definition.ts | 24 +++++++- 5 files changed, 86 insertions(+), 26 deletions(-) create mode 100644 src/entry/command/resolvers/definition.test.ts diff --git a/src/cli/virtual/command.ts b/src/cli/virtual/command.ts index c8dfd6a0..70aef766 100644 --- a/src/cli/virtual/command.ts +++ b/src/cli/virtual/command.ts @@ -1,28 +1,9 @@ -import type {CommandUnresolvedDefinition} from "adnbn"; -import command, {isValidCommandDefinition, isValidCommandExecuteFunction} from "adnbn/entry/command"; +import command, {resolveDefinition} from "adnbn/entry/command"; import * as module from "virtual:command-entrypoint"; try { - const commandName = "virtual:command-name"; - - const {default: defaultDefinition, ...otherDefinition} = module; - - let definition: CommandUnresolvedDefinition = otherDefinition; - - if (isValidCommandDefinition(defaultDefinition)) { - definition = {...definition, ...defaultDefinition}; - } else if (isValidCommandExecuteFunction(defaultDefinition)) { - definition = {...definition, execute: defaultDefinition}; - } - - const {execute, name = commandName, ...options} = definition; - - command({ - name, - execute, - ...options, - }); + command(resolveDefinition(module, "virtual:command-name")); } catch (e) { console.error("The command crashed on startup:", e); } diff --git a/src/cli/virtual/virtual.test.ts b/src/cli/virtual/virtual.test.ts index 3dd5e844..9fa07457 100644 --- a/src/cli/virtual/virtual.test.ts +++ b/src/cli/virtual/virtual.test.ts @@ -16,7 +16,7 @@ describe("Built virtual modules", () => { }, { generator: "virtualCommandModule", - imports: ["adnbn", "adnbn/entry/command", "{entry}"], + imports: ["adnbn/entry/command", "{entry}"], }, { generator: "virtualContentScriptModule", @@ -174,6 +174,12 @@ describe("Built virtual modules", () => { startup: "background", call: "background(resolveDefinition(module))", }, + { + generator: "virtualCommandModule", + specifier: "adnbn/entry/command", + startup: "command", + call: 'command(resolveDefinition(module, "example"))', + }, { generator: "virtualContentScriptModule", specifier: "adnbn/entry/content/{framework}", diff --git a/src/entry/command/index.ts b/src/entry/command/index.ts index 718808fa..26e7851c 100644 --- a/src/entry/command/index.ts +++ b/src/entry/command/index.ts @@ -1,5 +1,5 @@ import Builder from "./Builder"; -export * from "./resolvers"; +export {resolveDefinition} from "./resolvers"; export default Builder.resolver(); diff --git a/src/entry/command/resolvers/definition.test.ts b/src/entry/command/resolvers/definition.test.ts new file mode 100644 index 00000000..60b0bb08 --- /dev/null +++ b/src/entry/command/resolvers/definition.test.ts @@ -0,0 +1,55 @@ +import {resolveDefinition} from "./definition"; + +describe("Command definitions", () => { + test("merges default options over named exports and uses the build name without executing", () => { + const execute = jest.fn(); + const namedExecute = jest.fn(); + const defaults = Object.freeze({name: "default-name", execute, defaultKey: "Ctrl+Shift+D"}); + const module = Object.freeze({ + default: defaults, + name: "named-name", + execute: namedExecute, + defaultKey: "Ctrl+Shift+N", + description: "Named", + }); + + expect(resolveDefinition(module, "build-name")).toEqual({ + name: "build-name", + execute, + defaultKey: "Ctrl+Shift+D", + description: "Named", + }); + expect(module.name).toBe("named-name"); + expect(defaults.name).toBe("default-name"); + expect(execute).not.toHaveBeenCalled(); + expect(namedExecute).not.toHaveBeenCalled(); + }); + + test("treats a default function as execute over a named execute", () => { + const execute = jest.fn(); + const namedExecute = jest.fn(); + + expect( + resolveDefinition({default: execute, execute: namedExecute, defaultKey: "Ctrl+Shift+P"}, "ping") + ).toEqual({ + name: "ping", + execute, + defaultKey: "Ctrl+Shift+P", + }); + expect(execute).not.toHaveBeenCalled(); + expect(namedExecute).not.toHaveBeenCalled(); + }); + + test.each([undefined, null, false, true, "", "ignored", 0, []])( + "preserves named exports when default is not a definition or execute: %p", + value => { + const execute = jest.fn(); + + expect(resolveDefinition({default: value, execute, name: "ignored", global: false}, "ping")).toEqual({ + name: "ping", + execute, + global: false, + }); + } + ); +}); diff --git a/src/entry/command/resolvers/definition.ts b/src/entry/command/resolvers/definition.ts index bd7e226c..e68d4ab5 100644 --- a/src/entry/command/resolvers/definition.ts +++ b/src/entry/command/resolvers/definition.ts @@ -1,7 +1,7 @@ -import {CommandDefinition, CommandExecute} from "@typing/command"; +import {CommandDefinition, CommandExecute, CommandUnresolvedDefinition} from "@typing/command"; -export const isValidCommandDefinition = (definition: any): definition is CommandDefinition => { - return definition && typeof definition === "object" && definition.constructor === Object; +const isDefinitionObject = (value: unknown): value is CommandDefinition => { + return value !== null && typeof value === "object" && value.constructor === Object; }; export const isValidCommandExecuteFunction = (execute: any): execute is CommandExecute => { @@ -11,3 +11,21 @@ export const isValidCommandExecuteFunction = (execute: any): execute is CommandE export const isValidCommandName = (name: any): name is string => { return name && typeof name === "string" && name.trim().length > 0; }; + +/** + * Merge command exports without executing them: default options override named exports, + * and a default function is the execute handler. The build supplies the authoritative name. + */ +export const resolveDefinition = (module: object, name: string): CommandUnresolvedDefinition => { + const {default: defaultDefinition, ...namedDefinition} = module as Record<string, unknown>; + + let definition = namedDefinition as CommandUnresolvedDefinition; + + if (isDefinitionObject(defaultDefinition)) { + definition = {...definition, ...defaultDefinition}; + } else if (isValidCommandExecuteFunction(defaultDefinition)) { + definition = {...definition, execute: defaultDefinition}; + } + + return {...definition, name}; +}; From 2fc5eb991448648729ca29cf1d61386d68736131 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:04:39 +0300 Subject: [PATCH 17/28] refactor(entry): import resolvers from their owning files Remove the resolvers/index.ts barrels from the background, command, transport and content entrypoint runtimes and import each resolver from the file that owns it, as most callers already did. List the exports of adnbn/entry/transport explicitly, so mergeDefinition() stays internal and only the transport guards used by the offscreen and sandbox templates remain public. The behavior is unchanged and the existing suites cover the moved imports. Record both rules in the internal imports section of AGENTS.md. --- AGENTS.md | 2 ++ src/entry/background/Builder.ts | 2 +- src/entry/background/index.ts | 2 +- src/entry/background/resolvers/index.ts | 1 - src/entry/command/Builder.ts | 2 +- src/entry/command/index.ts | 2 +- src/entry/command/resolvers/index.ts | 1 - src/entry/content/lifecycle/Builder.ts | 14 +++++--------- src/entry/content/resolvers/index.ts | 6 ------ src/entry/transport/AbstractBuilder.ts | 2 +- src/entry/transport/index.ts | 7 ++++++- src/entry/transport/resolvers/index.ts | 1 - 12 files changed, 18 insertions(+), 24 deletions(-) delete mode 100644 src/entry/background/resolvers/index.ts delete mode 100644 src/entry/command/resolvers/index.ts delete mode 100644 src/entry/content/resolvers/index.ts delete mode 100644 src/entry/transport/resolvers/index.ts diff --git a/AGENTS.md b/AGENTS.md index 39c79cca..1a1cc1aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,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 diff --git a/src/entry/background/Builder.ts b/src/entry/background/Builder.ts index af59520a..acf6dc74 100644 --- a/src/entry/background/Builder.ts +++ b/src/entry/background/Builder.ts @@ -1,6 +1,6 @@ import Builder from "@entry/core/Builder"; -import {isValidBackgroundMainHandler} from "./resolvers"; +import {isValidBackgroundMainHandler} from "./resolvers/definition"; import {BackgroundBuilder, BackgroundDefinition} from "@typing/background"; diff --git a/src/entry/background/index.ts b/src/entry/background/index.ts index 26e7851c..e66357b1 100644 --- a/src/entry/background/index.ts +++ b/src/entry/background/index.ts @@ -1,5 +1,5 @@ import Builder from "./Builder"; -export {resolveDefinition} from "./resolvers"; +export {resolveDefinition} from "./resolvers/definition"; export default Builder.resolver(); diff --git a/src/entry/background/resolvers/index.ts b/src/entry/background/resolvers/index.ts deleted file mode 100644 index fb0f233e..00000000 --- a/src/entry/background/resolvers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./definition"; diff --git a/src/entry/command/Builder.ts b/src/entry/command/Builder.ts index 82914283..a50e9f18 100644 --- a/src/entry/command/Builder.ts +++ b/src/entry/command/Builder.ts @@ -2,7 +2,7 @@ import {onActionClicked, onSpecificCommand} from "@addon-core/browser"; import Builder from "@entry/core/Builder"; -import {isValidCommandExecuteFunction, isValidCommandName} from "./resolvers"; +import {isValidCommandExecuteFunction, isValidCommandName} from "./resolvers/definition"; import { CommandBuilder, diff --git a/src/entry/command/index.ts b/src/entry/command/index.ts index 26e7851c..e66357b1 100644 --- a/src/entry/command/index.ts +++ b/src/entry/command/index.ts @@ -1,5 +1,5 @@ import Builder from "./Builder"; -export {resolveDefinition} from "./resolvers"; +export {resolveDefinition} from "./resolvers/definition"; export default Builder.resolver(); diff --git a/src/entry/command/resolvers/index.ts b/src/entry/command/resolvers/index.ts deleted file mode 100644 index fb0f233e..00000000 --- a/src/entry/command/resolvers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./definition"; diff --git a/src/entry/content/lifecycle/Builder.ts b/src/entry/content/lifecycle/Builder.ts index e7bbb5d8..fbd2db9f 100644 --- a/src/entry/content/lifecycle/Builder.ts +++ b/src/entry/content/lifecycle/Builder.ts @@ -2,15 +2,11 @@ import {isContentScriptFrameNavigation, resolveContentScriptIsolation} from "@sh import EntrypointBuilder from "@entry/core/Builder"; -import { - createAnchorResolver, - createAwaitFirstStrategy, - createContainerResolver, - createTargetResolver, - withLocationTracking, - createAppendMountHandler, - createMutationObserverStrategy, -} from "../resolvers"; +import {createAnchorResolver} from "../resolvers/anchor"; +import {createContainerResolver} from "../resolvers/container"; +import {createAppendMountHandler} from "../resolvers/mount"; +import {createTargetResolver} from "../resolvers/target"; +import {createAwaitFirstStrategy, createMutationObserverStrategy, withLocationTracking} from "../resolvers/watch"; import {ManagedContext, EventEmitter} from "./context"; import {AttributeMarker, WeakMarker} from "./markers"; diff --git a/src/entry/content/resolvers/index.ts b/src/entry/content/resolvers/index.ts deleted file mode 100644 index 346cded0..00000000 --- a/src/entry/content/resolvers/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./anchor"; -export * from "./container"; -export * from "./definition"; -export * from "./mount"; -export * from "./target"; -export * from "./watch"; diff --git a/src/entry/transport/AbstractBuilder.ts b/src/entry/transport/AbstractBuilder.ts index 63b4aee1..0c2861db 100644 --- a/src/entry/transport/AbstractBuilder.ts +++ b/src/entry/transport/AbstractBuilder.ts @@ -1,6 +1,6 @@ import Builder from "@entry/core/Builder"; -import {isValidTransportInitFunction, isValidTransportMainFunction, isValidTransportName} from "./resolvers"; +import {isValidTransportInitFunction, isValidTransportMainFunction, isValidTransportName} from "./resolvers/definition"; import { TransportOptions, diff --git a/src/entry/transport/index.ts b/src/entry/transport/index.ts index 38267da5..835eb8c9 100644 --- a/src/entry/transport/index.ts +++ b/src/entry/transport/index.ts @@ -1 +1,6 @@ -export * from "./resolvers"; +export { + isValidTransportDefinition, + isValidTransportInitFunction, + isValidTransportMainFunction, + isValidTransportName, +} from "./resolvers/definition"; diff --git a/src/entry/transport/resolvers/index.ts b/src/entry/transport/resolvers/index.ts deleted file mode 100644 index fb0f233e..00000000 --- a/src/entry/transport/resolvers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./definition"; From 73effdc34912e6a90198341d9bf8c9d118b2a434 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:28:05 +0300 Subject: [PATCH 18/28] refactor(view)!: resolve and mount views in one adapter-driven builder Replace the view guards with mergeDefinition() and give the Vanilla and React adapters their own resolveDefinition(), so the generated view module becomes a single view(resolveDefinition(module)) call and only the React adapter knows React elements. Merge the view core builder and the duplicated adapter lifecycles into one abstract Builder that owns the title and the container, while adapters only resolve the render and mount its value; a Vanilla render of 0 is now rendered instead of skipped. Rename virtual:view-framework to virtual:view-builder for view, offscreen and sandbox. Cover definition merging, adapter recognition and both builders through their public build/destroy behavior, and check the template and the React-free Vanilla bundle graph in virtual.test.ts. BREAKING CHANGE: The adnbn/entry/view subpath is removed together with isViewDefinition() and isValidViewDefinitionRenderValue(). --- src/cli/virtual/index.ts | 6 +- src/cli/virtual/offscreen.ts | 2 +- src/cli/virtual/sandbox.ts | 2 +- src/cli/virtual/view.ts | 17 +-- src/cli/virtual/virtual.d.ts | 6 +- src/cli/virtual/virtual.test.ts | 51 ++++++--- src/entry/view/Builder.ts | 100 ++++++++++++++++++ src/entry/view/adapters/react/Builder.test.ts | 67 ++++++++++++ src/entry/view/adapters/react/Builder.ts | 50 ++++----- .../view/adapters/react/definition.test.ts | 33 ++++++ src/entry/view/adapters/react/definition.ts | 17 +++ src/entry/view/adapters/react/index.ts | 1 + .../view/adapters/react/resolvers/render.ts | 13 --- .../view/adapters/vanilla/Builder.test.ts | 71 +++++++++++++ src/entry/view/adapters/vanilla/Builder.ts | 46 +++----- .../view/adapters/vanilla/definition.test.ts | 30 ++++++ src/entry/view/adapters/vanilla/definition.ts | 8 ++ src/entry/view/adapters/vanilla/index.ts | 1 + src/entry/view/adapters/vanilla/utils.ts | 3 + src/entry/view/core/Builder.ts | 57 ---------- src/entry/view/core/resolvers/definition.ts | 21 ---- src/entry/view/core/resolvers/render.ts | 21 ---- src/entry/view/index.ts | 1 - .../view/{core => }/resolvers/container.ts | 0 src/entry/view/resolvers/definition.test.ts | 35 ++++++ src/entry/view/resolvers/definition.ts | 25 +++++ src/types/view.ts | 2 +- 27 files changed, 476 insertions(+), 210 deletions(-) create mode 100644 src/entry/view/Builder.ts create mode 100644 src/entry/view/adapters/react/Builder.test.ts create mode 100644 src/entry/view/adapters/react/definition.test.ts create mode 100644 src/entry/view/adapters/react/definition.ts delete mode 100644 src/entry/view/adapters/react/resolvers/render.ts create mode 100644 src/entry/view/adapters/vanilla/Builder.test.ts create mode 100644 src/entry/view/adapters/vanilla/definition.test.ts create mode 100644 src/entry/view/adapters/vanilla/definition.ts create mode 100644 src/entry/view/adapters/vanilla/utils.ts delete mode 100644 src/entry/view/core/Builder.ts delete mode 100644 src/entry/view/core/resolvers/definition.ts delete mode 100644 src/entry/view/core/resolvers/render.ts delete mode 100644 src/entry/view/index.ts rename src/entry/view/{core => }/resolvers/container.ts (100%) create mode 100644 src/entry/view/resolvers/definition.test.ts create mode 100644 src/entry/view/resolvers/definition.ts diff --git a/src/cli/virtual/index.ts b/src/cli/virtual/index.ts index 1333362f..69561618 100644 --- a/src/cli/virtual/index.ts +++ b/src/cli/virtual/index.ts @@ -40,7 +40,7 @@ export const virtualContentScriptModule = (file: EntrypointFile, navigation = fa export const virtualOffscreenModule = (file: EntrypointFile, name: string): string => { return getVirtualModule(file, "offscreen") .replace("virtual:offscreen-name", name) - .replace(`virtual:view-framework`, getEntryFramework(file, "view")); + .replace(`virtual:view-builder`, getEntryFramework(file, "view")); }; export const virtualOffscreenBackgroundModule = (): string => { @@ -59,7 +59,7 @@ export const virtualRelayModule = (file: EntrypointFile, name: string, navigatio export const virtualSandboxModule = (file: EntrypointFile, name: string): string => { return getVirtualModule(file, "sandbox") .replace("virtual:sandbox-name", name) - .replace(`virtual:view-framework`, getEntryFramework(file, "view")); + .replace(`virtual:view-builder`, getEntryFramework(file, "view")); }; export const virtualServiceModule = (file: EntrypointFile, name: string): string => { @@ -69,5 +69,5 @@ export const virtualServiceModule = (file: EntrypointFile, name: string): string export const virtualViewModule = (file: EntrypointFile): string => { // prettier-ignore return getVirtualModule(file, "view") - .replace(`virtual:view-framework`, getEntryFramework(file, "view")); + .replace(`virtual:view-builder`, getEntryFramework(file, "view")); }; diff --git a/src/cli/virtual/offscreen.ts b/src/cli/virtual/offscreen.ts index 285a42e3..6898930a 100644 --- a/src/cli/virtual/offscreen.ts +++ b/src/cli/virtual/offscreen.ts @@ -3,7 +3,7 @@ import type {TransportType} from "adnbn/transport"; import {isValidTransportDefinition, isValidTransportInitFunction} from "adnbn/entry/transport"; import {Builder as OffscreenBuilder} from "adnbn/entry/offscreen"; -import {Builder as ViewBuilder} from "virtual:view-framework"; +import {Builder as ViewBuilder} from "virtual:view-builder"; import * as module from "virtual:offscreen-entrypoint"; diff --git a/src/cli/virtual/sandbox.ts b/src/cli/virtual/sandbox.ts index 9842c3ac..bacad2fe 100644 --- a/src/cli/virtual/sandbox.ts +++ b/src/cli/virtual/sandbox.ts @@ -3,7 +3,7 @@ import type {TransportType} from "adnbn/transport"; import {isValidTransportDefinition, isValidTransportInitFunction} from "adnbn/entry/transport"; import {Builder as SandboxBuilder} from "adnbn/entry/sandbox"; -import {Builder as ViewBuilder} from "virtual:view-framework"; +import {Builder as ViewBuilder} from "virtual:view-builder"; import * as module from "virtual:sandbox-entrypoint"; diff --git a/src/cli/virtual/view.ts b/src/cli/virtual/view.ts index e7d680e4..60537a38 100644 --- a/src/cli/virtual/view.ts +++ b/src/cli/virtual/view.ts @@ -1,22 +1,9 @@ -import type {ViewDefinition, ViewOptions} from "adnbn"; -import {isViewDefinition, isValidViewDefinitionRenderValue} from "adnbn/entry/view"; - -import view from "virtual:view-framework"; +import view, {resolveDefinition} from "virtual:view-builder"; import * as module from "virtual:view-entrypoint"; try { - const {default: defaultDefinition, ...otherDefinition} = module; - - let definition: ViewDefinition<ViewOptions> = otherDefinition; - - if (isViewDefinition(defaultDefinition)) { - definition = {...definition, ...defaultDefinition}; - } else if (isValidViewDefinitionRenderValue(defaultDefinition)) { - definition = {...definition, render: defaultDefinition}; - } - - view(definition); + view(resolveDefinition(module)); } catch (e) { console.error("The view crashed on startup:", e); } diff --git a/src/cli/virtual/virtual.d.ts b/src/cli/virtual/virtual.d.ts index 731250aa..2337430a 100644 --- a/src/cli/virtual/virtual.d.ts +++ b/src/cli/virtual/virtual.d.ts @@ -125,7 +125,11 @@ declare module "virtual:view-entrypoint" { export = module; } -declare module "virtual:view-framework" { +declare module "virtual:view-builder" { + export const resolveDefinition: ( + module: object + ) => import("@typing/view").ViewDefinition<import("@typing/view").ViewConfig>; + export const Builder: | typeof import("@entry/view/adapters/vanilla").Builder | typeof import("@entry/view/adapters/react").Builder; diff --git a/src/cli/virtual/virtual.test.ts b/src/cli/virtual/virtual.test.ts index 9fa07457..8de98da8 100644 --- a/src/cli/virtual/virtual.test.ts +++ b/src/cli/virtual/virtual.test.ts @@ -58,13 +58,30 @@ describe("Built virtual modules", () => { }, { generator: "virtualViewModule", - imports: ["adnbn", "adnbn/entry/view", "adnbn/entry/view/{framework}", "{entry}"], + imports: ["adnbn/entry/view/{framework}", "{entry}"], }, ]; let generated: Record<"ts" | "tsx", Record<Generator, string>>; let navigation: Record<"ts" | "tsx", Record<"virtualContentScriptModule" | "virtualRelayModule", string>>; let contentModule: string; + const bundleInputs = async (entrypoint: string): Promise<string[]> => { + const result = await build({ + absWorkingDir: projectDir, + entryPoints: [entrypoint], + bundle: true, + platform: "browser", + format: "esm", + // Resolve the published package exports instead of the source aliases in tsconfig.json. + tsconfigRaw: {}, + write: false, + metafile: true, + logLevel: "silent", + }); + + return Object.keys(result.metafile!.inputs).map(filename => filename.replaceAll("\\", "/")); + }; + beforeAll(() => { // Run the final JS artifact in Node, without Jest transforms, source aliases, or module mocks. const artifacts = JSON.parse( @@ -137,19 +154,7 @@ describe("Built virtual modules", () => { "adnbn/entry/content", "adnbn/entry/relay", ])("%s includes only its own framework dependencies in the bundle graph", async entrypoint => { - const result = await build({ - absWorkingDir: projectDir, - entryPoints: [entrypoint], - bundle: true, - platform: "browser", - format: "esm", - // Resolve the published package exports instead of the source aliases in tsconfig.json. - tsconfigRaw: {}, - write: false, - metafile: true, - logLevel: "silent", - }); - const inputs = Object.keys(result.metafile!.inputs).map(filename => filename.replaceAll("\\", "/")); + const inputs = await bundleInputs(entrypoint); const usesReact = entrypoint.endsWith("/react"); expect(inputs.some(filename => /node_modules\/react\//.test(filename))).toBe(usesReact); @@ -163,6 +168,18 @@ describe("Built virtual modules", () => { ); }); + test.each(["adnbn/entry/view/vanilla", "adnbn/entry/view/react"])( + "%s includes React only in the React view adapter bundle graph", + async entrypoint => { + const inputs = await bundleInputs(entrypoint); + const usesReact = entrypoint.endsWith("/react"); + + expect(inputs.some(filename => /node_modules\/react\//.test(filename))).toBe(usesReact); + expect(inputs.some(filename => /node_modules\/react-dom\//.test(filename))).toBe(usesReact); + expect(inputs.some(filename => filename.includes("entry/view/adapters/react/"))).toBe(usesReact); + } + ); + describe.each([ {extension: "ts" as const, framework: "vanilla"}, {extension: "tsx" as const, framework: "react"}, @@ -198,6 +215,12 @@ describe("Built virtual modules", () => { startup: "service", call: 'service(resolveDefinition(module, "example"))', }, + { + generator: "virtualViewModule", + specifier: "adnbn/entry/view/{framework}", + startup: "view", + call: "view(resolveDefinition(module))", + }, ] as const)( "$generator delegates normalization and startup to its runtime entrypoint", ({generator, specifier, startup, call}) => { diff --git a/src/entry/view/Builder.ts b/src/entry/view/Builder.ts new file mode 100644 index 00000000..90e164ba --- /dev/null +++ b/src/entry/view/Builder.ts @@ -0,0 +1,100 @@ +import EntrypointBuilder from "@entry/core/Builder"; + +import {viewContainerResolver} from "./resolvers/container"; + +import { + ViewBuilder, + ViewConfig, + ViewContainerCreator, + ViewContainerFactory, + ViewContainerOptions, + ViewContainerTag, + ViewDefinition, + ViewRenderHandler, + ViewRenderValue, + ViewResolvedDefinition, +} from "@typing/view"; + +/** Owns the view container; renderer adapters only resolve the render and put its value into the container. */ +export default abstract class Builder<T extends ViewConfig> extends EntrypointBuilder implements ViewBuilder { + protected readonly definition: ViewResolvedDefinition<T>; + + private container?: Element; + + protected constructor(definition: ViewDefinition<T>) { + super(); + + this.definition = { + ...definition, + container: this.resolveContainer(definition.container), + render: this.resolveRender(definition.render), + }; + } + + protected abstract mount(container: Element, value: ViewRenderValue<T>): void; + + protected unmount(): void { + // Removing the container is enough unless the renderer owns state inside it. + } + + protected resolveContainer( + container?: ViewContainerTag | ViewContainerOptions | ViewContainerFactory<T> + ): ViewContainerCreator<T> { + return viewContainerResolver<T>(container); + } + + protected resolveRender(render?: ViewRenderValue<T> | ViewRenderHandler<T>): ViewRenderHandler<T> | undefined { + if (render === undefined) { + return; + } + + throw new Error("View rendering requires a renderer adapter"); + } + + protected getProps(): T { + const {render, container, ...props} = this.definition; + + return props as T; + } + + public async build(): Promise<void> { + await this.destroy(); + + const {title, render, container} = this.definition; + + if (title) { + document.title = title; + } + + if (!render) { + return; + } + + const props = this.getProps(); + const value = await render(props); + + if (value === undefined) { + return; + } + + this.container = await container(props); + + // Mount into a connected container: renderers may measure the DOM while mounting. + document.body.prepend(this.container); + + this.mount(this.container, value); + } + + public async destroy(): Promise<void> { + if (!this.container) { + return; + } + + try { + this.unmount(); + } finally { + this.container.remove(); + this.container = undefined; + } + } +} diff --git a/src/entry/view/adapters/react/Builder.test.ts b/src/entry/view/adapters/react/Builder.test.ts new file mode 100644 index 00000000..2ff1c458 --- /dev/null +++ b/src/entry/view/adapters/react/Builder.test.ts @@ -0,0 +1,67 @@ +import {act} from "@testing-library/react"; +import {createElement, useEffect} from "react"; + +import Builder from "./Builder"; + +import type {ViewConfig} from "@typing/view"; + +describe("React view Builder", () => { + afterEach(() => { + document.body.replaceChildren(); + document.title = ""; + }); + + test("renders a component with the definition options as props", async () => { + const builder = new Builder<ViewConfig>({ + title: "React", + render: ({title}: ViewConfig) => createElement("p", null, `Title: ${title}`), + }); + + await act(() => builder.build()); + + expect(document.title).toBe("React"); + expect(document.body.firstElementChild?.innerHTML).toBe("<p>Title: React</p>"); + + await act(() => builder.destroy()); + }); + + test("renders a React element value", async () => { + const builder = new Builder<ViewConfig>({render: createElement("strong", null, "element")}); + + await act(() => builder.build()); + + expect(document.body.firstElementChild?.innerHTML).toBe("<strong>element</strong>"); + + await act(() => builder.destroy()); + }); + + test("destroy unmounts the React tree before removing the container", async () => { + const events: string[] = []; + + const Component = () => { + useEffect(() => { + events.push("mounted"); + + return () => { + events.push(`cleanup, connected: ${document.body.children.length === 1}`); + }; + }, []); + + return createElement("p", null, "effects"); + }; + + const builder = new Builder<ViewConfig>({render: Component}); + + await act(() => builder.build()); + await act(() => builder.destroy()); + + expect(events).toEqual(["mounted", "cleanup, connected: true"]); + expect(document.body.children).toHaveLength(0); + }); + + test("creates no container for a value React cannot render", async () => { + await act(() => new Builder<ViewConfig>({render: "text"}).build()); + + expect(document.body.children).toHaveLength(0); + }); +}); diff --git a/src/entry/view/adapters/react/Builder.ts b/src/entry/view/adapters/react/Builder.ts index 171fdce6..6a8eac10 100644 --- a/src/entry/view/adapters/react/Builder.ts +++ b/src/entry/view/adapters/react/Builder.ts @@ -1,54 +1,42 @@ -import {isValidElement} from "react"; +import {createElement, isValidElement} from "react"; import {createRoot, Root} from "react-dom/client"; -import Builder from "../../core/Builder"; +import ViewBuilder from "../../Builder"; -import {viewReactRenderResolver} from "./resolvers/render"; +import {ViewConfig, ViewDefinition, ViewRenderHandler, ViewRenderReactComponent, ViewRenderValue} from "@typing/view"; -import {ViewConfig, ViewDefinition, ViewRenderHandler, ViewRenderValue} from "@typing/view"; - -export default class<T extends ViewConfig> extends Builder<T> { +export default class Builder<T extends ViewConfig> extends ViewBuilder<T> { protected root?: Root; - protected container?: Element; - public constructor(definition: ViewDefinition<T>) { super(definition); } - protected resolveRender(render?: ViewRenderValue<T>): ViewRenderHandler<T> { - return viewReactRenderResolver(render); - } - - public async build(): Promise<void> { - await super.build(); - - const props = this.getProps(); - - const element = await this.definition.render(props); - - if (!isValidElement(element)) { + protected resolveRender(render?: ViewRenderValue<T> | ViewRenderHandler<T>): ViewRenderHandler<T> | undefined { + if (render === undefined) { return; } - this.container = await this.definition.container(props); + return async props => { + // Functions in React entrypoints are components; React owns their invocation and hooks. + const value = + typeof render === "function" ? createElement(render as ViewRenderReactComponent<T>, props) : render; + + return isValidElement(value) ? value : undefined; + }; + } - if (!this.container) { + protected mount(container: Element, value: ViewRenderValue<T>): void { + if (!isValidElement(value)) { return; } - document.body.prepend(this.container); - - this.root = createRoot(this.container); - - this.root.render(element); + this.root = createRoot(container); + this.root.render(value); } - public async destroy(): Promise<void> { + protected unmount(): void { this.root?.unmount(); this.root = undefined; - - this.container?.remove(); - this.container = undefined; } } diff --git a/src/entry/view/adapters/react/definition.test.ts b/src/entry/view/adapters/react/definition.test.ts new file mode 100644 index 00000000..29cb309e --- /dev/null +++ b/src/entry/view/adapters/react/definition.test.ts @@ -0,0 +1,33 @@ +import {createElement} from "react"; + +import {resolveDefinition} from "./definition"; + +describe("React view definitions", () => { + test("uses a default React element as the render", () => { + const element = createElement("p", null, "Hello"); + + expect(resolveDefinition({default: element, title: "Named"})).toEqual({title: "Named", render: element}); + }); + + test("uses a default component function as the render without calling it", () => { + const Component = jest.fn(() => null); + + expect(resolveDefinition({default: Component})).toEqual({render: Component}); + expect(Component).not.toHaveBeenCalled(); + }); + + test("merges a default options object over named exports", () => { + const render = jest.fn(); + + expect(resolveDefinition({default: {title: "Default", render}, title: "Named"})).toEqual({ + title: "Default", + render, + }); + }); + + test("a property named $$typeof alone does not identify a React element", () => { + const definition = {title: "Options", $$typeof: "application metadata"}; + + expect(resolveDefinition({default: definition})).toEqual(definition); + }); +}); diff --git a/src/entry/view/adapters/react/definition.ts b/src/entry/view/adapters/react/definition.ts new file mode 100644 index 00000000..5ec6b22c --- /dev/null +++ b/src/entry/view/adapters/react/definition.ts @@ -0,0 +1,17 @@ +import {isValidElement} from "react"; + +import {mergeDefinition} from "../../resolvers/definition"; + +import type {ViewConfig, ViewDefinition} from "@typing/view"; + +export const resolveDefinition = (module: object): ViewDefinition<ViewConfig> => { + return mergeDefinition( + module, + value => + typeof value === "function" || + isValidElement(value) || + typeof value === "number" || + (typeof value === "string" && value.length > 0) || + value instanceof Element + ); +}; diff --git a/src/entry/view/adapters/react/index.ts b/src/entry/view/adapters/react/index.ts index 230e7fd9..803aed33 100644 --- a/src/entry/view/adapters/react/index.ts +++ b/src/entry/view/adapters/react/index.ts @@ -1,5 +1,6 @@ import Builder from "./Builder"; export {Builder}; +export {resolveDefinition} from "./definition"; export default Builder.resolver(); diff --git a/src/entry/view/adapters/react/resolvers/render.ts b/src/entry/view/adapters/react/resolvers/render.ts deleted file mode 100644 index bd074623..00000000 --- a/src/entry/view/adapters/react/resolvers/render.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {createElement, isValidElement} from "react"; - -import {ViewRenderHandler, ViewRenderValue, ViewConfig} from "@typing/view"; - -export const viewReactRenderResolver = - <T extends ViewConfig>(render?: ViewRenderValue<T>): ViewRenderHandler<T> => - async (props): Promise<void | ViewRenderValue<T>> => { - let resolvedRender = typeof render === "function" ? createElement(render, props) : render; - - if (isValidElement(resolvedRender)) { - return resolvedRender; - } - }; diff --git a/src/entry/view/adapters/vanilla/Builder.test.ts b/src/entry/view/adapters/vanilla/Builder.test.ts new file mode 100644 index 00000000..4483092a --- /dev/null +++ b/src/entry/view/adapters/vanilla/Builder.test.ts @@ -0,0 +1,71 @@ +import Builder from "./Builder"; + +import type {ViewConfig} from "@typing/view"; + +describe("Vanilla view Builder", () => { + afterEach(() => { + document.body.replaceChildren(); + document.title = ""; + }); + + test("prepends a container with the rendered element and sets the title", async () => { + const existing = document.createElement("main"); + const element = document.createElement("p"); + const builder = new Builder<ViewConfig>({title: "Vanilla", render: () => element}); + + document.body.append(existing); + + await builder.build(); + + expect(document.title).toBe("Vanilla"); + expect(document.body.firstElementChild?.tagName).toBe("DIV"); + expect(document.body.firstElementChild?.firstElementChild).toBe(element); + expect(document.body.lastElementChild).toBe(existing); + }); + + test("passes the definition options as render props", async () => { + const render = jest.fn(() => "rendered"); + + await new Builder<ViewConfig>({title: "Props", template: "page.html", render}).build(); + + expect(render).toHaveBeenCalledWith({title: "Props", template: "page.html"}); + }); + + test.each([ + ["an HTML string as markup", "<b>bold</b>", "<b>bold</b>"], + ["zero as text", 0, "0"], + ])("renders %s", async (_, value, html) => { + await new Builder<ViewConfig>({render: value}).build(); + + expect(document.body.firstElementChild?.innerHTML).toBe(html); + }); + + test("creates the container from the container option", async () => { + await new Builder<ViewConfig>({container: {tagName: "section", id: "root"}, render: "content"}).build(); + + expect(document.body.firstElementChild?.outerHTML).toBe('<section id="root">content</section>'); + }); + + test.each([ + ["without render", undefined], + ["for an unsupported value", () => ({}) as unknown as string], + ])("creates no container %s", async (_, render) => { + await new Builder<ViewConfig>({title: "Empty", render}).build(); + + expect(document.title).toBe("Empty"); + expect(document.body.children).toHaveLength(0); + }); + + test("rebuilding replaces the container and destroy removes it", async () => { + const builder = new Builder<ViewConfig>({render: () => "content"}); + + await builder.build(); + await builder.build(); + + expect(document.body.children).toHaveLength(1); + + await builder.destroy(); + + expect(document.body.children).toHaveLength(0); + }); +}); diff --git a/src/entry/view/adapters/vanilla/Builder.ts b/src/entry/view/adapters/vanilla/Builder.ts index b70b35b3..456c5d66 100644 --- a/src/entry/view/adapters/vanilla/Builder.ts +++ b/src/entry/view/adapters/vanilla/Builder.ts @@ -1,44 +1,30 @@ -import Builder from "../../core/Builder"; +import ViewBuilder from "../../Builder"; +import {isValidRenderValue} from "./utils"; -import {ViewConfig, ViewDefinition} from "@typing/view"; - -export default class<T extends ViewConfig> extends Builder<T> { - protected container?: Element; +import {ViewConfig, ViewDefinition, ViewRenderHandler, ViewRenderValue} from "@typing/view"; +export default class Builder<T extends ViewConfig> extends ViewBuilder<T> { public constructor(definition: ViewDefinition<T>) { super(definition); } - public async build(): Promise<void> { - await super.build(); - - const props = this.getProps(); - - const content = await this.definition.render(props); - - if (!content) { + protected resolveRender(render?: ViewRenderValue<T> | ViewRenderHandler<T>): ViewRenderHandler<T> | undefined { + if (render === undefined) { return; } - this.container = await this.definition.container(props); + return async props => { + const value = typeof render === "function" ? await render(props) : render; - if (!this.container) { - return; - } - - if (content instanceof Element) { - this.container.appendChild(content); - } else if (typeof content === "string" || typeof content === "number") { - this.container.innerHTML = content.toString(); - } else { - return; - } - - document.body.prepend(this.container); + return isValidRenderValue(value) ? value : undefined; + }; } - public async destroy(): Promise<void> { - this.container?.remove(); - this.container = undefined; + protected mount(container: Element, value: ViewRenderValue<T>): void { + if (value instanceof Element) { + container.appendChild(value); + } else if (typeof value === "string" || typeof value === "number") { + container.innerHTML = value.toString(); + } } } diff --git a/src/entry/view/adapters/vanilla/definition.test.ts b/src/entry/view/adapters/vanilla/definition.test.ts new file mode 100644 index 00000000..7044ec53 --- /dev/null +++ b/src/entry/view/adapters/vanilla/definition.test.ts @@ -0,0 +1,30 @@ +import {createElement} from "react"; + +import {resolveDefinition} from "./definition"; + +describe("Vanilla view definitions", () => { + test.each([ + ["a render function", () => "rendered"], + ["a non-empty string", "<p>Hello</p>"], + ["a number", 42], + ["an element", document.createElement("p")], + ])("uses %s as the default render", (_, value) => { + expect(resolveDefinition({default: value, title: "Named"})).toEqual({title: "Named", render: value}); + }); + + test("does not recognize React elements, so a plain element object is options", () => { + const element = createElement("p"); + + expect(resolveDefinition({default: element})).not.toHaveProperty("render"); + }); + + test("merges a default options object over named exports", () => { + const render = jest.fn(); + + expect(resolveDefinition({default: {title: "Default", render}, title: "Named"})).toEqual({ + title: "Default", + render, + }); + expect(render).not.toHaveBeenCalled(); + }); +}); diff --git a/src/entry/view/adapters/vanilla/definition.ts b/src/entry/view/adapters/vanilla/definition.ts new file mode 100644 index 00000000..71355f4b --- /dev/null +++ b/src/entry/view/adapters/vanilla/definition.ts @@ -0,0 +1,8 @@ +import {mergeDefinition} from "../../resolvers/definition"; +import {isValidRenderValue} from "./utils"; + +import type {ViewConfig, ViewDefinition} from "@typing/view"; + +export const resolveDefinition = (module: object): ViewDefinition<ViewConfig> => { + return mergeDefinition(module, value => typeof value === "function" || isValidRenderValue(value)); +}; diff --git a/src/entry/view/adapters/vanilla/index.ts b/src/entry/view/adapters/vanilla/index.ts index 230e7fd9..803aed33 100644 --- a/src/entry/view/adapters/vanilla/index.ts +++ b/src/entry/view/adapters/vanilla/index.ts @@ -1,5 +1,6 @@ import Builder from "./Builder"; export {Builder}; +export {resolveDefinition} from "./definition"; export default Builder.resolver(); diff --git a/src/entry/view/adapters/vanilla/utils.ts b/src/entry/view/adapters/vanilla/utils.ts new file mode 100644 index 00000000..022f9add --- /dev/null +++ b/src/entry/view/adapters/vanilla/utils.ts @@ -0,0 +1,3 @@ +export const isValidRenderValue = (value: unknown): value is string | number | Element => { + return (typeof value === "string" && value.length > 0) || typeof value === "number" || value instanceof Element; +}; diff --git a/src/entry/view/core/Builder.ts b/src/entry/view/core/Builder.ts deleted file mode 100644 index 56634073..00000000 --- a/src/entry/view/core/Builder.ts +++ /dev/null @@ -1,57 +0,0 @@ -import Builder from "@entry/core/Builder"; - -import {viewContainerResolver} from "./resolvers/container"; -import {viewRenderResolver} from "./resolvers/render"; - -import { - ViewBuilder, - ViewConfig, - ViewContainerCreator, - ViewContainerFactory, - ViewContainerOptions, - ViewContainerTag, - ViewDefinition, - ViewRenderHandler, - ViewRenderValue, - ViewResolvedDefinition, -} from "@typing/view"; - -export default abstract class<T extends ViewConfig> extends Builder implements ViewBuilder { - protected readonly definition: ViewResolvedDefinition<T>; - - protected constructor(definition: ViewDefinition<T>) { - super(); - - this.definition = { - ...definition, - container: this.resolveContainer(definition.container), - render: this.resolveRender(definition.render), - }; - } - - protected resolveContainer( - container?: ViewContainerTag | ViewContainerOptions | ViewContainerFactory<T> - ): ViewContainerCreator<T> { - return viewContainerResolver<T>(container); - } - - protected resolveRender(render?: ViewRenderValue<T> | ViewRenderHandler<T>): ViewRenderHandler<T> { - return viewRenderResolver<T>(render); - } - - protected getProps(): T { - const {render, container, ...props} = this.definition; - - return props as T; - } - - public async build(): Promise<void> { - await this.destroy(); - - const {title} = this.definition; - - if (title) { - document.title = title; - } - } -} diff --git a/src/entry/view/core/resolvers/definition.ts b/src/entry/view/core/resolvers/definition.ts deleted file mode 100644 index c835d537..00000000 --- a/src/entry/view/core/resolvers/definition.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {isValidViewRenderValue} from "../resolvers/render"; - -import {ViewConfig, ViewDefinition, ViewRenderHandler, ViewRenderValue} from "@typing/view"; - -export const isViewDefinition = <T extends ViewConfig = ViewConfig>( - definition: any -): definition is ViewDefinition<T> => { - return ( - definition && typeof definition === "object" && definition.constructor === Object && !("$$typeof" in definition) - ); -}; - -export const isValidViewDefinitionRenderValue = <T extends ViewConfig = ViewConfig>( - definition: any -): definition is ViewRenderValue<T> | ViewRenderHandler<T> => { - return ( - isValidViewRenderValue(definition) || - typeof definition === "function" || - (typeof definition === "object" && definition.constructor === Object && "$$typeof" in definition) - ); -}; diff --git a/src/entry/view/core/resolvers/render.ts b/src/entry/view/core/resolvers/render.ts deleted file mode 100644 index cf885eb2..00000000 --- a/src/entry/view/core/resolvers/render.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {ViewConfig, ViewRenderHandler, ViewRenderValue} from "@typing/view"; - -export const isValidViewRenderValue = (value: unknown): value is string | number | Element => { - return (typeof value === "string" && value.length > 0) || typeof value === "number" || value instanceof Element; -}; - -export const viewRenderResolver = - <T extends ViewConfig>(render?: ViewRenderValue<T> | ViewRenderHandler<T>): ViewRenderHandler<T> => - async (props): Promise<void | ViewRenderValue<T>> => { - let resolvedRender = typeof render === "function" ? render(props) : render; - - if (resolvedRender instanceof Promise) { - resolvedRender = await resolvedRender; - } - - if (!isValidViewRenderValue(resolvedRender)) { - return; - } - - return resolvedRender; - }; diff --git a/src/entry/view/index.ts b/src/entry/view/index.ts deleted file mode 100644 index 2e4efe5f..00000000 --- a/src/entry/view/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./core/resolvers/definition"; diff --git a/src/entry/view/core/resolvers/container.ts b/src/entry/view/resolvers/container.ts similarity index 100% rename from src/entry/view/core/resolvers/container.ts rename to src/entry/view/resolvers/container.ts diff --git a/src/entry/view/resolvers/definition.test.ts b/src/entry/view/resolvers/definition.test.ts new file mode 100644 index 00000000..a1eeffd9 --- /dev/null +++ b/src/entry/view/resolvers/definition.test.ts @@ -0,0 +1,35 @@ +import {mergeDefinition} from "./definition"; + +describe("View definitions", () => { + const isRenderInput = (value: unknown): boolean => typeof value === "function"; + + test("merges default options over named exports without mutating either object", () => { + const render = jest.fn(); + const defaults = Object.freeze({title: "Default", render}); + const module = Object.freeze({default: defaults, title: "Named", template: "page.html"}); + + expect(mergeDefinition(module, isRenderInput)).toEqual({title: "Default", render, template: "page.html"}); + expect(module.title).toBe("Named"); + expect(render).not.toHaveBeenCalled(); + }); + + test("uses a recognized default render over a named render", () => { + const render = jest.fn(); + const namedRender = jest.fn(); + + expect(mergeDefinition({default: render, render: namedRender, title: "Named"}, isRenderInput)).toEqual({ + title: "Named", + render, + }); + expect(render).not.toHaveBeenCalled(); + }); + + test.each([undefined, null, false, true, "", 0, []])("preserves named options with a default %p", value => { + const render = jest.fn(); + + expect(mergeDefinition({default: value, render, title: "Named"}, isRenderInput)).toEqual({ + render, + title: "Named", + }); + }); +}); diff --git a/src/entry/view/resolvers/definition.ts b/src/entry/view/resolvers/definition.ts new file mode 100644 index 00000000..590ef58e --- /dev/null +++ b/src/entry/view/resolvers/definition.ts @@ -0,0 +1,25 @@ +import {ViewConfig, ViewDefinition} from "@typing/view"; + +/** Adapters must recognize their render values before falling back to an options object. */ +const isDefinitionObject = (value: unknown): value is ViewDefinition<ViewConfig> => { + return value !== null && typeof value === "object" && value.constructor === Object; +}; + +/** Merge module exports using the selected adapter's interpretation of the default value. */ +export const mergeDefinition = ( + module: object, + isRenderInput: (value: unknown) => boolean +): ViewDefinition<ViewConfig> => { + const {default: defaultDefinition, ...namedDefinition} = module as Record<string, unknown>; + const definition = namedDefinition as ViewDefinition<ViewConfig>; + + if (isRenderInput(defaultDefinition)) { + return {...definition, render: defaultDefinition} as ViewDefinition<ViewConfig>; + } + + if (isDefinitionObject(defaultDefinition)) { + return {...definition, ...defaultDefinition}; + } + + return definition; +}; diff --git a/src/types/view.ts b/src/types/view.ts index 19720b17..3f82cf46 100644 --- a/src/types/view.ts +++ b/src/types/view.ts @@ -42,7 +42,7 @@ export interface ViewDefinition<T extends ViewConfig> extends ViewOptions { } export interface ViewResolvedDefinition<T extends ViewConfig> extends ViewDefinition<T> { - render: ViewRenderHandler<T>; + render?: ViewRenderHandler<T>; container: ViewContainerCreator<T>; } From e80fe2344764e00d1f4cbacd669b8ec10e926e20 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:41:21 +0300 Subject: [PATCH 19/28] feat(entry)!: render text and DOM elements the same way in every adapter Share one render-value contract between the view and content adapters in src/entry/core/render.ts: a non-empty string or a number is inserted as text and never parsed as HTML, a DOM element is appended as is, and a React element still goes through React. The React adapters now render text and DOM elements instead of silently dropping them, the Vanilla view adapter stops parsing strings with innerHTML, and elements from another realm such as an iframe document are recognized by node type. Cover the shared contract in render.test.ts and the adapters through their builders, and document the rule in the content README. BREAKING CHANGE: A string returned by a Vanilla view render is shown as text; return an Element or use a UI framework to render markup. --- src/entry/content/README.md | 10 +++- .../content/adapters/react/Builder.test.ts | 28 +++++++++ src/entry/content/adapters/react/Builder.ts | 4 +- src/entry/content/adapters/react/Node.ts | 18 ++++-- .../content/adapters/react/definition.ts | 10 ++-- .../content/adapters/vanilla/Builder.test.ts | 3 +- src/entry/content/adapters/vanilla/Builder.ts | 5 +- src/entry/content/adapters/vanilla/Node.ts | 10 ++-- .../content/adapters/vanilla/definition.ts | 5 +- src/entry/content/adapters/vanilla/utils.ts | 3 - src/entry/core/render.test.ts | 59 +++++++++++++++++++ src/entry/core/render.ts | 20 +++++++ src/entry/view/adapters/react/Builder.test.ts | 23 +++++++- src/entry/view/adapters/react/Builder.ts | 14 +++-- src/entry/view/adapters/react/definition.ts | 9 +-- .../view/adapters/vanilla/Builder.test.ts | 9 +-- src/entry/view/adapters/vanilla/Builder.ts | 11 ++-- src/entry/view/adapters/vanilla/definition.ts | 5 +- src/entry/view/adapters/vanilla/utils.ts | 3 - 19 files changed, 192 insertions(+), 57 deletions(-) delete mode 100644 src/entry/content/adapters/vanilla/utils.ts create mode 100644 src/entry/core/render.test.ts create mode 100644 src/entry/core/render.ts delete mode 100644 src/entry/view/adapters/vanilla/utils.ts diff --git a/src/entry/content/README.md b/src/entry/content/README.md index b0b163d5..e2c1c7e8 100644 --- a/src/entry/content/README.md +++ b/src/entry/content/README.md @@ -130,13 +130,17 @@ Each adapter interprets its own default export before shared code merges options override named options; a recognized default render value overrides a named `render`. React uses `isValidElement` to recognize elements without exposing that dependency to Vanilla or the common runtime. For React UI, pass a React element or component function. Component functions are invoked by React -and may use hooks. Vanilla renders DOM elements, nonempty strings, and numbers. +and may use hooks. Every adapter also renders the framework-independent values the same way: a +nonempty string or a number becomes text through `textContent` and is never parsed as HTML, and a DOM +element is appended as is. Markup comes from elements or from the UI framework. Empty strings, +booleans, `null`, and `undefined` render nothing. `mergeDefinition` combines exports using the selected resolver's interpretation of default values. The common `resolveDefinition` accepts configuration without recognizing framework components; adapters provide their own definition resolvers. The common builder accepts absent rendering and literal `render: true`; UI rendering requires an adapter. Each adapter prepares synchronous render -handlers in its builder's `resolveRender()` method. Vanilla shares its value check between the builder -and the definition resolver through `adapters/vanilla/utils.ts`. +handlers in its builder's `resolveRender()` method. The value check and the text/element rendering are +shared with the view adapters through `src/entry/core/render.ts`; it compares the node type instead of +`instanceof Element`, so elements created in an iframe document are accepted. The CLI `ContentParser.ts` and its test live beside the other parsers in `src/cli/entrypoint/parser`. Content fixtures live in `parser/tests/fixtures/content`. The helper in diff --git a/src/entry/content/adapters/react/Builder.test.ts b/src/entry/content/adapters/react/Builder.test.ts index 82dce036..51c86d8c 100644 --- a/src/entry/content/adapters/react/Builder.test.ts +++ b/src/entry/content/adapters/react/Builder.test.ts @@ -121,6 +121,34 @@ describe("React Builder", () => { await act(() => builder.destroy()); } }); + + test.each(["text", "<b>text</b>", 0, 12])("React renders the default value %p as text", async value => { + const anchor = document.createElement("article"); + document.body.appendChild(anchor); + const builder = new Builder(resolveDefinition({anchor, default: value})); + + try { + await act(() => builder.build()); + expect(anchor.textContent).toBe(String(value)); + expect(anchor.querySelector("b")).toBeNull(); + } finally { + await act(() => builder.destroy()); + } + }); + + test("React appends a DOM element render value", async () => { + const anchor = document.createElement("article"); + const element = document.createElement("span"); + document.body.appendChild(anchor); + const builder = new Builder(resolveDefinition({anchor, default: element})); + + try { + await act(() => builder.build()); + expect(anchor.querySelector("span")).toBe(element); + } finally { + await act(() => builder.destroy()); + } + }); }); test("React receives prepared data and fresh DOM props while retaining state on the same target", async () => { diff --git a/src/entry/content/adapters/react/Builder.ts b/src/entry/content/adapters/react/Builder.ts index ceab113b..231c27b2 100644 --- a/src/entry/content/adapters/react/Builder.ts +++ b/src/entry/content/adapters/react/Builder.ts @@ -1,5 +1,7 @@ import {createElement, isValidElement} from "react"; +import {isDomRenderValue} from "@entry/core/render"; + import ReactNode from "./Node"; import MountBuilder from "../../lifecycle/MountBuilder"; @@ -32,7 +34,7 @@ export default class Builder< ? createElement(render as ContentScriptRenderReactComponent<Data>, props) : render; - return isValidElement(value) ? value : undefined; + return isValidElement(value) || isDomRenderValue(value) ? value : undefined; }; } diff --git a/src/entry/content/adapters/react/Node.ts b/src/entry/content/adapters/react/Node.ts index 1e97a6a6..500e17b0 100644 --- a/src/entry/content/adapters/react/Node.ts +++ b/src/entry/content/adapters/react/Node.ts @@ -1,6 +1,8 @@ import {isValidElement} from "react"; import {createRoot, type Root} from "react-dom/client"; +import {isDomRenderValue, renderDomValue} from "@entry/core/render"; + import {RenderNode} from "../../lifecycle/nodes"; import type {ContentScriptRenderValue} from "@typing/content"; @@ -9,14 +11,20 @@ export default class Node<Data = unknown> extends RenderNode<Data> { private root?: Root; protected render(value: ContentScriptRenderValue<Data> | undefined, target: Element): boolean { - if (!isValidElement(value)) { - return false; + if (isValidElement(value)) { + this.root = createRoot(target); + this.root.render(value); + + return true; } - this.root = createRoot(target); - this.root.render(value); + if (isDomRenderValue(value)) { + renderDomValue(target, value); + + return true; + } - return true; + return false; } protected clear(): void { diff --git a/src/entry/content/adapters/react/definition.ts b/src/entry/content/adapters/react/definition.ts index 7bdb034e..b970fc21 100644 --- a/src/entry/content/adapters/react/definition.ts +++ b/src/entry/content/adapters/react/definition.ts @@ -1,4 +1,7 @@ import {isValidElement} from "react"; + +import {isDomRenderValue} from "@entry/core/render"; + import {mergeDefinition} from "../../resolvers/definition"; import type {ContentScriptDefinition} from "@typing/content"; @@ -6,11 +9,6 @@ import type {ContentScriptDefinition} from "@typing/content"; export const resolveDefinition = (module: object): ContentScriptDefinition => { return mergeDefinition( module, - value => - typeof value === "function" || - isValidElement(value) || - typeof value === "number" || - (typeof value === "string" && value.length > 0) || - value instanceof Element + value => typeof value === "function" || isValidElement(value) || isDomRenderValue(value) ); }; diff --git a/src/entry/content/adapters/vanilla/Builder.test.ts b/src/entry/content/adapters/vanilla/Builder.test.ts index 1bb7ee0a..d62bf7d8 100644 --- a/src/entry/content/adapters/vanilla/Builder.test.ts +++ b/src/entry/content/adapters/vanilla/Builder.test.ts @@ -153,7 +153,7 @@ describe("Vanilla Builder", () => { ]); }); - test.each(["text", 0, 12])("Vanilla preserves the default render value %p", async value => { + test.each(["text", "<b>text</b>", 0, 12])("Vanilla renders the default value %p as text", async value => { const anchor = document.createElement("article"); document.body.appendChild(anchor); const builder = new Builder(resolveDefinition({anchor, default: value})); @@ -161,6 +161,7 @@ describe("Vanilla Builder", () => { try { await builder.build(); expect(anchor.textContent).toBe(String(value)); + expect(anchor.querySelector("b")).toBeNull(); } finally { await builder.destroy(); } diff --git a/src/entry/content/adapters/vanilla/Builder.ts b/src/entry/content/adapters/vanilla/Builder.ts index be54c1be..b13db256 100644 --- a/src/entry/content/adapters/vanilla/Builder.ts +++ b/src/entry/content/adapters/vanilla/Builder.ts @@ -1,6 +1,7 @@ +import {isDomRenderValue} from "@entry/core/render"; + import MountBuilder from "../../lifecycle/MountBuilder"; import VanillaNode from "./Node"; -import {isValidRenderValue} from "./utils"; import type {ContentScriptRenderOptions} from "../../lifecycle/types"; import type { @@ -25,7 +26,7 @@ export default class Builder< return props => { const resolvedRender = typeof render === "function" ? render(props) : render; - if (!isValidRenderValue(resolvedRender)) { + if (!isDomRenderValue(resolvedRender)) { return; } diff --git a/src/entry/content/adapters/vanilla/Node.ts b/src/entry/content/adapters/vanilla/Node.ts index 3838bdc4..1fc4db6a 100644 --- a/src/entry/content/adapters/vanilla/Node.ts +++ b/src/entry/content/adapters/vanilla/Node.ts @@ -1,16 +1,16 @@ +import {isDomRenderValue, renderDomValue} from "@entry/core/render"; + import {RenderNode} from "../../lifecycle/nodes"; import type {ContentScriptRenderValue} from "@typing/content"; export default class Node<Data = unknown> extends RenderNode<Data> { protected render(value: ContentScriptRenderValue<Data> | undefined, target: Element): boolean { - if (value && typeof value === "object" && "nodeType" in value && value.nodeType === 1) { - target.appendChild(value as Element); - } else if (typeof value === "string" || typeof value === "number") { - target.textContent = String(value); - } else { + if (!isDomRenderValue(value)) { return false; } + renderDomValue(target, value); + return true; } diff --git a/src/entry/content/adapters/vanilla/definition.ts b/src/entry/content/adapters/vanilla/definition.ts index ff1d6cf6..43c41d87 100644 --- a/src/entry/content/adapters/vanilla/definition.ts +++ b/src/entry/content/adapters/vanilla/definition.ts @@ -1,8 +1,9 @@ +import {isDomRenderValue} from "@entry/core/render"; + import {mergeDefinition} from "../../resolvers/definition"; -import {isValidRenderValue} from "./utils"; import type {ContentScriptDefinition} from "@typing/content"; export const resolveDefinition = (module: object): ContentScriptDefinition => { - return mergeDefinition(module, value => typeof value === "function" || isValidRenderValue(value)); + return mergeDefinition(module, value => typeof value === "function" || isDomRenderValue(value)); }; diff --git a/src/entry/content/adapters/vanilla/utils.ts b/src/entry/content/adapters/vanilla/utils.ts deleted file mode 100644 index 022f9add..00000000 --- a/src/entry/content/adapters/vanilla/utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const isValidRenderValue = (value: unknown): value is string | number | Element => { - return (typeof value === "string" && value.length > 0) || typeof value === "number" || value instanceof Element; -}; diff --git a/src/entry/core/render.test.ts b/src/entry/core/render.test.ts new file mode 100644 index 00000000..270b92c9 --- /dev/null +++ b/src/entry/core/render.test.ts @@ -0,0 +1,59 @@ +import {isDomRenderValue, renderDomValue} from "./render"; + +describe("DOM render values", () => { + test.each([ + ["a non-empty string", "text"], + ["an HTML string", "<b>text</b>"], + ["a number", 42], + ["zero", 0], + ["an element", document.createElement("p")], + ])("accepts %s", (_, value) => { + expect(isDomRenderValue(value)).toBe(true); + }); + + test("accepts an element created in another realm", () => { + const frame = document.createElement("iframe"); + + document.body.append(frame); + + const element = frame.contentDocument!.createElement("p"); + + expect(element instanceof Element).toBe(false); + expect(isDomRenderValue(element)).toBe(true); + + frame.remove(); + }); + + test.each([ + ["an empty string", ""], + ["a boolean", true], + ["null", null], + ["undefined", undefined], + ["a text node", document.createTextNode("text")], + ["a plain object", {nodeType: "1"}], + ])("rejects %s", (_, value) => { + expect(isDomRenderValue(value)).toBe(false); + }); + + test("renders an HTML string as text", () => { + const target = document.createElement("div"); + + renderDomValue(target, "<b>text</b>"); + + expect(target.children).toHaveLength(0); + expect(target.textContent).toBe("<b>text</b>"); + }); + + test("renders a number as text and appends an element", () => { + const target = document.createElement("div"); + const element = document.createElement("p"); + + renderDomValue(target, 0); + + expect(target.textContent).toBe("0"); + + renderDomValue(target, element); + + expect(target.lastChild).toBe(element); + }); +}); diff --git a/src/entry/core/render.ts b/src/entry/core/render.ts new file mode 100644 index 00000000..169dfef6 --- /dev/null +++ b/src/entry/core/render.ts @@ -0,0 +1,20 @@ +/** Render values that every adapter supports without a UI framework. */ +export type DomRenderValue = string | number | Element; + +export const isDomRenderValue = (value: unknown): value is DomRenderValue => { + return ( + (typeof value === "string" && value.length > 0) || + typeof value === "number" || + // Compare the node type, not instanceof Element: an element may come from another realm, such as an iframe. + (typeof value === "object" && value !== null && (value as Node).nodeType === Node.ELEMENT_NODE) + ); +}; + +/** Text is always inserted as text, never parsed as HTML; markup comes from elements or a UI framework. */ +export const renderDomValue = (target: Element, value: DomRenderValue): void => { + if (typeof value === "object") { + target.appendChild(value); + } else { + target.textContent = String(value); + } +}; diff --git a/src/entry/view/adapters/react/Builder.test.ts b/src/entry/view/adapters/react/Builder.test.ts index 2ff1c458..f50090bf 100644 --- a/src/entry/view/adapters/react/Builder.test.ts +++ b/src/entry/view/adapters/react/Builder.test.ts @@ -59,9 +59,28 @@ describe("React view Builder", () => { expect(document.body.children).toHaveLength(0); }); - test("creates no container for a value React cannot render", async () => { - await act(() => new Builder<ViewConfig>({render: "text"}).build()); + test.each([ + ["an HTML string", "<b>bold</b>"], + ["zero", 0], + ])("renders %s as text", async (_, value) => { + await act(() => new Builder<ViewConfig>({render: value}).build()); + + expect(document.body.firstElementChild?.textContent).toBe(String(value)); + expect(document.body.firstElementChild?.children).toHaveLength(0); + }); + + test("appends a DOM element render value", async () => { + const element = document.createElement("p"); + + await act(() => new Builder<ViewConfig>({render: element}).build()); + + expect(document.body.firstElementChild?.firstElementChild).toBe(element); + }); + + test("creates no container for an unsupported value", async () => { + await act(() => new Builder<ViewConfig>({title: "Empty", render: true}).build()); + expect(document.title).toBe("Empty"); expect(document.body.children).toHaveLength(0); }); }); diff --git a/src/entry/view/adapters/react/Builder.ts b/src/entry/view/adapters/react/Builder.ts index 6a8eac10..fad6e0c8 100644 --- a/src/entry/view/adapters/react/Builder.ts +++ b/src/entry/view/adapters/react/Builder.ts @@ -1,6 +1,8 @@ import {createElement, isValidElement} from "react"; import {createRoot, Root} from "react-dom/client"; +import {isDomRenderValue, renderDomValue} from "@entry/core/render"; + import ViewBuilder from "../../Builder"; import {ViewConfig, ViewDefinition, ViewRenderHandler, ViewRenderReactComponent, ViewRenderValue} from "@typing/view"; @@ -22,17 +24,17 @@ export default class Builder<T extends ViewConfig> extends ViewBuilder<T> { const value = typeof render === "function" ? createElement(render as ViewRenderReactComponent<T>, props) : render; - return isValidElement(value) ? value : undefined; + return isValidElement(value) || isDomRenderValue(value) ? value : undefined; }; } protected mount(container: Element, value: ViewRenderValue<T>): void { - if (!isValidElement(value)) { - return; + if (isValidElement(value)) { + this.root = createRoot(container); + this.root.render(value); + } else if (isDomRenderValue(value)) { + renderDomValue(container, value); } - - this.root = createRoot(container); - this.root.render(value); } protected unmount(): void { diff --git a/src/entry/view/adapters/react/definition.ts b/src/entry/view/adapters/react/definition.ts index 5ec6b22c..2e2dc9db 100644 --- a/src/entry/view/adapters/react/definition.ts +++ b/src/entry/view/adapters/react/definition.ts @@ -1,5 +1,7 @@ import {isValidElement} from "react"; +import {isDomRenderValue} from "@entry/core/render"; + import {mergeDefinition} from "../../resolvers/definition"; import type {ViewConfig, ViewDefinition} from "@typing/view"; @@ -7,11 +9,6 @@ import type {ViewConfig, ViewDefinition} from "@typing/view"; export const resolveDefinition = (module: object): ViewDefinition<ViewConfig> => { return mergeDefinition( module, - value => - typeof value === "function" || - isValidElement(value) || - typeof value === "number" || - (typeof value === "string" && value.length > 0) || - value instanceof Element + value => typeof value === "function" || isValidElement(value) || isDomRenderValue(value) ); }; diff --git a/src/entry/view/adapters/vanilla/Builder.test.ts b/src/entry/view/adapters/vanilla/Builder.test.ts index 4483092a..eb8ab57a 100644 --- a/src/entry/view/adapters/vanilla/Builder.test.ts +++ b/src/entry/view/adapters/vanilla/Builder.test.ts @@ -32,12 +32,13 @@ describe("Vanilla view Builder", () => { }); test.each([ - ["an HTML string as markup", "<b>bold</b>", "<b>bold</b>"], - ["zero as text", 0, "0"], - ])("renders %s", async (_, value, html) => { + ["an HTML string", "<b>bold</b>"], + ["zero", 0], + ])("renders %s as text", async (_, value) => { await new Builder<ViewConfig>({render: value}).build(); - expect(document.body.firstElementChild?.innerHTML).toBe(html); + expect(document.body.firstElementChild?.textContent).toBe(String(value)); + expect(document.body.firstElementChild?.children).toHaveLength(0); }); test("creates the container from the container option", async () => { diff --git a/src/entry/view/adapters/vanilla/Builder.ts b/src/entry/view/adapters/vanilla/Builder.ts index 456c5d66..1c0beeb2 100644 --- a/src/entry/view/adapters/vanilla/Builder.ts +++ b/src/entry/view/adapters/vanilla/Builder.ts @@ -1,5 +1,6 @@ +import {isDomRenderValue, renderDomValue} from "@entry/core/render"; + import ViewBuilder from "../../Builder"; -import {isValidRenderValue} from "./utils"; import {ViewConfig, ViewDefinition, ViewRenderHandler, ViewRenderValue} from "@typing/view"; @@ -16,15 +17,13 @@ export default class Builder<T extends ViewConfig> extends ViewBuilder<T> { return async props => { const value = typeof render === "function" ? await render(props) : render; - return isValidRenderValue(value) ? value : undefined; + return isDomRenderValue(value) ? value : undefined; }; } protected mount(container: Element, value: ViewRenderValue<T>): void { - if (value instanceof Element) { - container.appendChild(value); - } else if (typeof value === "string" || typeof value === "number") { - container.innerHTML = value.toString(); + if (isDomRenderValue(value)) { + renderDomValue(container, value); } } } diff --git a/src/entry/view/adapters/vanilla/definition.ts b/src/entry/view/adapters/vanilla/definition.ts index 71355f4b..a3b679a0 100644 --- a/src/entry/view/adapters/vanilla/definition.ts +++ b/src/entry/view/adapters/vanilla/definition.ts @@ -1,8 +1,9 @@ +import {isDomRenderValue} from "@entry/core/render"; + import {mergeDefinition} from "../../resolvers/definition"; -import {isValidRenderValue} from "./utils"; import type {ViewConfig, ViewDefinition} from "@typing/view"; export const resolveDefinition = (module: object): ViewDefinition<ViewConfig> => { - return mergeDefinition(module, value => typeof value === "function" || isValidRenderValue(value)); + return mergeDefinition(module, value => typeof value === "function" || isDomRenderValue(value)); }; diff --git a/src/entry/view/adapters/vanilla/utils.ts b/src/entry/view/adapters/vanilla/utils.ts deleted file mode 100644 index 022f9add..00000000 --- a/src/entry/view/adapters/vanilla/utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const isValidRenderValue = (value: unknown): value is string | number | Element => { - return (typeof value === "string" && value.length > 0) || typeof value === "number" || value instanceof Element; -}; From 012df1349be2eb037f8bfd64efb6fb144c113f73 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:08:30 +0300 Subject: [PATCH 20/28] feat(offscreen)!: render offscreen views with the injected view builder Resolve offscreen exports in the runtime through resolveDefinition() over the shared transport merge, and let the offscreen Builder compose its transport with a view builder passed by the generated module, as Relay does with its content builder. Offscreen definitions adopt the new ViewRenderDefinition mixin, so render and container work with the adapter selected by the file extension, while init and main still receive only the offscreen options. The generated module becomes a single offscreen(resolveDefinition(module, name), ViewBuilder) call. Cover the startup and the layer composition in index.test.ts, and check the template and the adapter-free offscreen bundle graph in virtual.test.ts. BREAKING CHANGE: The offscreen Builder now takes the view builder class as its second constructor argument and no longer has a view() method. --- src/cli/virtual/offscreen.ts | 31 +----------- src/cli/virtual/virtual.test.ts | 19 ++++--- src/entry/offscreen/Builder.ts | 32 +++++++----- src/entry/offscreen/TransportBuilder.ts | 10 +++- src/entry/offscreen/index.test.ts | 56 +++++++++++++++++++++ src/entry/offscreen/index.ts | 14 +++++- src/entry/offscreen/resolvers/definition.ts | 12 +++++ src/types/offscreen.ts | 7 ++- src/types/view.ts | 8 ++- 9 files changed, 133 insertions(+), 56 deletions(-) create mode 100644 src/entry/offscreen/index.test.ts create mode 100644 src/entry/offscreen/resolvers/definition.ts diff --git a/src/cli/virtual/offscreen.ts b/src/cli/virtual/offscreen.ts index 6898930a..30bc3ade 100644 --- a/src/cli/virtual/offscreen.ts +++ b/src/cli/virtual/offscreen.ts @@ -1,38 +1,11 @@ -import type {OffscreenUnresolvedDefinition} from "adnbn"; -import type {TransportType} from "adnbn/transport"; -import {isValidTransportDefinition, isValidTransportInitFunction} from "adnbn/entry/transport"; -import {Builder as OffscreenBuilder} from "adnbn/entry/offscreen"; +import offscreen, {resolveDefinition} from "adnbn/entry/offscreen"; import {Builder as ViewBuilder} from "virtual:view-builder"; import * as module from "virtual:offscreen-entrypoint"; try { - const offscreenName = "virtual:offscreen-name"; - - const {default: defaultDefinition, ...otherDefinition} = module; - - let definition: OffscreenUnresolvedDefinition<TransportType> = otherDefinition; - - if (isValidTransportDefinition(defaultDefinition)) { - definition = {...definition, ...defaultDefinition}; - } else if (isValidTransportInitFunction(defaultDefinition)) { - definition = {...definition, init: defaultDefinition}; - } - - const {init, main, name, ...options} = definition; - - new OffscreenBuilder({ - name: offscreenName, - init, - main, - ...options, - }) - .view(new ViewBuilder(options)) - .build() - .catch(e => { - console.error("Failed to build offscreen: ", e); - }); + offscreen(resolveDefinition(module, "virtual:offscreen-name"), ViewBuilder); } catch (e) { console.error("The offscreen crashed on startup:", e); } diff --git a/src/cli/virtual/virtual.test.ts b/src/cli/virtual/virtual.test.ts index 8de98da8..23600e77 100644 --- a/src/cli/virtual/virtual.test.ts +++ b/src/cli/virtual/virtual.test.ts @@ -28,14 +28,7 @@ describe("Built virtual modules", () => { }, { generator: "virtualOffscreenModule", - imports: [ - "adnbn", - "adnbn/transport", - "adnbn/entry/transport", - "adnbn/entry/offscreen", - "adnbn/entry/view/{framework}", - "{entry}", - ], + imports: ["adnbn/entry/offscreen", "adnbn/entry/view/{framework}", "{entry}"], }, { generator: "virtualOffscreenBackgroundModule", @@ -153,6 +146,7 @@ describe("Built virtual modules", () => { "adnbn/entry/content/react", "adnbn/entry/content", "adnbn/entry/relay", + "adnbn/entry/offscreen", ])("%s includes only its own framework dependencies in the bundle graph", async entrypoint => { const inputs = await bundleInputs(entrypoint); const usesReact = entrypoint.endsWith("/react"); @@ -163,6 +157,9 @@ describe("Built virtual modules", () => { if (entrypoint === "adnbn/entry/relay") { expect(inputs.some(filename => filename.includes("entry/content/"))).toBe(false); } + if (entrypoint === "adnbn/entry/offscreen") { + expect(inputs.some(filename => filename.includes("entry/view/"))).toBe(false); + } expect(inputs.some(filename => /entry\/content\/adapters\/vanilla\/(Builder|Node)\.js$/.test(filename))).toBe( entrypoint.endsWith("/vanilla") ); @@ -203,6 +200,12 @@ describe("Built virtual modules", () => { startup: "contentScript", call: "contentScript(resolveDefinition(module))", }, + { + generator: "virtualOffscreenModule", + specifier: "adnbn/entry/offscreen", + startup: "offscreen", + call: 'offscreen(resolveDefinition(module, "example"), ViewBuilder)', + }, { generator: "virtualRelayModule", specifier: "adnbn/entry/relay", diff --git a/src/entry/offscreen/Builder.ts b/src/entry/offscreen/Builder.ts index 6425631e..ca792a09 100644 --- a/src/entry/offscreen/Builder.ts +++ b/src/entry/offscreen/Builder.ts @@ -1,26 +1,32 @@ -import TransportBuilder from "./TransportBuilder"; +import EntrypointBuilder from "@entry/core/Builder"; -import Builder from "../core/Builder"; +import TransportBuilder from "./TransportBuilder"; -import {OffscreenBridgeReadyMessageType, OffscreenGlobalAccess, OffscreenUnresolvedDefinition} from "@typing/offscreen"; +import { + OffscreenBridgeReadyMessageType, + OffscreenEntrypointOptions, + OffscreenGlobalAccess, + OffscreenUnresolvedDefinition, +} from "@typing/offscreen"; import {TransportType} from "@typing/transport"; -import {ViewBuilder} from "@typing/view"; +import {ViewBuilder, ViewBuilderConstructor} from "@typing/view"; -export default class<T extends TransportType = TransportType> extends Builder { +export default class Builder<T extends TransportType = TransportType> extends EntrypointBuilder { protected readonly _transport: TransportBuilder<T>; - protected _view?: ViewBuilder; + protected readonly _view: ViewBuilder; - constructor(definition: OffscreenUnresolvedDefinition<T>) { + constructor( + definition: OffscreenUnresolvedDefinition<T>, + viewBuilder: ViewBuilderConstructor<OffscreenEntrypointOptions> + ) { super(); this._transport = new TransportBuilder(definition); - } - public view(view: ViewBuilder): this { - this._view = view; + const {init, main, name, ...viewOptions} = definition; - return this; + this._view = new viewBuilder(viewOptions); } public async build(): Promise<void> { @@ -29,14 +35,14 @@ export default class<T extends TransportType = TransportType> extends Builder { globalThis[OffscreenGlobalAccess] = true; await this._transport.build(); - await this._view?.build(); + await this._view.build(); this.ready(); } public async destroy(): Promise<void> { await this._transport.destroy(); - await this._view?.destroy(); + await this._view.destroy(); } private ready(): void { diff --git a/src/entry/offscreen/TransportBuilder.ts b/src/entry/offscreen/TransportBuilder.ts index ec4fc68e..6f10c76e 100644 --- a/src/entry/offscreen/TransportBuilder.ts +++ b/src/entry/offscreen/TransportBuilder.ts @@ -5,9 +5,15 @@ import {RegisterOffscreen} from "@offscreen/providers"; import {OffscreenOptions, OffscreenUnresolvedDefinition} from "@typing/offscreen"; import {TransportName, TransportType} from "@typing/transport"; -export default class<T extends TransportType = TransportType> extends AbstractBuilder<OffscreenOptions, T> { +export default class TransportBuilder<T extends TransportType = TransportType> extends AbstractBuilder< + OffscreenOptions, + T +> { constructor(definition: OffscreenUnresolvedDefinition<T>) { - super(definition); + // The view owns rendering; init and main receive only the offscreen options. + const {render, container, ...options} = definition; + + super(options); } protected transport(): RegisterOffscreen<TransportName, T, [OffscreenOptions]> { diff --git a/src/entry/offscreen/index.test.ts b/src/entry/offscreen/index.test.ts new file mode 100644 index 00000000..09217b23 --- /dev/null +++ b/src/entry/offscreen/index.test.ts @@ -0,0 +1,56 @@ +import {waitFor} from "@testing-library/react"; + +import offscreen, {Builder, resolveDefinition} from "./index"; +import VanillaBuilder from "../view/adapters/vanilla/Builder"; +import OffscreenManager from "@offscreen/OffscreenManager"; + +import {OffscreenGlobalAccess} from "@typing/offscreen"; + +describe("Offscreen startup", () => { + const manager = OffscreenManager.getInstance(); + + afterEach(() => { + manager.clear(); + document.body.replaceChildren(); + document.title = ""; + delete globalThis[OffscreenGlobalAccess]; + }); + + test("starts a normalized module with the selected view builder", async () => { + const instance = {ready: true}; + const init = jest.fn(() => instance); + const main = jest.fn(); + + const definition = resolveDefinition( + {default: init, name: "source-name", title: "Worker", render: () => "Offscreen text", main}, + "build-name" + ); + + expect(offscreen(definition, VanillaBuilder)).toBeUndefined(); + + await waitFor(() => expect(document.body.textContent).toBe("Offscreen text")); + + expect(document.title).toBe("Worker"); + expect(manager.get("build-name")).toBe(instance); + expect(manager.has("source-name")).toBe(false); + expect(main).toHaveBeenCalledWith(instance, expect.objectContaining({name: "build-name", title: "Worker"})); + }); + + test("keeps view options out of the transport and destroys both layers", async () => { + const init = jest.fn(() => ({})); + const builder = new Builder( + resolveDefinition({default: {init, render: "Offscreen text", reasons: ["DOM_PARSER"]}}, "worker"), + VanillaBuilder + ); + + await builder.build(); + + expect(init).toHaveBeenCalledWith({name: "worker", reasons: ["DOM_PARSER"]}); + expect(document.body.textContent).toBe("Offscreen text"); + + await builder.destroy(); + + expect(manager.has("worker")).toBe(false); + expect(document.body.children).toHaveLength(0); + }); +}); diff --git a/src/entry/offscreen/index.ts b/src/entry/offscreen/index.ts index 230e7fd9..1e7609a6 100644 --- a/src/entry/offscreen/index.ts +++ b/src/entry/offscreen/index.ts @@ -1,5 +1,17 @@ import Builder from "./Builder"; +import type {OffscreenEntrypointOptions, OffscreenUnresolvedDefinition} from "@typing/offscreen"; +import type {TransportType} from "@typing/transport"; +import type {ViewBuilderConstructor} from "@typing/view"; + export {Builder}; +export {resolveDefinition} from "./resolvers/definition"; -export default Builder.resolver(); +export default function offscreen<T extends TransportType>( + definition: OffscreenUnresolvedDefinition<T>, + viewBuilder: ViewBuilderConstructor<OffscreenEntrypointOptions> +): void { + new Builder(definition, viewBuilder).build().catch(error => { + console.error("Failed to build offscreen: ", error); + }); +} diff --git a/src/entry/offscreen/resolvers/definition.ts b/src/entry/offscreen/resolvers/definition.ts new file mode 100644 index 00000000..772aba48 --- /dev/null +++ b/src/entry/offscreen/resolvers/definition.ts @@ -0,0 +1,12 @@ +import {mergeDefinition} from "@entry/transport/resolvers/definition"; + +import type {OffscreenUnresolvedDefinition} from "@typing/offscreen"; +import type {TransportType} from "@typing/transport"; + +/** A default function is the offscreen transport init; the view renders only an explicit render. */ +export const resolveDefinition = <T extends TransportType = TransportType>( + module: object, + name: string +): OffscreenUnresolvedDefinition<T> => { + return mergeDefinition(module, name) as OffscreenUnresolvedDefinition<T>; +}; diff --git a/src/types/offscreen.ts b/src/types/offscreen.ts index cb14b127..50e6eac1 100644 --- a/src/types/offscreen.ts +++ b/src/types/offscreen.ts @@ -5,7 +5,7 @@ import { TransportTarget, TransportType, } from "@typing/transport"; -import {ViewOptions} from "@typing/view"; +import {ViewOptions, ViewRenderDefinition} from "@typing/view"; import {Awaiter} from "@typing/helpers"; import {CspOptions} from "@typing/csp"; @@ -83,7 +83,10 @@ export type OffscreenMainHandler<T extends TransportType> = ( ) => Awaiter<void>; export interface OffscreenDefinition<T extends TransportType> - extends TransportDefinition<OffscreenOptions, T>, OffscreenEntrypointOptions { + extends + TransportDefinition<OffscreenOptions, T>, + OffscreenEntrypointOptions, + ViewRenderDefinition<OffscreenEntrypointOptions> { main?: OffscreenMainHandler<T>; } diff --git a/src/types/view.ts b/src/types/view.ts index 3f82cf46..8950ffca 100644 --- a/src/types/view.ts +++ b/src/types/view.ts @@ -36,11 +36,14 @@ export type ViewContainerFactory<T extends ViewConfig> = ( export type ViewContainerCreator<T extends ViewConfig> = (props: T) => Awaiter<Element>; // Definition -export interface ViewDefinition<T extends ViewConfig> extends ViewOptions { +/** UI of a view document; entrypoints that also render a view (offscreen, sandbox) adopt it as a mixin. */ +export interface ViewRenderDefinition<T extends ViewConfig> { render?: ViewRenderValue<T> | ViewRenderHandler<T>; container?: ViewContainerTag | ViewContainerOptions | ViewContainerFactory<T>; } +export interface ViewDefinition<T extends ViewConfig> extends ViewOptions, ViewRenderDefinition<T> {} + export interface ViewResolvedDefinition<T extends ViewConfig> extends ViewDefinition<T> { render?: ViewRenderHandler<T>; container: ViewContainerCreator<T>; @@ -48,3 +51,6 @@ export interface ViewResolvedDefinition<T extends ViewConfig> extends ViewDefini // Builder export type ViewBuilder = EntrypointBuilder; + +/** A renderer adapter's builder class, selected by the entrypoint filename and injected by the build. */ +export type ViewBuilderConstructor<T extends ViewConfig> = new (definition: ViewDefinition<T>) => ViewBuilder; From 8d27fe17e93069edb4b98e7e8b592b9a4ef6bb25 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:15:16 +0300 Subject: [PATCH 21/28] feat(sandbox)!: render sandbox views with the injected view builder Resolve sandbox exports in the runtime through resolveDefinition() over the shared transport merge, and let the sandbox Builder compose its transport with the view builder passed by the generated module, as offscreen does. Sandbox definitions adopt ViewRenderDefinition, so render and container use the adapter selected by the file extension, while init and main receive only the sandbox options; adnbn/entry/sandbox exports its startup function instead of TransportBuilder. With no template left on the transport guards, remove the adnbn/entry/transport subpath, make the options-object check private to mergeDefinition, and drop the Relay and offscreen types from the transport resolvers. Cover the startup and the layer composition in index.test.ts, and check the template and the adapter-free sandbox bundle graph in virtual.test.ts. BREAKING CHANGE: The sandbox Builder now takes the view builder class as its second constructor argument and no longer has a view() method, adnbn/entry/sandbox no longer exports TransportBuilder, and the adnbn/entry/transport subpath is removed. --- src/cli/virtual/sandbox.ts | 31 +----------- src/cli/virtual/virtual.test.ts | 18 +++---- src/entry/sandbox/Builder.ts | 26 +++++----- src/entry/sandbox/TransportBuilder.ts | 10 +++- src/entry/sandbox/index.test.ts | 56 +++++++++++++++++++++ src/entry/sandbox/index.ts | 19 ++++++- src/entry/sandbox/resolvers/definition.ts | 12 +++++ src/entry/transport/index.ts | 6 --- src/entry/transport/resolvers/definition.ts | 15 +++--- src/types/sandbox.ts | 7 ++- 10 files changed, 129 insertions(+), 71 deletions(-) create mode 100644 src/entry/sandbox/index.test.ts create mode 100644 src/entry/sandbox/resolvers/definition.ts delete mode 100644 src/entry/transport/index.ts diff --git a/src/cli/virtual/sandbox.ts b/src/cli/virtual/sandbox.ts index bacad2fe..76f19ee0 100644 --- a/src/cli/virtual/sandbox.ts +++ b/src/cli/virtual/sandbox.ts @@ -1,38 +1,11 @@ -import type {SandboxUnresolvedDefinition} from "adnbn"; -import type {TransportType} from "adnbn/transport"; -import {isValidTransportDefinition, isValidTransportInitFunction} from "adnbn/entry/transport"; -import {Builder as SandboxBuilder} from "adnbn/entry/sandbox"; +import sandbox, {resolveDefinition} from "adnbn/entry/sandbox"; import {Builder as ViewBuilder} from "virtual:view-builder"; import * as module from "virtual:sandbox-entrypoint"; try { - const sandboxName = "virtual:sandbox-name"; - - const {default: defaultDefinition, ...otherDefinition} = module; - - let definition: SandboxUnresolvedDefinition<TransportType> = otherDefinition; - - if (isValidTransportDefinition(defaultDefinition)) { - definition = {...definition, ...defaultDefinition}; - } else if (isValidTransportInitFunction(defaultDefinition)) { - definition = {...definition, init: defaultDefinition}; - } - - const {init, main, name, ...options} = definition; - - new SandboxBuilder({ - name: sandboxName, - init, - main, - ...options, - }) - .view(new ViewBuilder(options)) - .build() - .catch(e => { - console.error("Failed to build sandbox: ", e); - }); + sandbox(resolveDefinition(module, "virtual:sandbox-name"), ViewBuilder); } catch (e) { console.error("The sandbox crashed on startup:", e); } diff --git a/src/cli/virtual/virtual.test.ts b/src/cli/virtual/virtual.test.ts index 23600e77..97627625 100644 --- a/src/cli/virtual/virtual.test.ts +++ b/src/cli/virtual/virtual.test.ts @@ -40,14 +40,7 @@ describe("Built virtual modules", () => { }, { generator: "virtualSandboxModule", - imports: [ - "adnbn", - "adnbn/transport", - "adnbn/entry/transport", - "adnbn/entry/sandbox", - "adnbn/entry/view/{framework}", - "{entry}", - ], + imports: ["adnbn/entry/sandbox", "adnbn/entry/view/{framework}", "{entry}"], }, { generator: "virtualViewModule", @@ -147,6 +140,7 @@ describe("Built virtual modules", () => { "adnbn/entry/content", "adnbn/entry/relay", "adnbn/entry/offscreen", + "adnbn/entry/sandbox", ])("%s includes only its own framework dependencies in the bundle graph", async entrypoint => { const inputs = await bundleInputs(entrypoint); const usesReact = entrypoint.endsWith("/react"); @@ -157,7 +151,7 @@ describe("Built virtual modules", () => { if (entrypoint === "adnbn/entry/relay") { expect(inputs.some(filename => filename.includes("entry/content/"))).toBe(false); } - if (entrypoint === "adnbn/entry/offscreen") { + if (entrypoint === "adnbn/entry/offscreen" || entrypoint === "adnbn/entry/sandbox") { expect(inputs.some(filename => filename.includes("entry/view/"))).toBe(false); } expect(inputs.some(filename => /entry\/content\/adapters\/vanilla\/(Builder|Node)\.js$/.test(filename))).toBe( @@ -212,6 +206,12 @@ describe("Built virtual modules", () => { startup: "relay", call: 'relay(resolveDefinition(module, "example"), ContentBuilder)', }, + { + generator: "virtualSandboxModule", + specifier: "adnbn/entry/sandbox", + startup: "sandbox", + call: 'sandbox(resolveDefinition(module, "example"), ViewBuilder)', + }, { generator: "virtualServiceModule", specifier: "adnbn/entry/service", diff --git a/src/entry/sandbox/Builder.ts b/src/entry/sandbox/Builder.ts index 2932d222..9cdf38ae 100644 --- a/src/entry/sandbox/Builder.ts +++ b/src/entry/sandbox/Builder.ts @@ -1,36 +1,38 @@ -import TransportBuilder from "./TransportBuilder"; +import EntrypointBuilder from "@entry/core/Builder"; -import Builder from "../core/Builder"; +import TransportBuilder from "./TransportBuilder"; import {sandboxChannel} from "@sandbox/utils"; import { + SandboxEntrypointOptions, SandboxGlobalAccess, SandboxReadyMessage, SandboxReadyMessageType, SandboxUnresolvedDefinition, } from "@typing/sandbox"; import {TransportType} from "@typing/transport"; -import {ViewBuilder} from "@typing/view"; +import {ViewBuilder, ViewBuilderConstructor} from "@typing/view"; -export default class<T extends TransportType = TransportType> extends Builder { +export default class Builder<T extends TransportType = TransportType> extends EntrypointBuilder { protected readonly _transport: TransportBuilder<T>; - protected _view?: ViewBuilder; + protected readonly _view: ViewBuilder; private readonly name: string; - constructor(definition: SandboxUnresolvedDefinition<T>) { + constructor( + definition: SandboxUnresolvedDefinition<T>, + viewBuilder: ViewBuilderConstructor<SandboxEntrypointOptions> + ) { super(); this.name = definition.name!; this._transport = new TransportBuilder(definition); - } - public view(view: ViewBuilder): this { - this._view = view; + const {init, main, name, ...viewOptions} = definition; - return this; + this._view = new viewBuilder(viewOptions); } public async build(): Promise<void> { @@ -39,14 +41,14 @@ export default class<T extends TransportType = TransportType> extends Builder { globalThis[SandboxGlobalAccess] = true; await this._transport.build(); - await this._view?.build(); + await this._view.build(); this.ready(); } public async destroy(): Promise<void> { await this._transport.destroy(); - await this._view?.destroy(); + await this._view.destroy(); } private ready(): void { diff --git a/src/entry/sandbox/TransportBuilder.ts b/src/entry/sandbox/TransportBuilder.ts index 36fd10ac..7b5dd4dd 100644 --- a/src/entry/sandbox/TransportBuilder.ts +++ b/src/entry/sandbox/TransportBuilder.ts @@ -5,9 +5,15 @@ import {RegisterSandbox} from "@sandbox/providers"; import type {SandboxOptions, SandboxUnresolvedDefinition} from "@typing/sandbox"; import type {TransportName, TransportType} from "@typing/transport"; -export default class<T extends TransportType = TransportType> extends AbstractBuilder<SandboxOptions, T> { +export default class TransportBuilder<T extends TransportType = TransportType> extends AbstractBuilder< + SandboxOptions, + T +> { constructor(definition: SandboxUnresolvedDefinition<T>) { - super(definition); + // The view owns rendering; init and main receive only the sandbox options. + const {render, container, ...options} = definition; + + super(options); } protected transport(): RegisterSandbox<TransportName, T, [SandboxOptions]> { diff --git a/src/entry/sandbox/index.test.ts b/src/entry/sandbox/index.test.ts new file mode 100644 index 00000000..345e2111 --- /dev/null +++ b/src/entry/sandbox/index.test.ts @@ -0,0 +1,56 @@ +import {waitFor} from "@testing-library/react"; + +import sandbox, {Builder, resolveDefinition} from "./index"; +import VanillaBuilder from "../view/adapters/vanilla/Builder"; +import SandboxManager from "@sandbox/SandboxManager"; + +import {SandboxGlobalAccess} from "@typing/sandbox"; + +describe("Sandbox startup", () => { + const manager = SandboxManager.getInstance(); + + afterEach(() => { + manager.clear(); + document.body.replaceChildren(); + document.title = ""; + delete globalThis[SandboxGlobalAccess]; + }); + + test("starts a normalized module with the selected view builder", async () => { + const instance = {ready: true}; + const init = jest.fn(() => instance); + const main = jest.fn(); + + const definition = resolveDefinition( + {default: init, name: "source-name", title: "Frame", render: () => "Sandbox text", main}, + "build-name" + ); + + expect(sandbox(definition, VanillaBuilder)).toBeUndefined(); + + await waitFor(() => expect(document.body.textContent).toBe("Sandbox text")); + + expect(document.title).toBe("Frame"); + expect(manager.get("build-name")).toBe(instance); + expect(manager.has("source-name")).toBe(false); + expect(main).toHaveBeenCalledWith(instance, expect.objectContaining({name: "build-name", title: "Frame"})); + }); + + test("keeps view options out of the transport and destroys both layers", async () => { + const init = jest.fn(() => ({})); + const builder = new Builder( + resolveDefinition({default: {init, render: "Sandbox text", readyTimeout: 500}}, "frame"), + VanillaBuilder + ); + + await builder.build(); + + expect(init).toHaveBeenCalledWith({name: "frame", readyTimeout: 500}); + expect(document.body.textContent).toBe("Sandbox text"); + + await builder.destroy(); + + expect(manager.has("frame")).toBe(false); + expect(document.body.children).toHaveLength(0); + }); +}); diff --git a/src/entry/sandbox/index.ts b/src/entry/sandbox/index.ts index 0a295e75..f6bed082 100644 --- a/src/entry/sandbox/index.ts +++ b/src/entry/sandbox/index.ts @@ -1,2 +1,17 @@ -export {default as Builder} from "./Builder"; -export {default as TransportBuilder} from "./TransportBuilder"; +import Builder from "./Builder"; + +import type {SandboxEntrypointOptions, SandboxUnresolvedDefinition} from "@typing/sandbox"; +import type {TransportType} from "@typing/transport"; +import type {ViewBuilderConstructor} from "@typing/view"; + +export {Builder}; +export {resolveDefinition} from "./resolvers/definition"; + +export default function sandbox<T extends TransportType>( + definition: SandboxUnresolvedDefinition<T>, + viewBuilder: ViewBuilderConstructor<SandboxEntrypointOptions> +): void { + new Builder(definition, viewBuilder).build().catch(error => { + console.error("Failed to build sandbox: ", error); + }); +} diff --git a/src/entry/sandbox/resolvers/definition.ts b/src/entry/sandbox/resolvers/definition.ts new file mode 100644 index 00000000..a4487bfd --- /dev/null +++ b/src/entry/sandbox/resolvers/definition.ts @@ -0,0 +1,12 @@ +import {mergeDefinition} from "@entry/transport/resolvers/definition"; + +import type {SandboxUnresolvedDefinition} from "@typing/sandbox"; +import type {TransportType} from "@typing/transport"; + +/** A default function is the sandbox transport init; the view renders only an explicit render. */ +export const resolveDefinition = <T extends TransportType = TransportType>( + module: object, + name: string +): SandboxUnresolvedDefinition<T> => { + return mergeDefinition(module, name) as SandboxUnresolvedDefinition<T>; +}; diff --git a/src/entry/transport/index.ts b/src/entry/transport/index.ts deleted file mode 100644 index 835eb8c9..00000000 --- a/src/entry/transport/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - isValidTransportDefinition, - isValidTransportInitFunction, - isValidTransportMainFunction, - isValidTransportName, -} from "./resolvers/definition"; diff --git a/src/entry/transport/resolvers/definition.ts b/src/entry/transport/resolvers/definition.ts index b0e9c7dc..a603f6d7 100644 --- a/src/entry/transport/resolvers/definition.ts +++ b/src/entry/transport/resolvers/definition.ts @@ -1,18 +1,15 @@ import { - TransportDefinition, TransportInitGetter, TransportMainHandler, TransportOptions, TransportType, TransportUnresolvedDefinition, } from "@typing/transport"; -import {RelayDefinition, RelayMainHandler} from "@typing/relay"; -import {OffscreenDefinition, OffscreenMainHandler} from "@typing/offscreen"; -export const isValidTransportDefinition = <O extends TransportOptions, T extends TransportType>( - definition: any -): definition is TransportDefinition<O, T> & RelayDefinition<T> & OffscreenDefinition<T> => { - return definition && typeof definition === "object" && definition.constructor === Object; +const isDefinitionObject = ( + value: unknown +): value is TransportUnresolvedDefinition<TransportOptions, TransportType> => { + return value !== null && typeof value === "object" && value.constructor === Object; }; export const isValidTransportInitFunction = <O extends TransportOptions, T extends TransportType>( @@ -23,7 +20,7 @@ export const isValidTransportInitFunction = <O extends TransportOptions, T exten export const isValidTransportMainFunction = <O extends TransportOptions, T extends TransportType>( main: any -): main is TransportMainHandler<O, T> & RelayMainHandler<T> & OffscreenMainHandler<T> => { +): main is TransportMainHandler<O, T> => { return main && typeof main === "function"; }; @@ -43,7 +40,7 @@ export const mergeDefinition = ( let definition = namedDefinition as TransportUnresolvedDefinition<TransportOptions, TransportType>; - if (isValidTransportDefinition(defaultDefinition)) { + if (isDefinitionObject(defaultDefinition)) { definition = {...definition, ...defaultDefinition}; } else if (isValidTransportInitFunction(defaultDefinition)) { definition = {...definition, init: defaultDefinition}; diff --git a/src/types/sandbox.ts b/src/types/sandbox.ts index 26a0089b..c97166dd 100644 --- a/src/types/sandbox.ts +++ b/src/types/sandbox.ts @@ -1,5 +1,5 @@ import {TransportConfig, TransportDefinition, TransportType} from "@typing/transport"; -import {ViewOptions} from "@typing/view"; +import {ViewOptions, ViewRenderDefinition} from "@typing/view"; import {Awaiter} from "@typing/helpers"; import {MessageError, MessageSender} from "@typing/message"; import type {RpcAsyncProxy} from "@typing/rpc"; @@ -84,7 +84,10 @@ export type SandboxMainHandler<T extends TransportType> = ( ) => Awaiter<void>; export interface SandboxDefinition<T extends TransportType> - extends TransportDefinition<SandboxOptions, T>, SandboxEntrypointOptions { + extends + TransportDefinition<SandboxOptions, T>, + SandboxEntrypointOptions, + ViewRenderDefinition<SandboxEntrypointOptions> { main?: SandboxMainHandler<T>; } From a3cfaee1e340d8d4978c2c890e969cdbb78a13c9 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:25:17 +0300 Subject: [PATCH 22/28] refactor(cli): declare generated entrypoint modules with one wildcard Replace the eight virtual:*-entrypoint declarations with a single wildcard declaration: every generated module passes the entrypoint namespace to its runtime resolveDefinition(), which accepts any object, so the per-entrypoint export lists no longer describe anything the templates use. Keep the content and view builder declarations, whose startup and Builder types the templates still rely on. Remove the relay fixture assertions that pinned the old declaration to RelayUnresolvedDefinition; the fixture still type-checks the relay template against the real builders. --- .../tests/fixtures/relay-types/virtual.ts | 11 +- src/cli/virtual/virtual.d.ts | 123 +----------------- 2 files changed, 4 insertions(+), 130 deletions(-) diff --git a/src/cli/plugins/content/tests/fixtures/relay-types/virtual.ts b/src/cli/plugins/content/tests/fixtures/relay-types/virtual.ts index 2c90c7e4..6cf285b5 100644 --- a/src/cli/plugins/content/tests/fixtures/relay-types/virtual.ts +++ b/src/cli/plugins/content/tests/fixtures/relay-types/virtual.ts @@ -1,4 +1,4 @@ -import {RelayAllFrames, type ContentScriptDefinition, type RelayDefinition} from "adnbn"; +import {RelayAllFrames, type ContentScriptDefinition} from "adnbn"; import relay, {Builder as RelayBuilder, resolveDefinition, type RelayUnresolvedDefinition} from "adnbn/entry/relay"; import type {TransportType} from "adnbn/transport"; import {Builder as ContentScriptBuilder} from "virtual:content-builder"; @@ -11,15 +11,6 @@ type ContentOptions = Expect<Equal<ConstructorParameters<typeof ContentScriptBui type RelayOptions = Expect< Equal<ConstructorParameters<typeof RelayBuilder>[0], RelayUnresolvedDefinition<TransportType>> >; -type NamedExports = Expect< - Equal<Partial<Omit<typeof definition, "default">>, Readonly<RelayUnresolvedDefinition<TransportType>>> ->; -type DefaultExport = Expect< - Equal< - typeof definition.default, - RelayDefinition<TransportType> | RelayDefinition<TransportType>["init"] | undefined - > ->; new RelayBuilder({allFrames: RelayAllFrames.All}, ContentScriptBuilder); new ContentScriptBuilder({allFrames: true}); diff --git a/src/cli/virtual/virtual.d.ts b/src/cli/virtual/virtual.d.ts index 2337430a..105f3191 100644 --- a/src/cli/virtual/virtual.d.ts +++ b/src/cli/virtual/virtual.d.ts @@ -1,34 +1,6 @@ -declare module "virtual:background-entrypoint" { - type BackgroundDefinition = import("@typing/background").BackgroundDefinition; - - interface ModuleType extends BackgroundDefinition { - default: BackgroundDefinition | BackgroundDefinition["main"] | undefined; - } - - const module: ModuleType; - export = module; -} - -declare module "virtual:command-entrypoint" { - type CommandDefinition = import("@typing/command").CommandDefinition; - - interface ModuleType extends CommandDefinition { - default: CommandDefinition | CommandDefinition["execute"] | undefined; - } - - const module: ModuleType; - export = module; -} - -declare module "virtual:content-entrypoint" { - type ContentScriptDefinition = import("@typing/content").ContentScriptDefinition; - - interface ModuleType { - [name: string]: unknown; - default: ContentScriptDefinition | ContentScriptDefinition["render"] | undefined; - } - - const module: ModuleType; +/** Generated modules import the entrypoint as a namespace and pass it to their runtime resolveDefinition(). */ +declare module "virtual:*-entrypoint" { + const module: {readonly [name: string]: unknown}; export = module; } @@ -47,84 +19,6 @@ declare module "virtual:content-builder" { export default content; } -declare module "virtual:offscreen-entrypoint" { - type OffscreenDefinition = import("@typing/offscreen").OffscreenDefinition< - import("@typing/transport").TransportType - >; - - interface ModuleType extends OffscreenDefinition { - default: OffscreenDefinition | OffscreenDefinition["init"] | undefined; - } - - const module: ModuleType; - export = module; -} - -declare module "virtual:relay-entrypoint" { - type RelayDefinition = import("@typing/relay").RelayDefinition<import("@typing/transport").TransportType>; - - export const { - init, - main, - name, - method, - isolation, - allFrames, - matches, - excludeMatches, - includeGlobs, - excludeGlobs, - runAt, - world, - matchAboutBlank, - matchOriginAsFallback, - declarative, - marker, - anchor, - mount, - prepare, - render, - container, - boundary, - target, - watch, - includeBrowser, - excludeBrowser, - includeApp, - excludeApp, - mode, - manifestVersion, - debug, - }: Partial<RelayDefinition>; - - const definition: RelayDefinition | RelayDefinition["init"] | undefined; - export default definition; -} - -declare module "virtual:sandbox-entrypoint" { - type SandboxDefinition = import("@typing/sandbox").SandboxDefinition<any>; - - interface ModuleType extends SandboxDefinition { - default: SandboxDefinition | SandboxDefinition["init"] | undefined; - } - - const module: ModuleType; - export = module; -} - -declare module "virtual:view-entrypoint" { - import {ViewOptions} from "@typing/view"; - - type ViewDefinition = import("@typing/view").ViewDefinition<ViewOptions>; - - interface ModuleType extends ViewDefinition { - default: ViewDefinition | ViewDefinition["render"] | undefined; - } - - const module: ModuleType; - export = module; -} - declare module "virtual:view-builder" { export const resolveDefinition: ( module: object @@ -139,14 +33,3 @@ declare module "virtual:view-builder" { | typeof import("@entry/view/adapters/react").default; export default view; } - -declare module "virtual:service-entrypoint" { - type ServiceDefinition = import("@typing/service").ServiceDefinition<import("@typing/transport").TransportType>; - - interface ModuleType extends ServiceDefinition { - default: ServiceDefinition | ServiceDefinition["init"] | undefined; - } - - const module: ModuleType; - export = module; -} From 6aa1a53ce05c6f4f2d22234f8f424dbe1f626640 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:25:18 +0300 Subject: [PATCH 23/28] test: resolve package imports in fixture projects Extend the root TypeScript configuration from the empty fixture projects of the override, view and finder tests, as the command finder fixtures already do, so editors resolve "adnbn" imports in them. Override include and exclude, because the root configuration excludes tests/fixtures. Enable JSX for the expression default-export fixture, whose jsx.tsx did not type-check. The parsers read the same configurations, and the CLI suites pass unchanged. --- .../tests/fixtures/expression/default-exports/tsconfig.json | 3 ++- .../finder/tests/fixtures/override/workspace/tsconfig.json | 6 +++++- .../finder/tests/fixtures/selection/tsconfig.json | 6 +++++- .../plugins/override/tests/fixtures/bookmarks/tsconfig.json | 6 +++++- .../plugins/override/tests/fixtures/conflict/tsconfig.json | 6 +++++- .../plugins/override/tests/fixtures/history/tsconfig.json | 6 +++++- .../plugins/override/tests/fixtures/newtab/tsconfig.json | 6 +++++- src/cli/plugins/override/tests/fixtures/split/tsconfig.json | 6 +++++- .../plugins/view/tests/fixtures/html-options/tsconfig.json | 6 +++++- .../view/tests/fixtures/no-html-options/tsconfig.json | 6 +++++- 10 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/tsconfig.json b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/tsconfig.json index 983ab059..e8680852 100644 --- a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/tsconfig.json +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/tsconfig.json @@ -3,6 +3,7 @@ "baseUrl": ".", "paths": {"@export/*": ["./*"]}, "module": "ESNext", - "moduleResolution": "Bundler" + "moduleResolution": "Bundler", + "jsx": "react" } } diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/tsconfig.json b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/tsconfig.json index 0967ef42..57275076 100644 --- a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/tsconfig.json +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/tsconfig.json @@ -1 +1,5 @@ -{} +{ + "extends": "../../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} diff --git a/src/cli/entrypoint/finder/tests/fixtures/selection/tsconfig.json b/src/cli/entrypoint/finder/tests/fixtures/selection/tsconfig.json index 0967ef42..a135b733 100644 --- a/src/cli/entrypoint/finder/tests/fixtures/selection/tsconfig.json +++ b/src/cli/entrypoint/finder/tests/fixtures/selection/tsconfig.json @@ -1 +1,5 @@ -{} +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} diff --git a/src/cli/plugins/override/tests/fixtures/bookmarks/tsconfig.json b/src/cli/plugins/override/tests/fixtures/bookmarks/tsconfig.json index 0967ef42..a135b733 100644 --- a/src/cli/plugins/override/tests/fixtures/bookmarks/tsconfig.json +++ b/src/cli/plugins/override/tests/fixtures/bookmarks/tsconfig.json @@ -1 +1,5 @@ -{} +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} diff --git a/src/cli/plugins/override/tests/fixtures/conflict/tsconfig.json b/src/cli/plugins/override/tests/fixtures/conflict/tsconfig.json index 0967ef42..a135b733 100644 --- a/src/cli/plugins/override/tests/fixtures/conflict/tsconfig.json +++ b/src/cli/plugins/override/tests/fixtures/conflict/tsconfig.json @@ -1 +1,5 @@ -{} +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} diff --git a/src/cli/plugins/override/tests/fixtures/history/tsconfig.json b/src/cli/plugins/override/tests/fixtures/history/tsconfig.json index 0967ef42..a135b733 100644 --- a/src/cli/plugins/override/tests/fixtures/history/tsconfig.json +++ b/src/cli/plugins/override/tests/fixtures/history/tsconfig.json @@ -1 +1,5 @@ -{} +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} diff --git a/src/cli/plugins/override/tests/fixtures/newtab/tsconfig.json b/src/cli/plugins/override/tests/fixtures/newtab/tsconfig.json index 0967ef42..a135b733 100644 --- a/src/cli/plugins/override/tests/fixtures/newtab/tsconfig.json +++ b/src/cli/plugins/override/tests/fixtures/newtab/tsconfig.json @@ -1 +1,5 @@ -{} +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} diff --git a/src/cli/plugins/override/tests/fixtures/split/tsconfig.json b/src/cli/plugins/override/tests/fixtures/split/tsconfig.json index 0967ef42..a135b733 100644 --- a/src/cli/plugins/override/tests/fixtures/split/tsconfig.json +++ b/src/cli/plugins/override/tests/fixtures/split/tsconfig.json @@ -1 +1,5 @@ -{} +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} diff --git a/src/cli/plugins/view/tests/fixtures/html-options/tsconfig.json b/src/cli/plugins/view/tests/fixtures/html-options/tsconfig.json index 0967ef42..a135b733 100644 --- a/src/cli/plugins/view/tests/fixtures/html-options/tsconfig.json +++ b/src/cli/plugins/view/tests/fixtures/html-options/tsconfig.json @@ -1 +1,5 @@ -{} +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} diff --git a/src/cli/plugins/view/tests/fixtures/no-html-options/tsconfig.json b/src/cli/plugins/view/tests/fixtures/no-html-options/tsconfig.json index 0967ef42..a135b733 100644 --- a/src/cli/plugins/view/tests/fixtures/no-html-options/tsconfig.json +++ b/src/cli/plugins/view/tests/fixtures/no-html-options/tsconfig.json @@ -1 +1,5 @@ -{} +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} From 2479b768dbe9ed0d4d4a2cbf48471ea9c4bf44e0 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:34:08 +0300 Subject: [PATCH 24/28] feat(view): organize view types by adapter and export the render contract Split @typing/view into common options and containers, per-adapter render values, the combined render contract, and the definitions, the same way the content types are organized. The Vanilla adapter owns DOM, text and empty values, the React adapter owns React nodes and components, and render.ts combines them, so a new adapter only adds its own file. Export the render contract types from adnbn next to ViewDefinition and ViewOptions. A Promise is no longer accepted as a render value, matching the runtime, which never rendered one; handlers can still await data. Cover the public types through source and package APIs in view.integration.test.ts and list the check in the integration README. --- src/main/view.ts | 10 ++- src/types/view.ts | 56 ---------------- src/types/view/adapters/index.ts | 2 + src/types/view/adapters/react.ts | 9 +++ src/types/view/adapters/vanilla.ts | 1 + src/types/view/common.ts | 33 ++++++++++ src/types/view/definition.ts | 26 ++++++++ src/types/view/index.ts | 4 ++ src/types/view/render.ts | 10 +++ tests/integration/README.md | 5 +- .../types/fixtures/view/definition.tsx | 65 +++++++++++++++++++ .../types/view.integration.test.ts | 48 ++++++++++++++ 12 files changed, 211 insertions(+), 58 deletions(-) delete mode 100644 src/types/view.ts create mode 100644 src/types/view/adapters/index.ts create mode 100644 src/types/view/adapters/react.ts create mode 100644 src/types/view/adapters/vanilla.ts create mode 100644 src/types/view/common.ts create mode 100644 src/types/view/definition.ts create mode 100644 src/types/view/index.ts create mode 100644 src/types/view/render.ts create mode 100644 tests/integration/types/fixtures/view/definition.tsx create mode 100644 tests/integration/types/view.integration.test.ts diff --git a/src/main/view.ts b/src/main/view.ts index 68e76824..292ebea2 100644 --- a/src/main/view.ts +++ b/src/main/view.ts @@ -1 +1,9 @@ -export type {ViewDefinition, ViewOptions} from "@typing/view"; +export type { + ViewDefinition, + ViewOptions, + ViewReactRenderValue, + ViewRenderHandler, + ViewRenderReactComponent, + ViewRenderValue, + ViewVanillaRenderValue, +} from "@typing/view"; diff --git a/src/types/view.ts b/src/types/view.ts deleted file mode 100644 index 8950ffca..00000000 --- a/src/types/view.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type {FC, ReactNode} from "react"; -import type {Optional} from "utility-types"; -import type {Options as HtmlOptions} from "html-rspack-tags-plugin"; - -import {Awaiter, ExcludeFunctionsFromProperties, PickNonFunctionProperties} from "@typing/helpers"; -import {EntrypointBuilder, EntrypointOptions} from "@typing/entrypoint"; - -export interface ViewConfig { - as?: string; - title?: string; - template?: string; -} - -export type ViewOptions = ViewConfig & EntrypointOptions & ExcludeFunctionsFromProperties<HtmlOptions>; - -export type ViewEntrypointOptions = ViewOptions; - -// Render -export type ViewRenderReactComponent<T extends ViewConfig> = FC<T>; -export type ViewRenderValue<T extends ViewConfig> = string | Element | ReactNode | ViewRenderReactComponent<T>; -export type ViewRenderHandler<T extends ViewConfig> = (props: T) => Awaiter<void | ViewRenderValue<T>>; - -// Container -export type ViewContainerTag = Exclude<keyof HTMLElementTagNameMap, "html" | "body">; - -export type ViewContainerOptions = { - [Tag in ViewContainerTag]: {tagName: Tag} & Exclude< - Optional<PickNonFunctionProperties<HTMLElementTagNameMap[Tag]>>, - "id" - >; -}[ViewContainerTag]; - -export type ViewContainerFactory<T extends ViewConfig> = ( - props: T -) => Awaiter<Element | ViewContainerTag | ViewContainerOptions>; -export type ViewContainerCreator<T extends ViewConfig> = (props: T) => Awaiter<Element>; - -// Definition -/** UI of a view document; entrypoints that also render a view (offscreen, sandbox) adopt it as a mixin. */ -export interface ViewRenderDefinition<T extends ViewConfig> { - render?: ViewRenderValue<T> | ViewRenderHandler<T>; - container?: ViewContainerTag | ViewContainerOptions | ViewContainerFactory<T>; -} - -export interface ViewDefinition<T extends ViewConfig> extends ViewOptions, ViewRenderDefinition<T> {} - -export interface ViewResolvedDefinition<T extends ViewConfig> extends ViewDefinition<T> { - render?: ViewRenderHandler<T>; - container: ViewContainerCreator<T>; -} - -// Builder -export type ViewBuilder = EntrypointBuilder; - -/** A renderer adapter's builder class, selected by the entrypoint filename and injected by the build. */ -export type ViewBuilderConstructor<T extends ViewConfig> = new (definition: ViewDefinition<T>) => ViewBuilder; diff --git a/src/types/view/adapters/index.ts b/src/types/view/adapters/index.ts new file mode 100644 index 00000000..a0b8b4b4 --- /dev/null +++ b/src/types/view/adapters/index.ts @@ -0,0 +1,2 @@ +export type * from "./vanilla"; +export type * from "./react"; diff --git a/src/types/view/adapters/react.ts b/src/types/view/adapters/react.ts new file mode 100644 index 00000000..319f0d65 --- /dev/null +++ b/src/types/view/adapters/react.ts @@ -0,0 +1,9 @@ +import type {FC, ReactNode} from "react"; + +import type {ViewConfig} from "../common"; + +export type ViewRenderReactComponent<T extends ViewConfig> = FC<T>; + +export type ViewReactRenderValue<T extends ViewConfig> = + | Exclude<ReactNode, Promise<unknown>> + | ViewRenderReactComponent<T>; diff --git a/src/types/view/adapters/vanilla.ts b/src/types/view/adapters/vanilla.ts new file mode 100644 index 00000000..677fd1cd --- /dev/null +++ b/src/types/view/adapters/vanilla.ts @@ -0,0 +1 @@ +export type ViewVanillaRenderValue = Element | string | number | boolean | null | undefined; diff --git a/src/types/view/common.ts b/src/types/view/common.ts new file mode 100644 index 00000000..ebb104cf --- /dev/null +++ b/src/types/view/common.ts @@ -0,0 +1,33 @@ +import type {Optional} from "utility-types"; +import type {Options as HtmlOptions} from "html-rspack-tags-plugin"; + +import {Awaiter, ExcludeFunctionsFromProperties, PickNonFunctionProperties} from "@typing/helpers"; +import {EntrypointBuilder, EntrypointOptions} from "@typing/entrypoint"; + +export interface ViewConfig { + as?: string; + title?: string; + template?: string; +} + +export type ViewOptions = ViewConfig & EntrypointOptions & ExcludeFunctionsFromProperties<HtmlOptions>; + +export type ViewEntrypointOptions = ViewOptions; + +// Container +export type ViewContainerTag = Exclude<keyof HTMLElementTagNameMap, "html" | "body">; + +export type ViewContainerOptions = { + [Tag in ViewContainerTag]: {tagName: Tag} & Exclude< + Optional<PickNonFunctionProperties<HTMLElementTagNameMap[Tag]>>, + "id" + >; +}[ViewContainerTag]; + +export type ViewContainerFactory<T extends ViewConfig> = ( + props: T +) => Awaiter<Element | ViewContainerTag | ViewContainerOptions>; +export type ViewContainerCreator<T extends ViewConfig> = (props: T) => Awaiter<Element>; + +// Builder +export type ViewBuilder = EntrypointBuilder; diff --git a/src/types/view/definition.ts b/src/types/view/definition.ts new file mode 100644 index 00000000..14e8f2e9 --- /dev/null +++ b/src/types/view/definition.ts @@ -0,0 +1,26 @@ +import type { + ViewBuilder, + ViewConfig, + ViewContainerCreator, + ViewContainerFactory, + ViewContainerOptions, + ViewContainerTag, + ViewOptions, +} from "./common"; +import type {ViewRenderHandler, ViewRenderValue} from "./render"; + +/** UI of a view document; entrypoints that also render a view (offscreen, sandbox) adopt it as a mixin. */ +export interface ViewRenderDefinition<T extends ViewConfig> { + render?: ViewRenderValue<T> | ViewRenderHandler<T>; + container?: ViewContainerTag | ViewContainerOptions | ViewContainerFactory<T>; +} + +export interface ViewDefinition<T extends ViewConfig> extends ViewOptions, ViewRenderDefinition<T> {} + +export interface ViewResolvedDefinition<T extends ViewConfig> extends ViewDefinition<T> { + render?: ViewRenderHandler<T>; + container: ViewContainerCreator<T>; +} + +/** A renderer adapter's builder class, selected by the entrypoint filename and injected by the build. */ +export type ViewBuilderConstructor<T extends ViewConfig> = new (definition: ViewDefinition<T>) => ViewBuilder; diff --git a/src/types/view/index.ts b/src/types/view/index.ts new file mode 100644 index 00000000..441562d7 --- /dev/null +++ b/src/types/view/index.ts @@ -0,0 +1,4 @@ +export type * from "./common"; +export type * from "./definition"; +export type * from "./render"; +export type * from "./adapters"; diff --git a/src/types/view/render.ts b/src/types/view/render.ts new file mode 100644 index 00000000..2ac67b0e --- /dev/null +++ b/src/types/view/render.ts @@ -0,0 +1,10 @@ +import type {Awaiter} from "@typing/helpers"; + +import type {ViewConfig} from "./common"; +import type {ViewReactRenderValue, ViewVanillaRenderValue} from "./adapters"; + +/** All supported render values; the entrypoint's filename selects the runtime adapter. */ +export type ViewRenderValue<T extends ViewConfig> = ViewVanillaRenderValue | ViewReactRenderValue<T>; + +/** A view document renders once, so its handler may await data before returning the value. */ +export type ViewRenderHandler<T extends ViewConfig> = (props: T) => Awaiter<void | ViewRenderValue<T>>; diff --git a/tests/integration/README.md b/tests/integration/README.md index 557d6ded..dfd73484 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -64,9 +64,11 @@ tests/integration/ ├── types/ │ ├── content.integration.test.ts │ ├── registries.integration.test.ts +│ ├── view.integration.test.ts │ └── fixtures/ │ ├── content/ -│ └── registries/ +│ ├── registries/ +│ └── view/ ├── utils/ │ ├── fixture.ts │ ├── process.ts @@ -125,6 +127,7 @@ Tests copy application inputs to unique directories under `.cache/integration`. ## Coverage - `types/content`: shared Content and adapter render types through the public source and built package APIs, callback props inference, and iframe-navigation restrictions for both define functions. +- `types/view`: shared View and adapter render types through the public source and built package APIs, render and container props inference, the render contract adopted by Offscreen and Sandbox, and the rejection of Promise and plain-object render values. - `types/registries`: generated registry augmentation, empty fallbacks, public and internal type agreement, and message contracts against source and built package APIs. Compiler-host path checks cover both slash styles. - `build/options/embedded`: ten manifest checks covering explicit `openInTab: false` across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. No browser is launched. - `browser/options`: two Chrome MV3 cases covering Vanilla and React rendering, CSS, state/events, opening Options from background, and a View chunk shared with a Page. diff --git a/tests/integration/types/fixtures/view/definition.tsx b/tests/integration/types/fixtures/view/definition.tsx new file mode 100644 index 00000000..aecd4916 --- /dev/null +++ b/tests/integration/types/fixtures/view/definition.tsx @@ -0,0 +1,65 @@ +import {defineOffscreen, definePopup, defineSandbox} from "adnbn"; + +import type { + ViewDefinition, + ViewReactRenderValue, + ViewRenderHandler, + ViewRenderReactComponent, + ViewRenderValue, + ViewVanillaRenderValue, +} from "adnbn"; + +type Props = {title?: string}; + +const Panel: ViewRenderReactComponent<Props> = ({title}) => <span>{title}</span>; + +const vanillaValues: ViewVanillaRenderValue[] = [ + document.createElement("div"), + "text", + 0, + true, + false, + null, + undefined, +]; + +const reactValues: ViewReactRenderValue<Props>[] = [Panel, <Panel title="Popup" />, [<span />, "text"]]; + +const values: ViewRenderValue<Props>[] = [...vanillaValues, ...reactValues]; + +for (const render of values) { + const definition: ViewDefinition<Props> = {render}; + + definePopup(definition); +} + +const handler: ViewRenderHandler<Props> = async ({title}) => title ?? document.createElement("p"); + +definePopup({render: handler}); + +definePopup({ + title: "Popup", + + container: props => { + const title: string | undefined = props.title; + + return {tagName: "section", title: title ?? ""}; + }, + + render: async props => { + // @ts-expect-error: Render handlers receive the view options as props. + props.missing; + + return props.title ?? "untitled"; + }, +}); + +// Entrypoints that host a view adopt the same render contract. +defineOffscreen({init: () => ({}), render: () => "Offscreen"}); +defineSandbox({init: () => ({}), render: <span>Sandbox</span>}); + +// @ts-expect-error: A Promise is not a render value; await data inside a render handler instead. +definePopup({render: Promise.resolve("text")}); + +// @ts-expect-error: A plain object is neither a DOM nor a React render value. +definePopup({render: {text: "value"}}); diff --git a/tests/integration/types/view.integration.test.ts b/tests/integration/types/view.integration.test.ts new file mode 100644 index 00000000..3df8802a --- /dev/null +++ b/tests/integration/types/view.integration.test.ts @@ -0,0 +1,48 @@ +import path from "path"; +import ts from "typescript"; + +describe("view contracts", () => { + const projectDir = path.resolve(__dirname, "../../.."); + const fixture = path.join(__dirname, "fixtures/view/definition.tsx"); + + test.each(["source", "package"])( + "checks view definitions and adapter render values through the %s API", + mode => { + const config = ts.readConfigFile(path.join(projectDir, "tsconfig.json"), ts.sys.readFile); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, projectDir); + expect(config.error).toBeUndefined(); + expect(parsed.errors).toEqual([]); + + const options: ts.CompilerOptions = { + ...(mode === "source" ? parsed.options : {}), + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ESNext, + jsx: ts.JsxEmit.ReactJSX, + strict: true, + noEmit: true, + skipLibCheck: mode === "source", + types: ["node", "chrome"], + }; + + const host = ts.createCompilerHost(options); + const program = ts.createProgram([fixture], options, host); + + const diagnostics = ts.getPreEmitDiagnostics(program).map(diagnostic => { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); + const position = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0); + + return `${diagnostic.file?.fileName ?? "compiler"}:${(position?.line ?? 0) + 1}: ${message}`; + }); + + expect(diagnostics).toEqual([]); + + const apiFile = ts.resolveModuleName("adnbn", fixture, options, host).resolvedModule?.resolvedFileName; + + expect(apiFile).toBe( + path.join(projectDir, mode === "source" ? "src/index.ts" : "dist/index.d.ts").replace(/\\/g, "/") + ); + }, + 30_000 + ); +}); From 179209c78a5d91abb0b1e7b9eb1d4e66753523b7 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:36:06 +0300 Subject: [PATCH 25/28] chore(release): filter release notes and standardize sync commits --- .github/workflows/release.yml | 1 + .release-it.cjs | 13 ++++--------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48772d72..d77d553d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.release-it.cjs b/.release-it.cjs index c4f167a8..5bb16e89 100644 --- a/.release-it.cjs +++ b/.release-it.cjs @@ -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"], ]); @@ -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(); From 55f176c668046af53a88fccd051f5ddf4a63266c Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:43:20 +0300 Subject: [PATCH 26/28] test(view): render injected view builders in Chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Chrome MV3 integration case whose application renders React offscreen and sandbox views through the builder injected by file extension, reads their text and titles from the popup, and checks that a headless offscreen gets no view container. The same case renders a "<b>…</b>" string through the React view, Vanilla view and React content adapters and asserts that each shows the tags as text. Document the fixture beside it and in the integration README. --- tests/integration/README.md | 2 + tests/integration/browser/view/README.md | 17 ++ .../browser/view/render.integration.test.ts | 146 ++++++++++++++++++ .../browser/view/render/adnbn.config.ts | 8 + .../browser/view/render/package.json | 12 ++ .../browser/view/render/site/top.html | 3 + .../browser/view/render/src/background.ts | 1 + .../browser/view/render/src/frame.sandbox.tsx | 11 ++ .../browser/view/render/src/markup.page.ts | 4 + .../browser/view/render/src/note.content.tsx | 4 + .../view/render/src/plain.offscreen.ts | 8 + .../browser/view/render/src/popup.ts | 26 ++++ .../browser/view/render/src/text.page.tsx | 4 + .../view/render/src/worker.offscreen.tsx | 13 ++ .../browser/view/render/tsconfig.json | 6 + 15 files changed, 265 insertions(+) create mode 100644 tests/integration/browser/view/README.md create mode 100644 tests/integration/browser/view/render.integration.test.ts create mode 100644 tests/integration/browser/view/render/adnbn.config.ts create mode 100644 tests/integration/browser/view/render/package.json create mode 100644 tests/integration/browser/view/render/site/top.html create mode 100644 tests/integration/browser/view/render/src/background.ts create mode 100644 tests/integration/browser/view/render/src/frame.sandbox.tsx create mode 100644 tests/integration/browser/view/render/src/markup.page.ts create mode 100644 tests/integration/browser/view/render/src/note.content.tsx create mode 100644 tests/integration/browser/view/render/src/plain.offscreen.ts create mode 100644 tests/integration/browser/view/render/src/popup.ts create mode 100644 tests/integration/browser/view/render/src/text.page.tsx create mode 100644 tests/integration/browser/view/render/src/worker.offscreen.tsx create mode 100644 tests/integration/browser/view/render/tsconfig.json diff --git a/tests/integration/README.md b/tests/integration/README.md index dfd73484..9104ae0f 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -53,6 +53,7 @@ tests/integration/ │ ├── offscreen/ │ ├── options/ │ ├── override/ +│ ├── view/ │ └── utils/ │ ├── BidiClient.ts │ ├── CdpClient.ts @@ -130,6 +131,7 @@ Tests copy application inputs to unique directories under `.cache/integration`. - `types/view`: shared View and adapter render types through the public source and built package APIs, render and container props inference, the render contract adopted by Offscreen and Sandbox, and the rejection of Promise and plain-object render values. - `types/registries`: generated registry augmentation, empty fallbacks, public and internal type agreement, and message contracts against source and built package APIs. Compiler-host path checks cover both slash styles. - `build/options/embedded`: ten manifest checks covering explicit `openInTab: false` across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. No browser is launched. +- `browser/view`: one Chrome MV3 case covering React offscreen and sandbox views rendered by the injected builder with their props and titles, a headless offscreen without a view container, and strings rendered as text by the React view, Vanilla view and React content adapters. - `browser/options`: two Chrome MV3 cases covering Vanilla and React rendering, CSS, state/events, opening Options from background, and a View chunk shared with a Page. - `build/override`: thirty manifest checks covering the New Tab, Bookmarks, and History applications across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. Supporting browsers receive `chrome_url_overrides`, the page HTML, its CSP, and the permission declared by the entrypoint; the others receive none of them. Three more cases add the History entrypoint to a copy of the New Tab application: Chrome and Edge builds fail naming both entrypoints, while Firefox keeps the New Tab with its own permission and without the History one. No browser is launched. - `build/permissions/views`: four manifest checks of one application with two popups, two sidebars, and an options page that each declare permissions. Every built view contributes, including a popup and a sidebar that are not applied by default; Chrome MV2 has no sidebar, so its permissions are not requested; Firefox builds the sidebar action without `sidePanel`; MV2 declares hosts as permissions. No browser is launched. diff --git a/tests/integration/browser/view/README.md b/tests/integration/browser/view/README.md new file mode 100644 index 00000000..0edb9921 --- /dev/null +++ b/tests/integration/browser/view/README.md @@ -0,0 +1,17 @@ +# View browser fixtures + +See the [integration guide](../../README.md) for editor preparation, dependency setup, and test isolation. + +Run only the view rendering case from the repository root: + +```bash +npm run test -- tests/integration/browser/view/render.integration.test.ts --runInBand +``` + +The `render` application checks, in Chrome MV3, the view builder that the build injects by file extension: + +- `worker.offscreen.tsx` and `frame.sandbox.tsx` render React components with their `title` as props. The popup reads the rendered text and `document.title` through `getOffscreen` and `getSandbox`. +- `plain.offscreen.ts` has no render: its default function is the transport init, and its document gets no view container. +- `text.page.tsx`, `markup.page.ts` and `note.content.tsx` render the string `"<b>…</b>"` with the React view, Vanilla view and React content adapters. Each shows the tags as text; no `<b>` element is created. + +`site/top.html` is served locally for the content script. The case also checks that the manifest requests only the `offscreen` permission and that the attached pages report no runtime errors. diff --git a/tests/integration/browser/view/render.integration.test.ts b/tests/integration/browser/view/render.integration.test.ts new file mode 100644 index 00000000..d59889ca --- /dev/null +++ b/tests/integration/browser/view/render.integration.test.ts @@ -0,0 +1,146 @@ +import {mkdtemp, readFile, rm} from "fs/promises"; +import os from "os"; +import path from "path"; +import {spawn, type ChildProcess} from "child_process"; + +import {browserVersion, findChromeBinary} from "../utils/chrome"; +import CdpClient from "../utils/CdpClient"; +import {getFreePort, stop, waitFor} from "../utils/browser"; +import {startIntegrationSite, type IntegrationSite} from "../utils/site"; +import {createIntegrationFixture, type IntegrationFixture} from "../../utils/fixture"; + +const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); +const chromeBinary = findChromeBinary(rootDir); + +jest.setTimeout(90_000); + +test("Chrome MV3 renders offscreen, sandbox, page and content views through the injected builders", async () => { + if (!chromeBinary || !path.isAbsolute(chromeBinary)) { + throw new Error( + "Chrome is not installed or could not be found. Install Chrome or set ADNBN_CHROME_BIN to its absolute executable path." + ); + } + + const userDataDir = await mkdtemp(path.join(os.tmpdir(), "adnbn-view-render-")); + const debuggingPort = await getFreePort(); + let chrome: ChildProcess | undefined; + let browser: CdpClient | undefined; + let fixture: IntegrationFixture | undefined; + let site: IntegrationSite | undefined; + let chromeOutput = ""; + + try { + fixture = await createIntegrationFixture(rootDir, path.join(__dirname, "render")); + site = await startIntegrationSite(path.join(fixture.directory, "site"), null); + + const extensionDir = await fixture.build(); + const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); + + expect(manifest.manifest_version).toBe(3); + expect(manifest.permissions).toEqual(["offscreen"]); + + chrome = spawn( + chromeBinary, + [ + "--headless=new", + "--no-sandbox", + "--no-first-run", + "--no-default-browser-check", + `--remote-debugging-port=${debuggingPort}`, + `--user-data-dir=${userDataDir}`, + "about:blank", + ], + {stdio: ["ignore", "ignore", "pipe"]} + ); + chrome.stderr?.on("data", chunk => (chromeOutput += chunk)); + + const {webSocketDebuggerUrl} = await waitFor(() => browserVersion(debuggingPort)); + browser = await CdpClient.connect(webSocketDebuggerUrl); + + const extension = await browser.send("Extensions.loadUnpacked", {path: extensionDir}); + const extensionId = extension.id as string | undefined; + + if (!extensionId) { + throw new Error("Chrome did not return an extension ID after loading the view fixture"); + } + + const open = async (url: string) => { + const {targetId} = await browser!.send("Target.createTarget", {url}); + const {sessionId} = await browser!.send("Target.attachToTarget", {targetId, flatten: true}); + + await browser!.send("Runtime.enable", {}, sessionId); + + return async (expression: string): Promise<any> => { + const result = await browser!.send( + "Runtime.evaluate", + {expression, awaitPromise: true, returnByValue: true}, + sessionId + ); + + if (result.exceptionDetails) { + throw new Error(JSON.stringify(result.exceptionDetails)); + } + + return result.result.value; + }; + }; + + // A string render is text in every adapter: no <b> element may appear. + const readText = (evaluate: (expression: string) => Promise<any>) => { + return waitFor(() => + evaluate(`(() => { + const text = document.body.firstElementChild?.textContent; + + return text ? {title: document.title, text, markup: document.querySelector("b") !== null} : undefined; + })()`) + ); + }; + + const popup = await open(`chrome-extension://${extensionId}/popup.html`); + const result = JSON.parse( + await waitFor(() => popup("document.getElementById('result')?.value || undefined"), 30_000, "view calls") + ); + + expect(result).toEqual({ + offscreen: {title: "Worker", text: "React offscreen: Worker"}, + headless: {containers: 0}, + sandbox: {title: "Frame", text: "React sandbox: Frame"}, + }); + + await expect(readText(await open(`chrome-extension://${extensionId}/text.html`))).resolves.toEqual({ + title: "React text", + text: "<b>React text</b>", + markup: false, + }); + + await expect(readText(await open(`chrome-extension://${extensionId}/markup.html`))).resolves.toEqual({ + title: "Vanilla text", + text: "<b>Vanilla text</b>", + markup: false, + }); + + const page = await open(`${site.origin}/top.html`); + const note = await waitFor(() => + page(`(() => { + const note = [...document.body.children].find(element => element.textContent.includes("React content text")); + + return note ? {text: note.textContent, markup: document.querySelector("b") !== null} : undefined; + })()`) + ); + + expect(note).toEqual({text: "<b>React content text</b>", markup: false}); + expect(browser.runtimeErrors).toEqual([]); + } catch (error) { + throw new Error(`${String(error)}; Chrome output: ${chromeOutput}`, {cause: error}); + } finally { + await browser?.close(); + + if (chrome) { + await stop(chrome); + } + + await site?.close(); + await fixture?.dispose(); + await rm(userDataDir, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } +}); diff --git a/tests/integration/browser/view/render/adnbn.config.ts b/tests/integration/browser/view/render/adnbn.config.ts new file mode 100644 index 00000000..02365562 --- /dev/null +++ b/tests/integration/browser/view/render/adnbn.config.ts @@ -0,0 +1,8 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "View Render Integration", + description: "Chrome MV3 views rendered by the injected Vanilla and React view builders.", + version: "1.0.0", + jsFilename: "[name].js", +}); diff --git a/tests/integration/browser/view/render/package.json b/tests/integration/browser/view/render/package.json new file mode 100644 index 00000000..413ca04b --- /dev/null +++ b/tests/integration/browser/view/render/package.json @@ -0,0 +1,12 @@ +{ + "name": "adnbn-test-view-render", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../..", + "react": "*", + "react-dom": "*", + "scheduler": "*" + } +} diff --git a/tests/integration/browser/view/render/site/top.html b/tests/integration/browser/view/render/site/top.html new file mode 100644 index 00000000..ba686082 --- /dev/null +++ b/tests/integration/browser/view/render/site/top.html @@ -0,0 +1,3 @@ +<!doctype html> +<title>View render site +

Page

diff --git a/tests/integration/browser/view/render/src/background.ts b/tests/integration/browser/view/render/src/background.ts new file mode 100644 index 00000000..2d1ec238 --- /dev/null +++ b/tests/integration/browser/view/render/src/background.ts @@ -0,0 +1 @@ +export default () => {}; diff --git a/tests/integration/browser/view/render/src/frame.sandbox.tsx b/tests/integration/browser/view/render/src/frame.sandbox.tsx new file mode 100644 index 00000000..6cb53281 --- /dev/null +++ b/tests/integration/browser/view/render/src/frame.sandbox.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import {defineSandbox} from "adnbn"; + +export default defineSandbox({ + name: "frame", + title: "Frame", + render: ({title}) =>

React sandbox: {title}

, + init: () => ({ + view: () => ({title: document.title, text: document.getElementById("view")?.textContent ?? null}), + }), +}); diff --git a/tests/integration/browser/view/render/src/markup.page.ts b/tests/integration/browser/view/render/src/markup.page.ts new file mode 100644 index 00000000..0d434d55 --- /dev/null +++ b/tests/integration/browser/view/render/src/markup.page.ts @@ -0,0 +1,4 @@ +export const title = "Vanilla text"; + +// A string is always text: the tags must not become a element. +export default "Vanilla text"; diff --git a/tests/integration/browser/view/render/src/note.content.tsx b/tests/integration/browser/view/render/src/note.content.tsx new file mode 100644 index 00000000..c1a11e46 --- /dev/null +++ b/tests/integration/browser/view/render/src/note.content.tsx @@ -0,0 +1,4 @@ +export const matches = ["http://127.0.0.1/*"]; + +// A React content script with a string render inserts text, not markup. +export default "React content text"; diff --git a/tests/integration/browser/view/render/src/plain.offscreen.ts b/tests/integration/browser/view/render/src/plain.offscreen.ts new file mode 100644 index 00000000..53c23262 --- /dev/null +++ b/tests/integration/browser/view/render/src/plain.offscreen.ts @@ -0,0 +1,8 @@ +export const reasons = ["DOM_PARSER"]; + +export const justification = "Integration check of a headless offscreen"; + +// Without a render the view creates no container: only the page scripts stay in the body. +export default () => ({ + view: () => ({containers: document.body.querySelectorAll(":scope > :not(script)").length}), +}); diff --git a/tests/integration/browser/view/render/src/popup.ts b/tests/integration/browser/view/render/src/popup.ts new file mode 100644 index 00000000..141db46a --- /dev/null +++ b/tests/integration/browser/view/render/src/popup.ts @@ -0,0 +1,26 @@ +import {definePopup, getOffscreen, getSandbox} from "adnbn"; + +const collect = async () => ({ + offscreen: await getOffscreen("worker").view(), + headless: await getOffscreen("plain").view(), + sandbox: await getSandbox("frame").view(), +}); + +export default definePopup({ + render: () => { + const output = document.createElement("output"); + + output.id = "result"; + + collect().then( + result => { + output.value = JSON.stringify(result); + }, + error => { + output.value = JSON.stringify({error: error instanceof Error ? error.message : String(error)}); + } + ); + + return output; + }, +}); diff --git a/tests/integration/browser/view/render/src/text.page.tsx b/tests/integration/browser/view/render/src/text.page.tsx new file mode 100644 index 00000000..8045fe46 --- /dev/null +++ b/tests/integration/browser/view/render/src/text.page.tsx @@ -0,0 +1,4 @@ +export const title = "React text"; + +// The React adapter renders a string as text, like the Vanilla adapter. +export default "React text"; diff --git a/tests/integration/browser/view/render/src/worker.offscreen.tsx b/tests/integration/browser/view/render/src/worker.offscreen.tsx new file mode 100644 index 00000000..8ec73138 --- /dev/null +++ b/tests/integration/browser/view/render/src/worker.offscreen.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import {defineOffscreen, OffscreenReason} from "adnbn"; + +export default defineOffscreen({ + name: "worker", + title: "Worker", + reasons: [OffscreenReason.DOMParser], + justification: "Integration check of the offscreen view", + render: ({title}) =>

React offscreen: {title}

, + init: () => ({ + view: () => ({title: document.title, text: document.getElementById("view")?.textContent ?? null}), + }), +}); diff --git a/tests/integration/browser/view/render/tsconfig.json b/tests/integration/browser/view/render/tsconfig.json new file mode 100644 index 00000000..a732df41 --- /dev/null +++ b/tests/integration/browser/view/render/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "./.adnbn/tsconfig.json", + "compilerOptions": { + "jsx": "react" + } +} From 0d1164c3e88834cfe09605356bf4be0baa862fa9 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:43:20 +0300 Subject: [PATCH 27/28] docs(view): describe the view runtime, adapters and render contract Add src/entry/view/README.md covering adapter selection and the generated modules, how each adapter interprets the default export, the single builder and its container lifecycle, the render values shared with content, the type layout in src/types/view, how to add an adapter, and where each part is verified. --- src/entry/view/README.md | 135 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 src/entry/view/README.md diff --git a/src/entry/view/README.md b/src/entry/view/README.md new file mode 100644 index 00000000..f902730e --- /dev/null +++ b/src/entry/view/README.md @@ -0,0 +1,135 @@ +# View entrypoints + +A view is an extension document that renders UI into its own page: popup, sidebar, options, page and the +override pages (`newtab`, `bookmarks`, `history`). Offscreen and sandbox documents also host a view next to +their transport. This README describes the runtime in this directory and the contracts in +[`src/types/view`](../../types/view). + +## Public API + +Define a view with its `define*` helper from `adnbn`: `definePopup`, `defineSidebar`, `defineOptions`, +`definePage`, `defineNewtab`, `defineBookmarks`, `defineHistory`. `defineOffscreen` and `defineSandbox` +accept the same `render` and `container` options. The render contract is exported from `adnbn` as +`ViewRenderValue`, `ViewRenderHandler`, `ViewVanillaRenderValue`, `ViewReactRenderValue` and +`ViewRenderReactComponent`, next to `ViewDefinition` and `ViewOptions`. + +```tsx title="src/popup.tsx" +import {definePopup} from "adnbn"; + +export default definePopup({ + title: "Popup", + render: ({title}) =>

{title}

, +}); +``` + +```ts title="src/help.page.ts" +export const title = "Help"; + +export default async () => { + const response = await fetch("/help.json"); + const heading = document.createElement("h1"); + + heading.textContent = (await response.json()).title; + + return heading; +}; +``` + +## Adapter selection and generated modules + +The entrypoint filename selects the renderer adapter: `.tsx` and `.jsx` use React +(`adnbn/entry/view/react`), other script extensions use Vanilla (`adnbn/entry/view/vanilla`). Every view +build selects an adapter, so there is no common `adnbn/entry/view` subpath. + +The generated module ([`src/cli/virtual/view.ts`](../../cli/virtual/view.ts)) imports the selected adapter as +`virtual:view-builder` and makes one call: `view(resolveDefinition(module))`. Offscreen and sandbox inject the +adapter's `Builder` class into their own runtime instead: +`offscreen(resolveDefinition(module, name), ViewBuilder)`. Their builder composes the transport with a view +built from the definition without `init`, `main` and `name`; their transport options, in turn, never contain +`render` or `container`. The runtimes of `adnbn/entry/offscreen` and `adnbn/entry/sandbox` import no view +adapter. + +## Definitions + +`mergeDefinition(module, isRenderInput)` in [`resolvers/definition.ts`](resolvers/definition.ts) merges the +module exports using the selected adapter's interpretation of the default export: + +- a default value the adapter recognizes as a render becomes `render` and overrides a named `render`; +- a plain default object overrides the named exports; +- any other default value leaves the named exports as they are. + +Each adapter exposes `resolveDefinition` from its `index.ts`. Vanilla recognizes functions and the +framework-independent values below; React also recognizes React elements through `isValidElement`, so no +common code checks `$$typeof`. For offscreen and sandbox the default export is the transport `init`, resolved +by the transport resolver, never a render. + +## Building and rendering + +[`Builder.ts`](Builder.ts) is the single abstract builder. Its constructor normalizes the definition: the +container through [`resolvers/container.ts`](resolvers/container.ts) and the render through the adapter's +`resolveRender()`. Without an adapter, a defined render throws. `build()`: + +1. destroys the previous run and sets `document.title` from `title`; +2. calls the render with the definition options as props (the definition without `render` and + `container`); a view renders once, so the handler may be asynchronous; +3. creates nothing when the render or its value is absent; +4. creates the container, prepends it to `document.body`, and only then calls the adapter's `mount()`, so a + renderer may measure connected DOM. + +`destroy()` calls the adapter's `unmount()` and removes the container, even when `unmount()` throws. An +adapter implements only what depends on its framework: + +- `resolveRender()` decides what the render input means and drops values the adapter cannot render. Vanilla + calls a function and uses its (awaited) result; React treats a function as a component invoked by React, + so it may use hooks. +- `mount()` puts the value into the container. React creates a root for React elements; `unmount()` unmounts + it. + +Every adapter renders the framework-independent values the same way, shared with the content adapters +through [`src/entry/core/render.ts`](../core/render.ts): + +| Render value | Result | +| ----------------------------------------------- | ------------------------------------------------------------ | +| Nonempty string, number | Text through `textContent`; a string is never parsed as HTML | +| DOM element | Appended as is, including elements created in another realm | +| React element or component (React adapter only) | Rendered by React | +| Empty string, boolean, `null`, `undefined` | Nothing; no container is created | + +Markup comes from elements or from the UI framework, never from a string. + +## Types + +View contracts live in `src/types/view`, organized like the content contracts: + +- `common.ts` owns options, the container contracts and the builder contract. +- `adapters/vanilla.ts` and `adapters/react.ts` describe each adapter's render values. +- `adapters/index.ts` exports the types of all adapters. +- `render.ts` combines those values into `ViewRenderValue` and `ViewRenderHandler`. +- `definition.ts` owns `ViewRenderDefinition` (the `render` and `container` mixin adopted by offscreen and + sandbox), `ViewDefinition`, the resolved definition and the injected builder constructor. +- `index.ts` exports the contracts through `@typing/view`. + +## Adding an adapter + +Add `adapters//` with `Builder.ts` (extending the common `Builder` with `resolveRender()`, `mount()` and, +when the framework owns state in the container, `unmount()`), `definition.ts` (`resolveDefinition` through +`mergeDefinition`) and `index.ts` (the builder, `resolveDefinition` and `Builder.resolver()` as the default +export). Keep tests beside each file. Describe its render values in `src/types/view/adapters/.ts`, +export them from `adapters/index.ts`, and include them in `render.ts`. Selecting the adapter for a filename +belongs to the CLI (`Framework` and `inferEntrypointFramework`); the package already exports +`adnbn/entry/view/*`. + +## Verification + +- `resolvers/definition.test.ts` and `adapters/*/definition.test.ts` cover merging and each adapter's render + recognition; `adapters/*/Builder.test.ts` cover the container lifecycle, props, text and element values + through the public `build()` and `destroy()`. +- [`src/entry/core/render.test.ts`](../core/render.test.ts) covers the shared value contract. +- `src/entry/offscreen/index.test.ts` and `src/entry/sandbox/index.test.ts` cover the transport and view + composition. +- [`src/cli/virtual/virtual.test.ts`](../../cli/virtual/virtual.test.ts) checks the generated modules and that + React stays in the React adapter's bundle graph, out of the offscreen and sandbox runtimes. +- `tests/integration/types/view.integration.test.ts` checks the public types through the source and built + package APIs; `tests/integration/browser/view` renders offscreen, sandbox, page and content views in + Chrome; `tests/integration/browser/options` covers Vanilla and React pages, and + `tests/integration/browser/override` the override pages. From bc65b50c4c170b00a69ea53e92c78a3e6d504c6f Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:13:06 +0300 Subject: [PATCH 28/28] fix(entry): render every React node the React adapters accept The React view and content adapters accepted arrays, portals and bigints in their render types, since both use React's own ReactNode, but passed only React elements to React and silently rendered nothing for the rest; a default-exported portal was even merged as an options object. Recognize every React node through isReactRenderValue() in src/entry/core/react.ts, which only the React adapters import, and hand it to the React root, while text, numbers and DOM elements keep the shared render path; an iterable needs a callable iterator, so options objects stay options. Cover recognition, default exports and rendering of arrays, fragments, portals and bigints for both adapters, extend the type fixtures, and describe the contract in the view and content READMEs, noting that a bigint renders only with React 19. --- src/entry/content/README.md | 16 ++++---- .../content/adapters/react/Builder.test.ts | 38 ++++++++++++++++++- src/entry/content/adapters/react/Builder.ts | 5 ++- src/entry/content/adapters/react/Node.ts | 13 ++++--- .../content/adapters/react/definition.test.ts | 11 ++++++ .../content/adapters/react/definition.ts | 5 +-- src/entry/core/react.test.ts | 35 +++++++++++++++++ src/entry/core/react.ts | 22 +++++++++++ src/entry/view/README.md | 28 +++++++------- src/entry/view/adapters/react/Builder.test.ts | 33 +++++++++++++++- src/entry/view/adapters/react/Builder.ts | 12 +++--- .../view/adapters/react/definition.test.ts | 9 +++++ src/entry/view/adapters/react/definition.ts | 5 +-- .../types/fixtures/content/definition.tsx | 3 ++ .../types/fixtures/view/definition.tsx | 10 ++++- 15 files changed, 203 insertions(+), 42 deletions(-) create mode 100644 src/entry/core/react.test.ts create mode 100644 src/entry/core/react.ts diff --git a/src/entry/content/README.md b/src/entry/content/README.md index e2c1c7e8..c9606a50 100644 --- a/src/entry/content/README.md +++ b/src/entry/content/README.md @@ -127,13 +127,15 @@ Vanilla. `isolation.page`/`isolation.src` selects the common builder regardless Content and Relay. Scripts with only `main` use the filename-based selection. Each adapter interprets its own default export before shared code merges options. Default options -override named options; a recognized default render value overrides a named `render`. React uses -`isValidElement` to recognize elements without exposing that dependency to Vanilla or the common runtime. -For React UI, pass a React element or component function. Component functions are invoked by React -and may use hooks. Every adapter also renders the framework-independent values the same way: a -nonempty string or a number becomes text through `textContent` and is never parsed as HTML, and a DOM -element is appended as is. Markup comes from elements or from the UI framework. Empty strings, -booleans, `null`, and `undefined` render nothing. +override named options; a recognized default render value overrides a named `render`. React recognizes its +nodes through `isReactRenderValue()` in `src/entry/core/react.ts`, which only the React adapters import, so +neither Vanilla nor the common runtime depends on React. For React UI, pass any React node (an element, +fragment, array or other iterable, portal or `bigint`) or a component function; a portal renders into its +own target. React nodes reach React as is, so a `bigint` renders only with React 19; React 18 does not +render it. Component functions are invoked by React and may use hooks. Every adapter also renders the +framework-independent values the same way: a nonempty string or a number becomes text through `textContent` +and is never parsed as HTML, and a DOM element is appended as is. Markup comes from elements or from the UI +framework. Empty strings, booleans, `null`, and `undefined` render nothing. `mergeDefinition` combines exports using the selected resolver's interpretation of default values. The common `resolveDefinition` accepts configuration without recognizing framework components; adapters provide their own definition resolvers. The common builder accepts absent rendering diff --git a/src/entry/content/adapters/react/Builder.test.ts b/src/entry/content/adapters/react/Builder.test.ts index 51c86d8c..07db2f0a 100644 --- a/src/entry/content/adapters/react/Builder.test.ts +++ b/src/entry/content/adapters/react/Builder.test.ts @@ -1,5 +1,6 @@ import {act} from "@testing-library/react"; -import {createElement, useEffect, useState} from "react"; +import {createElement, Fragment, useEffect, useState} from "react"; +import {createPortal} from "react-dom"; import Builder from "./Builder"; import {resolveDefinition} from "./definition"; @@ -136,6 +137,41 @@ describe("React Builder", () => { } }); + test.each([ + ["an array of nodes", [createElement("strong", {key: "strong"}, "a"), "b"], "ab"], + ["a fragment", createElement(Fragment, null, createElement("em", null, "a"), "b"), "ab"], + ["a bigint as text", 42n, "42"], + ])("React renders %s", async (_, value, html) => { + const anchor = document.createElement("article"); + document.body.appendChild(anchor); + const builder = new Builder(resolveDefinition({anchor, default: value})); + + try { + await act(() => builder.build()); + expect(anchor.firstElementChild?.innerHTML).toBe(html); + } finally { + await act(() => builder.destroy()); + } + }); + + test("React renders a portal into its own target", async () => { + const anchor = document.createElement("article"); + const target = document.createElement("aside"); + document.body.append(anchor, target); + const builder = new Builder( + resolveDefinition({anchor, default: createPortal(createElement("p", null, "portal"), target)}) + ); + + try { + await act(() => builder.build()); + expect(target.innerHTML).toBe("

portal

"); + } finally { + await act(() => builder.destroy()); + } + + expect(target.innerHTML).toBe(""); + }); + test("React appends a DOM element render value", async () => { const anchor = document.createElement("article"); const element = document.createElement("span"); diff --git a/src/entry/content/adapters/react/Builder.ts b/src/entry/content/adapters/react/Builder.ts index 231c27b2..ab3a344b 100644 --- a/src/entry/content/adapters/react/Builder.ts +++ b/src/entry/content/adapters/react/Builder.ts @@ -1,6 +1,7 @@ -import {createElement, isValidElement} from "react"; +import {createElement} from "react"; import {isDomRenderValue} from "@entry/core/render"; +import {isReactRenderValue} from "@entry/core/react"; import ReactNode from "./Node"; @@ -34,7 +35,7 @@ export default class Builder< ? createElement(render as ContentScriptRenderReactComponent, props) : render; - return isValidElement(value) || isDomRenderValue(value) ? value : undefined; + return isDomRenderValue(value) || isReactRenderValue(value) ? value : undefined; }; } diff --git a/src/entry/content/adapters/react/Node.ts b/src/entry/content/adapters/react/Node.ts index 500e17b0..95c0741b 100644 --- a/src/entry/content/adapters/react/Node.ts +++ b/src/entry/content/adapters/react/Node.ts @@ -1,7 +1,7 @@ -import {isValidElement} from "react"; import {createRoot, type Root} from "react-dom/client"; import {isDomRenderValue, renderDomValue} from "@entry/core/render"; +import {isReactRenderValue} from "@entry/core/react"; import {RenderNode} from "../../lifecycle/nodes"; @@ -11,15 +11,16 @@ export default class Node extends RenderNode { private root?: Root; protected render(value: ContentScriptRenderValue | undefined, target: Element): boolean { - if (isValidElement(value)) { - this.root = createRoot(target); - this.root.render(value); + // Text is also iterable, so framework-independent values are checked before React nodes. + if (isDomRenderValue(value)) { + renderDomValue(target, value); return true; } - if (isDomRenderValue(value)) { - renderDomValue(target, value); + if (isReactRenderValue(value)) { + this.root = createRoot(target); + this.root.render(value); return true; } diff --git a/src/entry/content/adapters/react/definition.test.ts b/src/entry/content/adapters/react/definition.test.ts index 7f535e24..0635057f 100644 --- a/src/entry/content/adapters/react/definition.test.ts +++ b/src/entry/content/adapters/react/definition.test.ts @@ -1,3 +1,6 @@ +import {createElement} from "react"; +import {createPortal} from "react-dom"; + import {resolveDefinition} from "./definition"; describe("React content definitions", () => { @@ -16,6 +19,14 @@ describe("React content definitions", () => { expect(resolveDefinition({default: value, main})).toEqual({main}); }); + test.each([ + ["an array of nodes", [createElement("p", {key: "p"}), "text"]], + ["a portal", createPortal(createElement("p"), document.createElement("div"))], + ["a bigint", 42n], + ])("uses %s as the default render", (_, value) => { + expect(resolveDefinition({default: value, anchor: "article"})).toEqual({anchor: "article", render: value}); + }); + test("a property named $$typeof alone does not identify a React element", () => { const definition = {main: jest.fn(), $$typeof: "application metadata"}; expect(resolveDefinition({default: definition})).toEqual(definition); diff --git a/src/entry/content/adapters/react/definition.ts b/src/entry/content/adapters/react/definition.ts index b970fc21..f109772d 100644 --- a/src/entry/content/adapters/react/definition.ts +++ b/src/entry/content/adapters/react/definition.ts @@ -1,6 +1,5 @@ -import {isValidElement} from "react"; - import {isDomRenderValue} from "@entry/core/render"; +import {isReactRenderValue} from "@entry/core/react"; import {mergeDefinition} from "../../resolvers/definition"; @@ -9,6 +8,6 @@ import type {ContentScriptDefinition} from "@typing/content"; export const resolveDefinition = (module: object): ContentScriptDefinition => { return mergeDefinition( module, - value => typeof value === "function" || isValidElement(value) || isDomRenderValue(value) + value => typeof value === "function" || isDomRenderValue(value) || isReactRenderValue(value) ); }; diff --git a/src/entry/core/react.test.ts b/src/entry/core/react.test.ts new file mode 100644 index 00000000..5a368227 --- /dev/null +++ b/src/entry/core/react.test.ts @@ -0,0 +1,35 @@ +import {createElement, Fragment} from "react"; +import {createPortal} from "react-dom"; + +import {isReactRenderValue} from "./react"; + +describe("React render values", () => { + test.each([ + ["an element", createElement("p")], + ["a fragment", createElement(Fragment, null, "text")], + ["an array of nodes", [createElement("p", {key: "p"}), "text"]], + ["an empty array", []], + ["an iterable of nodes", new Set([createElement("p", {key: "p"})])], + ["a portal", createPortal(createElement("p"), document.createElement("div"))], + ["a bigint", 42n], + ])("accepts %s", (_, value) => { + expect(isReactRenderValue(value)).toBe(true); + }); + + test.each([ + ["a string, which is framework-independent text", "text"], + ["a number, which is framework-independent text", 42], + ["a DOM element, which is framework-independent", document.createElement("p")], + ["an options object", {title: "Options"}], + ["an object with a string $$typeof", {$$typeof: "application metadata"}], + ["an object with an undefined iterator", {title: "Options", [Symbol.iterator]: undefined}], + ["an object with a null iterator", {title: "Options", [Symbol.iterator]: null}], + ["an object with a non-function iterator", {title: "Options", [Symbol.iterator]: 1}], + ["a promise", Promise.resolve(createElement("p"))], + ["a boolean", true], + ["null", null], + ["undefined", undefined], + ])("rejects %s", (_, value) => { + expect(isReactRenderValue(value)).toBe(false); + }); +}); diff --git a/src/entry/core/react.ts b/src/entry/core/react.ts new file mode 100644 index 00000000..5d52bf90 --- /dev/null +++ b/src/entry/core/react.ts @@ -0,0 +1,22 @@ +import {isValidElement, type ReactNode} from "react"; + +const ReactPortalType = Symbol.for("react.portal"); + +/** + * React-only render values: elements (including fragments), portals, bigints and iterables of nodes. + * Text, numbers and DOM elements are framework-independent and belong to `isDomRenderValue()`. + */ +export const isReactRenderValue = (value: unknown): value is ReactNode => { + if (isValidElement(value) || typeof value === "bigint") { + return true; + } + + if (typeof value !== "object" || value === null) { + return false; + } + + const node = value as {$$typeof?: unknown; [Symbol.iterator]?: unknown}; + + // A portal is a plain object that isValidElement() does not recognize; an iterable needs a callable iterator. + return node.$$typeof === ReactPortalType || typeof node[Symbol.iterator] === "function"; +}; diff --git a/src/entry/view/README.md b/src/entry/view/README.md index f902730e..41b8e39e 100644 --- a/src/entry/view/README.md +++ b/src/entry/view/README.md @@ -59,9 +59,9 @@ module exports using the selected adapter's interpretation of the default export - any other default value leaves the named exports as they are. Each adapter exposes `resolveDefinition` from its `index.ts`. Vanilla recognizes functions and the -framework-independent values below; React also recognizes React elements through `isValidElement`, so no -common code checks `$$typeof`. For offscreen and sandbox the default export is the transport `init`, resolved -by the transport resolver, never a render. +framework-independent values below; React also recognizes every React node through `isReactRenderValue()` +in [`src/entry/core/react.ts`](../core/react.ts), which only the React adapters import. For offscreen and +sandbox the default export is the transport `init`, resolved by the transport resolver, never a render. ## Building and rendering @@ -82,20 +82,21 @@ adapter implements only what depends on its framework: - `resolveRender()` decides what the render input means and drops values the adapter cannot render. Vanilla calls a function and uses its (awaited) result; React treats a function as a component invoked by React, so it may use hooks. -- `mount()` puts the value into the container. React creates a root for React elements; `unmount()` unmounts - it. +- `mount()` puts the value into the container. React creates a root for a React node; `unmount()` unmounts it. Every adapter renders the framework-independent values the same way, shared with the content adapters through [`src/entry/core/render.ts`](../core/render.ts): -| Render value | Result | -| ----------------------------------------------- | ------------------------------------------------------------ | -| Nonempty string, number | Text through `textContent`; a string is never parsed as HTML | -| DOM element | Appended as is, including elements created in another realm | -| React element or component (React adapter only) | Rendered by React | -| Empty string, boolean, `null`, `undefined` | Nothing; no container is created | +| Render value | Result | +| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Nonempty string, number | Text through `textContent`; a string is never parsed as HTML | +| DOM element | Appended as is, including elements created in another realm | +| React node or component (React adapter only): element, fragment, array or other iterable, portal, `bigint` (React 19) | Rendered by React; a portal renders into its own target; an empty array is a node that renders nothing | +| Empty string, boolean, `null`, `undefined` | Nothing; no container is created | -Markup comes from elements or from the UI framework, never from a string. +Markup comes from elements or from the UI framework, never from a string. The React adapter passes a React node +to React as is, so what renders depends on the installed React version: React 18 does not render a `bigint`, +React 19 renders it as text. ## Types @@ -124,7 +125,8 @@ belongs to the CLI (`Framework` and `inferEntrypointFramework`); the package alr - `resolvers/definition.test.ts` and `adapters/*/definition.test.ts` cover merging and each adapter's render recognition; `adapters/*/Builder.test.ts` cover the container lifecycle, props, text and element values through the public `build()` and `destroy()`. -- [`src/entry/core/render.test.ts`](../core/render.test.ts) covers the shared value contract. +- [`src/entry/core/render.test.ts`](../core/render.test.ts) covers the shared value contract, and + [`src/entry/core/react.test.ts`](../core/react.test.ts) the React nodes recognized by the React adapters. - `src/entry/offscreen/index.test.ts` and `src/entry/sandbox/index.test.ts` cover the transport and view composition. - [`src/cli/virtual/virtual.test.ts`](../../cli/virtual/virtual.test.ts) checks the generated modules and that diff --git a/src/entry/view/adapters/react/Builder.test.ts b/src/entry/view/adapters/react/Builder.test.ts index f50090bf..ec9973b5 100644 --- a/src/entry/view/adapters/react/Builder.test.ts +++ b/src/entry/view/adapters/react/Builder.test.ts @@ -1,5 +1,6 @@ import {act} from "@testing-library/react"; -import {createElement, useEffect} from "react"; +import {createElement, Fragment, useEffect} from "react"; +import {createPortal} from "react-dom"; import Builder from "./Builder"; @@ -77,6 +78,36 @@ describe("React view Builder", () => { expect(document.body.firstElementChild?.firstElementChild).toBe(element); }); + test.each([ + ["an array of nodes", [createElement("strong", {key: "strong"}, "a"), "b"], "ab"], + ["a fragment", createElement(Fragment, null, createElement("em", null, "a"), "b"), "ab"], + ["a bigint as text", 42n, "42"], + ])("renders %s through React", async (_, value, html) => { + const builder = new Builder({render: value}); + + await act(() => builder.build()); + + expect(document.body.firstElementChild?.innerHTML).toBe(html); + + await act(() => builder.destroy()); + }); + + test("renders a portal into its own target", async () => { + const target = document.createElement("aside"); + + document.body.append(target); + + const builder = new Builder({render: createPortal(createElement("p", null, "portal"), target)}); + + await act(() => builder.build()); + + expect(target.innerHTML).toBe("

portal

"); + + await act(() => builder.destroy()); + + expect(target.innerHTML).toBe(""); + }); + test("creates no container for an unsupported value", async () => { await act(() => new Builder({title: "Empty", render: true}).build()); diff --git a/src/entry/view/adapters/react/Builder.ts b/src/entry/view/adapters/react/Builder.ts index fad6e0c8..ddd1800b 100644 --- a/src/entry/view/adapters/react/Builder.ts +++ b/src/entry/view/adapters/react/Builder.ts @@ -1,7 +1,8 @@ -import {createElement, isValidElement} from "react"; +import {createElement} from "react"; import {createRoot, Root} from "react-dom/client"; import {isDomRenderValue, renderDomValue} from "@entry/core/render"; +import {isReactRenderValue} from "@entry/core/react"; import ViewBuilder from "../../Builder"; @@ -24,16 +25,17 @@ export default class Builder extends ViewBuilder { const value = typeof render === "function" ? createElement(render as ViewRenderReactComponent, props) : render; - return isValidElement(value) || isDomRenderValue(value) ? value : undefined; + return isDomRenderValue(value) || isReactRenderValue(value) ? value : undefined; }; } protected mount(container: Element, value: ViewRenderValue): void { - if (isValidElement(value)) { + // Text is also iterable, so framework-independent values are checked before React nodes. + if (isDomRenderValue(value)) { + renderDomValue(container, value); + } else if (isReactRenderValue(value)) { this.root = createRoot(container); this.root.render(value); - } else if (isDomRenderValue(value)) { - renderDomValue(container, value); } } diff --git a/src/entry/view/adapters/react/definition.test.ts b/src/entry/view/adapters/react/definition.test.ts index 29cb309e..e1d144be 100644 --- a/src/entry/view/adapters/react/definition.test.ts +++ b/src/entry/view/adapters/react/definition.test.ts @@ -1,4 +1,5 @@ import {createElement} from "react"; +import {createPortal} from "react-dom"; import {resolveDefinition} from "./definition"; @@ -25,6 +26,14 @@ describe("React view definitions", () => { }); }); + test.each([ + ["an array of nodes", [createElement("p", {key: "p"}), "text"]], + ["a portal", createPortal(createElement("p"), document.createElement("div"))], + ["a bigint", 42n], + ])("uses %s as the default render", (_, value) => { + expect(resolveDefinition({default: value, title: "Named"})).toEqual({title: "Named", render: value}); + }); + test("a property named $$typeof alone does not identify a React element", () => { const definition = {title: "Options", $$typeof: "application metadata"}; diff --git a/src/entry/view/adapters/react/definition.ts b/src/entry/view/adapters/react/definition.ts index 2e2dc9db..877232fc 100644 --- a/src/entry/view/adapters/react/definition.ts +++ b/src/entry/view/adapters/react/definition.ts @@ -1,6 +1,5 @@ -import {isValidElement} from "react"; - import {isDomRenderValue} from "@entry/core/render"; +import {isReactRenderValue} from "@entry/core/react"; import {mergeDefinition} from "../../resolvers/definition"; @@ -9,6 +8,6 @@ import type {ViewConfig, ViewDefinition} from "@typing/view"; export const resolveDefinition = (module: object): ViewDefinition => { return mergeDefinition( module, - value => typeof value === "function" || isValidElement(value) || isDomRenderValue(value) + value => typeof value === "function" || isDomRenderValue(value) || isReactRenderValue(value) ); }; diff --git a/tests/integration/types/fixtures/content/definition.tsx b/tests/integration/types/fixtures/content/definition.tsx index 226afebb..d267dad2 100644 --- a/tests/integration/types/fixtures/content/definition.tsx +++ b/tests/integration/types/fixtures/content/definition.tsx @@ -1,3 +1,4 @@ +import {createPortal} from "react-dom"; import {ContentScriptAppend, ContentScriptIsolation, defineContentScript, defineContentScriptAppend} from "adnbn"; import type { @@ -32,6 +33,8 @@ const reactValues: ContentScriptReactRenderValue[] = [ boundary={undefined} />, [, "text"], + createPortal(, document.body), + 42n, ]; const values: ContentScriptRenderValue[] = [...vanillaValues, ...reactValues]; diff --git a/tests/integration/types/fixtures/view/definition.tsx b/tests/integration/types/fixtures/view/definition.tsx index aecd4916..24d7df7e 100644 --- a/tests/integration/types/fixtures/view/definition.tsx +++ b/tests/integration/types/fixtures/view/definition.tsx @@ -1,3 +1,4 @@ +import {createPortal} from "react-dom"; import {defineOffscreen, definePopup, defineSandbox} from "adnbn"; import type { @@ -23,7 +24,14 @@ const vanillaValues: ViewVanillaRenderValue[] = [ undefined, ]; -const reactValues: ViewReactRenderValue[] = [Panel, , [, "text"]]; +const reactValues: ViewReactRenderValue[] = [ + Panel, + , + <>fragment, + [, "text"], + createPortal(, document.body), + 42n, +]; const values: ViewRenderValue[] = [...vanillaValues, ...reactValues];