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(); diff --git a/AGENTS.md b/AGENTS.md index 9e20d5b8..1a1cc1aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,15 @@ - Wait for the expected compilation or observable output with a descriptive timeout. Do not rely on fixed sleeps or assume that emitted files mean the watcher has finished reconnecting. Preserve exact filename casing and account for platform line endings when they are not part of the tested contract. - A local pass on one operating system does not establish cross-platform compatibility. For platform-specific failures, reproduce the relevant filesystem or process behavior when possible and distinguish that simulation from validation on the actual CI runner. Do not hide the failure by skipping the platform or only increasing timeouts. +## Entrypoint parsing tests + +- `src/cli/entrypoint/file` reads source files without knowing entrypoints: a parser only supplies a definition name and its schema keys through `AbstractParser.optionFile()`. Organize its tests and fixtures by what the reader can read, not by entrypoint: export shapes in `tests/fixtures/export-shapes`, static value resolution in `static-options`, and expression kinds in `expression`. Cover every way a file may declare options there once; do not repeat that matrix per entrypoint. +- `src/cli/entrypoint/parser` tests are organized by entrypoint: one `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//options//`, `invalid/`, and `contracts/`. Use real entrypoint files rather than mocked readers or generated source strings. +- A parser test proves what the parser owns: its definition is recognised and a sibling's is ignored; a `full` scenario reads every accepted key through its own definition; defaults; invalid values of its own keys; entrypoint-specific interpretation; and `contract()` when the parser declares an agreement. The `full` scenario is also how an entrypoint proves that it adopted a shared schema fragment. +- Test a shared schema fragment once at its owner: the common filters in `AbstractParser.test.ts`; view and CSP options belong beside `ViewParser` and `ViewCspParser`; a mixin schema beside its module in `parser/schemas`. Do not add a cross-cutting test file with one row per adopting entrypoint. +- A schema fragment that unrelated parsers opt into, such as `PermissionsSchema`, is a mixin: keep it as a module in `src/cli/entrypoint/parser/schemas` and merge it explicitly in each adopting parser. Do not put it on `AbstractParser`, which holds only what every entrypoint shares, and do not add an intermediate parser class for it: independent mixins cannot be combined through single inheritance. +- Do not re-test export shapes such as named exports, default objects, `as`, or `satisfies` in a parser test. Add a parser-level shape scenario only when the entrypoint gives that shape a meaning, for example the Content default render or the Relay init. + ## Responsibility boundaries ### Layer ownership @@ -71,6 +80,8 @@ - Import internal TypeScript modules through configured aliases or relative paths without a file extension. Do not write the future `.js` output path into a TypeScript source import. - Output extensions and external-module paths belong to the build configuration. When a bundled entry needs an external dependency, resolve its source import to the emitted ESM path during the build. - Imports of actual JavaScript files, package subpaths, and loader-specific resources such as `.ts?raw` keep the syntax required by their owner. This rule does not rewrite raw template contents or generated JavaScript imports. +- A directory of functions inside an entrypoint runtime, such as `src/entry//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/`. 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 @@ -110,6 +121,16 @@ return node; - Name classes after their responsibility and role, such as `LocaleFinder` or `ContentParser`. Avoid names tied only to a temporary implementation detail or former file location. - Keep private helpers and local variables concise when their surrounding scope already supplies the domain. Do not mechanically repeat a long public prefix everywhere. +## Type declarations and reuse + +- Apply these conventions to every type the project owns: shared contracts, runtime APIs, entrypoints, CLI, bundler plugins, and tests. +- Refer to a type by its name wherever it appears in class and function code: return types, parameters, properties, generic arguments, and local annotations. Do not reach into another type with an indexed access such as `ReadonlyConfig["homepage"]` or `ViewItem["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`, `EntrypointFile` rather than `{file: string; import: string}`, and `ViewItems` rather than `Map>`. 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`, 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`. 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/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({ + 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 = (definition: T): T => definition; + +export const excludeBrowser = [Browser.Edge]; + +export default defineBackground({ + persistent: true, + includeBrowser: [Browser.Firefox], +}); 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/AbstractOverrideFinder.test.ts b/src/cli/entrypoint/finder/AbstractOverrideFinder.test.ts new file mode 100644 index 00000000..98d08d43 --- /dev/null +++ b/src/cli/entrypoint/finder/AbstractOverrideFinder.test.ts @@ -0,0 +1,123 @@ +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("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(); + + 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"]}}]); + await expect(finder.selectedOptions()).resolves.toMatchObject([{permissions: ["topSites"]}]); + }); +}); diff --git a/src/cli/entrypoint/finder/AbstractOverrideFinder.ts b/src/cli/entrypoint/finder/AbstractOverrideFinder.ts new file mode 100644 index 00000000..9e6a9a87 --- /dev/null +++ b/src/cli/entrypoint/finder/AbstractOverrideFinder.ts @@ -0,0 +1,33 @@ +import ViewPermissionsFinder from "./ViewPermissionsFinder"; +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< + O extends OverrideEntrypointOptions, +> extends ViewPermissionsFinder { + 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/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 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/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 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 { + 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 { return Array.from(await this.views()).reduce((aliases, [_, item]) => { return { 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/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 = {}): CommandFinder => { + const config = { + app: "app", + appSrcDir: ".", + appsDir: "apps", + debug: false, + mergeCommands: false, + plugins: [], + rootDir: fixtures, + sharedDir: ".", + srcDir: scenario, + ...overrides, + } as Partial 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 { @@ -31,16 +31,32 @@ export default class extends AbstractPluginFinder { 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> { const commands = new Map(); + const owners = new Map(); 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 { } public clear(): this { - this.names.reset(); - this._commands = undefined; return super.clear(); 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/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 { +export default class extends ViewPermissionsFinder { public constructor(config: ReadonlyConfig) { super(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/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 { +export default class extends ViewPermissionsFinder { 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 { +export default class extends ViewPermissionsFinder { 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 extends AbstractViewFinder { 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 = {}): PopupFinder => { + const config = { + app: "myapp", + appSrcDir: ".", + appsDir: "apps", + debug: false, + htmlDir: ".", + mergePopup: false, + multiplePopup: false, + plugins: [], + rootDir, + sharedDir: ".", + srcDir: "src", + ...overrides, + } as Partial 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 { + protected _permissions?: Promise; + + public async permissions(): Promise { + return (await this.getPermissions()).permissions; + } + + public async optionalPermissions(): Promise { + return (await this.getPermissions()).optionalPermissions; + } + + public async hostPermissions(): Promise { + return (await this.getPermissions()).hostPermissions; + } + + public async optionalHostPermissions(): Promise { + return (await this.getPermissions()).optionalHostPermissions; + } + + protected getPermissions(): Promise { + return (this._permissions ??= this.collectPermissions()); + } + + protected async collectPermissions(): Promise { + 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 c964633e..ed27cfee 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, @@ -9,12 +10,16 @@ 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"; 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/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..a135b733 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} 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/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..03f12514 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/bookmarks.ts @@ -0,0 +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 new file mode 100644 index 00000000..f1fd7d8f --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/history.ts @@ -0,0 +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 new file mode 100644 index 00000000..38e6c281 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/newtab.ts @@ -0,0 +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 new file mode 100644 index 00000000..2d623d22 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/bookmarks.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 00000000..d6e5afb8 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/history.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 00000000..71a816be --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/src/apps/app/newtab.ts @@ -0,0 +1,8 @@ +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/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..57275076 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} 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..a135b733 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/selection/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} 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/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 extends ViewCspParser { + protected schema(): typeof this.CommonPropertiesSchema { + return super.schema().merge(PermissionsSchema); + } +} 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 { + 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/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 { + protected definition(): string { + return "defineBookmarks"; + } +} 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/HistoryParser.ts b/src/cli/entrypoint/parser/HistoryParser.ts new file mode 100644 index 00000000..331514ca --- /dev/null +++ b/src/cli/entrypoint/parser/HistoryParser.ts @@ -0,0 +1,9 @@ +import AbstractOverrideParser from "./AbstractOverrideParser"; + +import {HistoryEntrypointOptions} from "@typing/override"; + +export default class HistoryParser extends AbstractOverrideParser { + 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..0802d025 --- /dev/null +++ b/src/cli/entrypoint/parser/NewtabParser.ts @@ -0,0 +1,9 @@ +import AbstractOverrideParser from "./AbstractOverrideParser"; + +import {NewtabEntrypointOptions} from "@typing/override"; + +export default class NewtabParser extends AbstractOverrideParser { + protected definition(): string { + return "defineNewtab"; + } +} 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/OptionsParser.test.ts b/src/cli/entrypoint/parser/OptionsParser.test.ts index 2a2ed898..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: { @@ -57,27 +61,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/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 { } 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 new file mode 100644 index 00000000..a6fbb0e5 --- /dev/null +++ b/src/cli/entrypoint/parser/PopupParser.test.ts @@ -0,0 +1,39 @@ +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, permissions 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"]}}, + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], + 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/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 { } 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/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..a9afa0c6 --- /dev/null +++ b/src/cli/entrypoint/parser/SidebarParser.test.ts @@ -0,0 +1,39 @@ +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, permissions 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"]}}, + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], + 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/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 { } 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/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 extends AbstractParser { - 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 542a5dc5..da5bde4d 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"; @@ -9,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/override-parsers.test.ts b/src/cli/entrypoint/parser/override-parsers.test.ts new file mode 100644 index 00000000..9ed3f7ab --- /dev/null +++ b/src/cli/entrypoint/parser/override-parsers.test.ts @@ -0,0 +1,64 @@ +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"); + +const config = {rootDir} as ReadonlyConfig; + +// 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)}, + {type: "history", label: "History", parser: new HistoryParser(config)}, +])("$type parser", ({type, label, parser}) => { + const parseOptions = (scenario: string) => { + const filename = path.join(fixtures, type, "options", scenario, `${type}.ts`); + + return parser.options({file: filename, import: filename}); + }; + + test("parses its own definition with permissions and 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:"], + }, + }, + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], + scripts: "extra.js", + links: "extra.css", + metas: { + attributes: { + name: `${type}-test`, + content: "enabled", + }, + }, + }); + }); + + test("ignores options wrapped in another override definition", () => { + expect(parseOptions("foreign-definition")).toEqual({}); + }); +}); 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/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 = { + 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/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/bookmarks/options/foreign-definition/bookmarks.ts b/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/foreign-definition/bookmarks.ts new file mode 100644 index 00000000..f95f4eb2 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/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/bookmarks/options/full/bookmarks.ts b/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/full/bookmarks.ts new file mode 100644 index 00000000..2b9ec7c1 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/full/bookmarks.ts @@ -0,0 +1,34 @@ +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:"], + }, + }, + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], + 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/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/history/options/foreign-definition/history.ts b/src/cli/entrypoint/parser/tests/fixtures/history/options/foreign-definition/history.ts new file mode 100644 index 00000000..527a401e --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/history/options/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/history/options/full/history.ts b/src/cli/entrypoint/parser/tests/fixtures/history/options/full/history.ts new file mode 100644 index 00000000..dc301db5 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/history/options/full/history.ts @@ -0,0 +1,34 @@ +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:"], + }, + }, + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], + 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/newtab/options/foreign-definition/newtab.ts b/src/cli/entrypoint/parser/tests/fixtures/newtab/options/foreign-definition/newtab.ts new file mode 100644 index 00000000..7a8409fc --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/newtab/options/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/newtab/options/full/newtab.ts b/src/cli/entrypoint/parser/tests/fixtures/newtab/options/full/newtab.ts new file mode 100644 index 00000000..bac4dfb3 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/newtab/options/full/newtab.ts @@ -0,0 +1,34 @@ +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:"], + }, + }, + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], + 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/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/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/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/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"; 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..f291ec63 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts @@ -0,0 +1,17 @@ +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"]}}, + 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/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..e7e1eb80 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts @@ -0,0 +1,17 @@ +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"]}}, + 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/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[0], RelayUnresolvedDefinition> >; -type NamedExports = Expect< - Equal>, Readonly>> ->; -type DefaultExport = Expect< - Equal< - typeof definition.default, - RelayDefinition | RelayDefinition["init"] | undefined - > ->; new RelayBuilder({allFrames: RelayAllFrames.All}, ContentScriptBuilder); new ContentScriptBuilder({allFrames: true}); 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/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 new file mode 100644 index 00000000..65704be9 --- /dev/null +++ b/src/cli/plugins/override/Override.test.ts @@ -0,0 +1,173 @@ +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]; + +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(), + 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]}, +] 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(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]}, + ]); + }); + + 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([]); + await expect(readPermissions(override)).resolves.toEqual(none); + }); +}); + +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); + await expect(readPermissions(override)).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"]}}]); + // The skipped History page must not request its browsing-history permission. + await expect(readPermissions(override)).resolves.toEqual(declared.newtab); + } + ); + + 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([]); + await expect(readPermissions(override)).resolves.toEqual(none); + }); +}); + +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..635c3483 --- /dev/null +++ b/src/cli/plugins/override/Override.ts @@ -0,0 +1,157 @@ +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 { + ManifestHostPermissions, + ManifestOptionalPermissions, + ManifestOverride, + ManifestPermissions, +} 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() : []; + } + + // Permissions of the override that reaches the build; skipped and losing candidates contribute nothing. + public async permissions(): Promise { + return (await (await this.finder())?.permissions()) ?? new Set(); + } + + public async optionalPermissions(): Promise { + return (await (await this.finder())?.optionalPermissions()) ?? new Set(); + } + + public async hostPermissions(): Promise { + return (await (await this.finder())?.hostPermissions()) ?? new Set(); + } + + public async optionalHostPermissions(): Promise { + return (await (await this.finder())?.optionalHostPermissions()) ?? new Set(); + } + + 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..25495b0a --- /dev/null +++ b/src/cli/plugins/override/index.ts @@ -0,0 +1,58 @@ +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()) + .appendPermissions(await override.permissions()) + .appendOptionalPermissions(await override.optionalPermissions()) + .appendHostPermissions(await override.hostPermissions()) + .appendOptionalHostPermissions(await override.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 new file mode 100644 index 00000000..73ee2902 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/bookmarks/src/bookmarks.ts @@ -0,0 +1,8 @@ +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/bookmarks/tsconfig.json b/src/cli/plugins/override/tests/fixtures/bookmarks/tsconfig.json new file mode 100644 index 00000000..a135b733 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/bookmarks/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} 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..0cbdf3fd --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/conflict/src/history/index.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 00000000..9df53497 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/conflict/src/newtab.ts @@ -0,0 +1,11 @@ +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/conflict/tsconfig.json b/src/cli/plugins/override/tests/fixtures/conflict/tsconfig.json new file mode 100644 index 00000000..a135b733 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/conflict/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} 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..0cbdf3fd --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/history/src/history.ts @@ -0,0 +1,8 @@ +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/history/tsconfig.json b/src/cli/plugins/override/tests/fixtures/history/tsconfig.json new file mode 100644 index 00000000..a135b733 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/history/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} 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..9df53497 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/newtab/src/newtab.ts @@ -0,0 +1,11 @@ +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/newtab/tsconfig.json b/src/cli/plugins/override/tests/fixtures/newtab/tsconfig.json new file mode 100644 index 00000000..a135b733 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/newtab/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} 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..0cbdf3fd --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/split/src/history.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 00000000..581a0c85 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/split/src/newtab.ts @@ -0,0 +1,12 @@ +import {defineNewtab} from "adnbn"; + +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/cli/plugins/override/tests/fixtures/split/tsconfig.json b/src/cli/plugins/override/tests/fixtures/split/tsconfig.json new file mode 100644 index 00000000..a135b733 --- /dev/null +++ b/src/cli/plugins/override/tests/fixtures/split/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} 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/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 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 { 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/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/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..a135b733 --- /dev/null +++ b/src/cli/plugins/view/tests/fixtures/html-options/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} 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..a135b733 --- /dev/null +++ b/src/cli/plugins/view/tests/fixtures/no-html-options/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../../../../../tsconfig.json", + "include": ["**/*"], + "exclude": [] +} 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/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/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/index.ts b/src/cli/virtual/index.ts index 8b25ef5b..69561618 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 => { @@ -50,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 => { @@ -69,15 +59,15 @@ 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 => { - return getTransportModule(file, name, "service"); + return getVirtualModule(file, "service").replace("virtual:service-name", name); }; 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..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-framework"; +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 = 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/sandbox.ts b/src/cli/virtual/sandbox.ts index 9842c3ac..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-framework"; +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 = 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/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 = 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/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 = 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 67bef56f..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,85 +19,11 @@ 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; - - 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; - - const definition: RelayDefinition | RelayDefinition["init"] | undefined; - export default definition; -} +declare module "virtual:view-builder" { + export const resolveDefinition: ( + module: object + ) => import("@typing/view").ViewDefinition; -declare module "virtual:sandbox-entrypoint" { - type SandboxDefinition = import("@typing/sandbox").SandboxDefinition; - - 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; - - interface ModuleType extends ViewDefinition { - default: ViewDefinition | ViewDefinition["render"] | undefined; - } - - const module: ModuleType; - export = module; -} - -declare module "virtual:view-framework" { export const Builder: | typeof import("@entry/view/adapters/vanilla").Builder | typeof import("@entry/view/adapters/react").Builder; @@ -135,21 +33,3 @@ declare module "virtual:view-framework" { | typeof import("@entry/view/adapters/react").default; export default view; } - -declare module "virtual:transport-entrypoint" { - type TransportDefinition = import("@typing/transport").TransportDefinition; - - interface ModuleType extends TransportDefinition { - default: TransportDefinition | TransportDefinition["init"] | undefined; - } - - const module: ModuleType; - export = module; -} - -declare module "adnbn/entry/:entry" { - import type {TransportUnresolvedDefinition, TransportOptions, TransportType} from "@typing/transport"; - - const transport: (definition: TransportUnresolvedDefinition) => void; - export default transport; -} diff --git a/src/cli/virtual/virtual.test.ts b/src/cli/virtual/virtual.test.ts index 50c5f257..97627625 100644 --- a/src/cli/virtual/virtual.test.ts +++ b/src/cli/virtual/virtual.test.ts @@ -12,11 +12,11 @@ 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", - imports: ["adnbn", "adnbn/entry/command", "{entry}"], + imports: ["adnbn/entry/command", "{entry}"], }, { generator: "virtualContentScriptModule", @@ -24,18 +24,11 @@ describe("Built virtual modules", () => { }, { generator: "virtualServiceModule", - imports: ["adnbn/transport", "adnbn/entry/transport", "adnbn/entry/service", "{entry}"], + imports: ["adnbn/entry/service", "{entry}"], }, { 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", @@ -47,24 +40,34 @@ 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", - imports: ["adnbn", "adnbn/entry/view", "adnbn/entry/view/{framework}", "{entry}"], + imports: ["adnbn/entry/view/{framework}", "{entry}"], }, ]; let generated: Record<"ts" | "tsx", Record>; let navigation: Record<"ts" | "tsx", Record<"virtualContentScriptModule" | "virtualRelayModule", string>>; let contentModule: string; + const bundleInputs = async (entrypoint: string): Promise => { + 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( @@ -136,20 +139,10 @@ describe("Built virtual modules", () => { "adnbn/entry/content/react", "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 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); @@ -158,43 +151,97 @@ describe("Built virtual modules", () => { if (entrypoint === "adnbn/entry/relay") { expect(inputs.some(filename => filename.includes("entry/content/"))).toBe(false); } + 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( entrypoint.endsWith("/vanilla") ); }); + 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"}, ])("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.each([ + { + generator: "virtualBackgroundModule", + specifier: "adnbn/entry/background", + 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}", + 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", + 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", + 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}) => { + 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; - 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; + 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 +253,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/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 718808fa..e66357b1 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/definition"; 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; + const definition = namedDefinition as BackgroundDefinition; + + if (isDefinitionObject(defaultDefinition)) { + return {...definition, ...defaultDefinition}; + } + + if (isValidBackgroundMainHandler(defaultDefinition)) { + return {...definition, main: defaultDefinition}; + } + + return definition; +}; 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 718808fa..e66357b1 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/definition"; 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; + + let definition = namedDefinition as CommandUnresolvedDefinition; + + if (isDefinitionObject(defaultDefinition)) { + definition = {...definition, ...defaultDefinition}; + } else if (isValidCommandExecuteFunction(defaultDefinition)) { + definition = {...definition, execute: defaultDefinition}; + } + + return {...definition, name}; +}; 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/README.md b/src/entry/content/README.md index b0b163d5..c9606a50 100644 --- a/src/entry/content/README.md +++ b/src/entry/content/README.md @@ -127,16 +127,22 @@ 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. Vanilla renders DOM elements, nonempty strings, and numbers. +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 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..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"; @@ -121,6 +122,69 @@ describe("React Builder", () => { await act(() => builder.destroy()); } }); + + test.each(["text", "text", 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.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"); + 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..ab3a344b 100644 --- a/src/entry/content/adapters/react/Builder.ts +++ b/src/entry/content/adapters/react/Builder.ts @@ -1,4 +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"; @@ -32,7 +35,7 @@ export default class Builder< ? createElement(render as ContentScriptRenderReactComponent, props) : render; - return isValidElement(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 1e97a6a6..95c0741b 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 {isReactRenderValue} from "@entry/core/react"; + import {RenderNode} from "../../lifecycle/nodes"; import type {ContentScriptRenderValue} from "@typing/content"; @@ -9,14 +11,21 @@ export default class Node extends RenderNode { private root?: Root; protected render(value: ContentScriptRenderValue | undefined, target: Element): boolean { - if (!isValidElement(value)) { - return false; + // Text is also iterable, so framework-independent values are checked before React nodes. + if (isDomRenderValue(value)) { + renderDomValue(target, value); + + return true; } - this.root = createRoot(target); - this.root.render(value); + if (isReactRenderValue(value)) { + this.root = createRoot(target); + this.root.render(value); + + return true; + } - return true; + return false; } protected clear(): void { 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 7bdb034e..f109772d 100644 --- a/src/entry/content/adapters/react/definition.ts +++ b/src/entry/content/adapters/react/definition.ts @@ -1,4 +1,6 @@ -import {isValidElement} from "react"; +import {isDomRenderValue} from "@entry/core/render"; +import {isReactRenderValue} from "@entry/core/react"; + import {mergeDefinition} from "../../resolvers/definition"; import type {ContentScriptDefinition} from "@typing/content"; @@ -6,11 +8,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" || isDomRenderValue(value) || isReactRenderValue(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", "text", 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 extends RenderNode { protected render(value: ContentScriptRenderValue | 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/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/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/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", "text"], + ["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, "text"); + + expect(target.children).toHaveLength(0); + expect(target.textContent).toBe("text"); + }); + + 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/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 extends Builder { +export default class Builder extends EntrypointBuilder { protected readonly _transport: TransportBuilder; - protected _view?: ViewBuilder; + protected readonly _view: ViewBuilder; - constructor(definition: OffscreenUnresolvedDefinition) { + constructor( + definition: OffscreenUnresolvedDefinition, + viewBuilder: ViewBuilderConstructor + ) { 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 { @@ -29,14 +35,14 @@ export default class extends Builder { globalThis[OffscreenGlobalAccess] = true; await this._transport.build(); - await this._view?.build(); + await this._view.build(); this.ready(); } public async destroy(): Promise { 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 extends AbstractBuilder { +export default class TransportBuilder extends AbstractBuilder< + OffscreenOptions, + T +> { constructor(definition: OffscreenUnresolvedDefinition) { - super(definition); + // The view owns rendering; init and main receive only the offscreen options. + const {render, container, ...options} = definition; + + super(options); } protected transport(): RegisterOffscreen { 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( + definition: OffscreenUnresolvedDefinition, + viewBuilder: ViewBuilderConstructor +): 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 = ( + module: object, + name: string +): OffscreenUnresolvedDefinition => { + return mergeDefinition(module, name) as OffscreenUnresolvedDefinition; +}; 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 = ( module: object, name: string ): RelayUnresolvedDefinition => { - const {default: defaultDefinition, ...otherDefinition} = module as Record; - - let definition = otherDefinition as RelayUnresolvedDefinition; - - if (isValidTransportDefinition(defaultDefinition)) { - definition = {...definition, ...defaultDefinition}; - } else if (isValidTransportInitFunction(defaultDefinition)) { - definition = {...definition, init: defaultDefinition}; - } - - const {init, main, name: _name, ...options} = definition; - - return {name, init, main, ...options}; + return mergeDefinition(module, name) as RelayUnresolvedDefinition; }; 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 extends Builder { +export default class Builder extends EntrypointBuilder { protected readonly _transport: TransportBuilder; - protected _view?: ViewBuilder; + protected readonly _view: ViewBuilder; private readonly name: string; - constructor(definition: SandboxUnresolvedDefinition) { + constructor( + definition: SandboxUnresolvedDefinition, + viewBuilder: ViewBuilderConstructor + ) { 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 { @@ -39,14 +41,14 @@ export default class extends Builder { globalThis[SandboxGlobalAccess] = true; await this._transport.build(); - await this._view?.build(); + await this._view.build(); this.ready(); } public async destroy(): Promise { 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 extends AbstractBuilder { +export default class TransportBuilder extends AbstractBuilder< + SandboxOptions, + T +> { constructor(definition: SandboxUnresolvedDefinition) { - super(definition); + // The view owns rendering; init and main receive only the sandbox options. + const {render, container, ...options} = definition; + + super(options); } protected transport(): RegisterSandbox { 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( + definition: SandboxUnresolvedDefinition, + viewBuilder: ViewBuilderConstructor +): 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 = ( + module: object, + name: string +): SandboxUnresolvedDefinition => { + return mergeDefinition(module, name) as SandboxUnresolvedDefinition; +}; 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 = ( + module: object, + name: string +): ServiceUnresolvedDefinition => { + return mergeDefinition(module, name) as ServiceUnresolvedDefinition; +}; 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 deleted file mode 100644 index 38267da5..00000000 --- a/src/entry/transport/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./resolvers"; 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..a603f6d7 100644 --- a/src/entry/transport/resolvers/definition.ts +++ b/src/entry/transport/resolvers/definition.ts @@ -1,17 +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 = ( - definition: any -): definition is TransportDefinition & RelayDefinition & OffscreenDefinition => { - return definition && typeof definition === "object" && definition.constructor === Object; +const isDefinitionObject = ( + value: unknown +): value is TransportUnresolvedDefinition => { + return value !== null && typeof value === "object" && value.constructor === Object; }; export const isValidTransportInitFunction = ( @@ -22,10 +20,33 @@ export const isValidTransportInitFunction = ( main: any -): main is TransportMainHandler & RelayMainHandler & OffscreenMainHandler => { +): main is TransportMainHandler => { return main && typeof main === "function"; }; 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 => { + const {default: defaultDefinition, ...namedDefinition} = module as Record; + + let definition = namedDefinition as TransportUnresolvedDefinition; + + if (isDefinitionObject(defaultDefinition)) { + definition = {...definition, ...defaultDefinition}; + } else if (isValidTransportInitFunction(defaultDefinition)) { + definition = {...definition, init: defaultDefinition}; + } + + const {init, main, name: _, ...options} = definition; + + return {name, init, main, ...options}; +}; 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"; 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 extends EntrypointBuilder implements ViewBuilder { + protected readonly definition: ViewResolvedDefinition; + + private container?: Element; + + protected constructor(definition: ViewDefinition) { + super(); + + this.definition = { + ...definition, + container: this.resolveContainer(definition.container), + render: this.resolveRender(definition.render), + }; + } + + protected abstract mount(container: Element, value: ViewRenderValue): void; + + protected unmount(): void { + // Removing the container is enough unless the renderer owns state inside it. + } + + protected resolveContainer( + container?: ViewContainerTag | ViewContainerOptions | ViewContainerFactory + ): ViewContainerCreator { + return viewContainerResolver(container); + } + + protected resolveRender(render?: ViewRenderValue | ViewRenderHandler): ViewRenderHandler | 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 { + 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 { + if (!this.container) { + return; + } + + try { + this.unmount(); + } finally { + this.container.remove(); + this.container = undefined; + } + } +} diff --git a/src/entry/view/README.md b/src/entry/view/README.md new file mode 100644 index 00000000..41b8e39e --- /dev/null +++ b/src/entry/view/README.md @@ -0,0 +1,137 @@ +# 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 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 + +[`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 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 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. 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 + +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, 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 + 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. 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..ec9973b5 --- /dev/null +++ b/src/entry/view/adapters/react/Builder.test.ts @@ -0,0 +1,117 @@ +import {act} from "@testing-library/react"; +import {createElement, Fragment, useEffect} from "react"; +import {createPortal} from "react-dom"; + +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({ + 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("

Title: React

"); + + await act(() => builder.destroy()); + }); + + test("renders a React element value", async () => { + const builder = new Builder({render: createElement("strong", null, "element")}); + + await act(() => builder.build()); + + expect(document.body.firstElementChild?.innerHTML).toBe("element"); + + 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({render: Component}); + + await act(() => builder.build()); + await act(() => builder.destroy()); + + expect(events).toEqual(["mounted", "cleanup, connected: true"]); + expect(document.body.children).toHaveLength(0); + }); + + test.each([ + ["an HTML string", "bold"], + ["zero", 0], + ])("renders %s as text", async (_, value) => { + await act(() => new Builder({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({render: element}).build()); + + 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()); + + 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 171fdce6..ddd1800b 100644 --- a/src/entry/view/adapters/react/Builder.ts +++ b/src/entry/view/adapters/react/Builder.ts @@ -1,54 +1,46 @@ -import {isValidElement} from "react"; +import {createElement} from "react"; import {createRoot, Root} from "react-dom/client"; -import Builder from "../../core/Builder"; +import {isDomRenderValue, renderDomValue} from "@entry/core/render"; +import {isReactRenderValue} from "@entry/core/react"; -import {viewReactRenderResolver} from "./resolvers/render"; +import ViewBuilder from "../../Builder"; -import {ViewConfig, ViewDefinition, ViewRenderHandler, ViewRenderValue} from "@typing/view"; +import {ViewConfig, ViewDefinition, ViewRenderHandler, ViewRenderReactComponent, ViewRenderValue} from "@typing/view"; -export default class extends Builder { +export default class Builder extends ViewBuilder { protected root?: Root; - protected container?: Element; - public constructor(definition: ViewDefinition) { super(definition); } - protected resolveRender(render?: ViewRenderValue): ViewRenderHandler { - return viewReactRenderResolver(render); - } - - public async build(): Promise { - await super.build(); - - const props = this.getProps(); - - const element = await this.definition.render(props); - - if (!isValidElement(element)) { + protected resolveRender(render?: ViewRenderValue | ViewRenderHandler): ViewRenderHandler | 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, props) : render; - if (!this.container) { - return; - } - - document.body.prepend(this.container); - - this.root = createRoot(this.container); + return isDomRenderValue(value) || isReactRenderValue(value) ? value : undefined; + }; + } - this.root.render(element); + protected mount(container: Element, value: ViewRenderValue): void { + // 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); + } } - public async destroy(): Promise { + 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..e1d144be --- /dev/null +++ b/src/entry/view/adapters/react/definition.test.ts @@ -0,0 +1,42 @@ +import {createElement} from "react"; +import {createPortal} from "react-dom"; + +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.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"}; + + 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..877232fc --- /dev/null +++ b/src/entry/view/adapters/react/definition.ts @@ -0,0 +1,13 @@ +import {isDomRenderValue} from "@entry/core/render"; +import {isReactRenderValue} from "@entry/core/react"; + +import {mergeDefinition} from "../../resolvers/definition"; + +import type {ViewConfig, ViewDefinition} from "@typing/view"; + +export const resolveDefinition = (module: object): ViewDefinition => { + return mergeDefinition( + module, + value => typeof value === "function" || isDomRenderValue(value) || isReactRenderValue(value) + ); +}; 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 = - (render?: ViewRenderValue): ViewRenderHandler => - async (props): Promise> => { - 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..eb8ab57a --- /dev/null +++ b/src/entry/view/adapters/vanilla/Builder.test.ts @@ -0,0 +1,72 @@ +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({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({title: "Props", template: "page.html", render}).build(); + + expect(render).toHaveBeenCalledWith({title: "Props", template: "page.html"}); + }); + + test.each([ + ["an HTML string", "bold"], + ["zero", 0], + ])("renders %s as text", async (_, value) => { + await new Builder({render: value}).build(); + + expect(document.body.firstElementChild?.textContent).toBe(String(value)); + expect(document.body.firstElementChild?.children).toHaveLength(0); + }); + + test("creates the container from the container option", async () => { + await new Builder({container: {tagName: "section", id: "root"}, render: "content"}).build(); + + expect(document.body.firstElementChild?.outerHTML).toBe('
content
'); + }); + + test.each([ + ["without render", undefined], + ["for an unsupported value", () => ({}) as unknown as string], + ])("creates no container %s", async (_, render) => { + await new Builder({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({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..1c0beeb2 100644 --- a/src/entry/view/adapters/vanilla/Builder.ts +++ b/src/entry/view/adapters/vanilla/Builder.ts @@ -1,44 +1,29 @@ -import Builder from "../../core/Builder"; +import {isDomRenderValue, renderDomValue} from "@entry/core/render"; -import {ViewConfig, ViewDefinition} from "@typing/view"; +import ViewBuilder from "../../Builder"; -export default class extends Builder { - protected container?: Element; +import {ViewConfig, ViewDefinition, ViewRenderHandler, ViewRenderValue} from "@typing/view"; +export default class Builder extends ViewBuilder { public constructor(definition: ViewDefinition) { super(definition); } - public async build(): Promise { - await super.build(); - - const props = this.getProps(); - - const content = await this.definition.render(props); - - if (!content) { - return; - } - - this.container = await this.definition.container(props); - - if (!this.container) { + protected resolveRender(render?: ViewRenderValue | ViewRenderHandler): ViewRenderHandler | undefined { + if (render === undefined) { return; } - if (content instanceof Element) { - this.container.appendChild(content); - } else if (typeof content === "string" || typeof content === "number") { - this.container.innerHTML = content.toString(); - } else { - return; - } + return async props => { + const value = typeof render === "function" ? await render(props) : render; - document.body.prepend(this.container); + return isDomRenderValue(value) ? value : undefined; + }; } - public async destroy(): Promise { - this.container?.remove(); - this.container = undefined; + protected mount(container: Element, value: ViewRenderValue): void { + if (isDomRenderValue(value)) { + renderDomValue(container, value); + } } } 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", "

Hello

"], + ["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..a3b679a0 --- /dev/null +++ b/src/entry/view/adapters/vanilla/definition.ts @@ -0,0 +1,9 @@ +import {isDomRenderValue} from "@entry/core/render"; + +import {mergeDefinition} from "../../resolvers/definition"; + +import type {ViewConfig, ViewDefinition} from "@typing/view"; + +export const resolveDefinition = (module: object): ViewDefinition => { + return mergeDefinition(module, value => typeof value === "function" || isDomRenderValue(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/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 extends Builder implements ViewBuilder { - protected readonly definition: ViewResolvedDefinition; - - protected constructor(definition: ViewDefinition) { - super(); - - this.definition = { - ...definition, - container: this.resolveContainer(definition.container), - render: this.resolveRender(definition.render), - }; - } - - protected resolveContainer( - container?: ViewContainerTag | ViewContainerOptions | ViewContainerFactory - ): ViewContainerCreator { - return viewContainerResolver(container); - } - - protected resolveRender(render?: ViewRenderValue | ViewRenderHandler): ViewRenderHandler { - return viewRenderResolver(render); - } - - protected getProps(): T { - const {render, container, ...props} = this.definition; - - return props as T; - } - - public async build(): Promise { - 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 = ( - definition: any -): definition is ViewDefinition => { - return ( - definition && typeof definition === "object" && definition.constructor === Object && !("$$typeof" in definition) - ); -}; - -export const isValidViewDefinitionRenderValue = ( - definition: any -): definition is ViewRenderValue | ViewRenderHandler => { - 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 = - (render?: ViewRenderValue | ViewRenderHandler): ViewRenderHandler => - async (props): Promise> => { - 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 => { + 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 => { + const {default: defaultDefinition, ...namedDefinition} = module as Record; + const definition = namedDefinition as ViewDefinition; + + if (isRenderInput(defaultDefinition)) { + return {...definition, render: defaultDefinition} as ViewDefinition; + } + + if (isDefinitionObject(defaultDefinition)) { + return {...definition, ...defaultDefinition}; + } + + return definition; +}; 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/main/index.ts b/src/main/index.ts index c2d909a8..c92906e3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,7 +11,9 @@ export * from "./icon"; export * from "./manifest"; 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/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/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/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/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/entrypoint.ts b/src/types/entrypoint.ts index 340a5071..4e5c3ecd 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 { @@ -146,6 +149,8 @@ export interface EntrypointNameGenerator { file(file: EntrypointFile): string; + derive(file: EntrypointFile): string; + likely(name?: string): boolean; has(name: string): boolean; 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>( - key: K, - ...args: LocaleSubstitutionArgs -): string; - -declare function choice>( - key: K, - count: number, - ...args: LocaleSubstitutionArgs -): 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/src/types/manifest.ts b/src/types/manifest.ts index 0be0103b..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 = Omit & { manifest_version: ManifestVersion; @@ -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/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 = ( ) => Awaiter; export interface OffscreenDefinition - extends TransportDefinition, OffscreenEntrypointOptions { + extends + TransportDefinition, + OffscreenEntrypointOptions, + ViewRenderDefinition { main?: OffscreenMainHandler; } 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/override.ts b/src/types/override.ts new file mode 100644 index 00000000..d04cba64 --- /dev/null +++ b/src/types/override.ts @@ -0,0 +1,38 @@ +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`. + * 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 = PermissionsOptions & 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/permissions.ts b/src/types/permissions.ts new file mode 100644 index 00000000..e8ba2cd0 --- /dev/null +++ b/src/types/permissions.ts @@ -0,0 +1,50 @@ +import type { + ManifestHostPermissions, + ManifestOptionalPermission, + ManifestOptionalPermissions, + ManifestPermission, + ManifestPermissions, +} from "@typing/manifest"; + +/** + * 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: ManifestPermissions; + optionalPermissions: ManifestOptionalPermissions; + hostPermissions: ManifestHostPermissions; + optionalHostPermissions: ManifestHostPermissions; +} 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/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/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 = ( ) => Awaiter; export interface SandboxDefinition - extends TransportDefinition, SandboxEntrypointOptions { + extends + TransportDefinition, + SandboxEntrypointOptions, + ViewRenderDefinition { main?: SandboxMainHandler; } 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 = 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/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 = FC; + +export type ViewReactRenderValue = + | Exclude> + | ViewRenderReactComponent; 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.ts b/src/types/view/common.ts similarity index 60% rename from src/types/view.ts rename to src/types/view/common.ts index 19720b17..ebb104cf 100644 --- a/src/types/view.ts +++ b/src/types/view/common.ts @@ -1,4 +1,3 @@ -import type {FC, ReactNode} from "react"; import type {Optional} from "utility-types"; import type {Options as HtmlOptions} from "html-rspack-tags-plugin"; @@ -15,11 +14,6 @@ export type ViewOptions = ViewConfig & EntrypointOptions & ExcludeFunctionsFromP export type ViewEntrypointOptions = ViewOptions; -// Render -export type ViewRenderReactComponent = FC; -export type ViewRenderValue = string | Element | ReactNode | ViewRenderReactComponent; -export type ViewRenderHandler = (props: T) => Awaiter>; - // Container export type ViewContainerTag = Exclude; @@ -35,16 +29,5 @@ export type ViewContainerFactory = ( ) => Awaiter; export type ViewContainerCreator = (props: T) => Awaiter; -// Definition -export interface ViewDefinition extends ViewOptions { - render?: ViewRenderValue | ViewRenderHandler; - container?: ViewContainerTag | ViewContainerOptions | ViewContainerFactory; -} - -export interface ViewResolvedDefinition extends ViewDefinition { - render: ViewRenderHandler; - container: ViewContainerCreator; -} - // 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 { + render?: ViewRenderValue | ViewRenderHandler; + container?: ViewContainerTag | ViewContainerOptions | ViewContainerFactory; +} + +export interface ViewDefinition extends ViewOptions, ViewRenderDefinition {} + +export interface ViewResolvedDefinition extends ViewDefinition { + render?: ViewRenderHandler; + container: ViewContainerCreator; +} + +/** A renderer adapter's builder class, selected by the entrypoint filename and injected by the build. */ +export type ViewBuilderConstructor = new (definition: ViewDefinition) => 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 = ViewVanillaRenderValue | ViewReactRenderValue; + +/** A view document renders once, so its handler may await data before returning the value. */ +export type ViewRenderHandler = (props: T) => Awaiter>; diff --git a/tests/integration/README.md b/tests/integration/README.md index f338a05e..9104ae0f 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -22,6 +22,14 @@ tests/integration/ โ”‚ โ”œโ”€โ”€ options/ โ”‚ โ”‚ โ”œโ”€โ”€ options.integration.test.ts โ”‚ โ”‚ โ””โ”€โ”€ embedded/ +โ”‚ โ”œโ”€โ”€ override/ +โ”‚ โ”‚ โ”œโ”€โ”€ override.integration.test.ts +โ”‚ โ”‚ โ”œโ”€โ”€ newtab/ +โ”‚ โ”‚ โ”œโ”€โ”€ bookmarks/ +โ”‚ โ”‚ โ””โ”€โ”€ history/ +โ”‚ โ”œโ”€โ”€ permissions/ +โ”‚ โ”‚ โ”œโ”€โ”€ permissions.integration.test.ts +โ”‚ โ”‚ โ””โ”€โ”€ views/ โ”‚ โ””โ”€โ”€ โ€ฆ โ”œโ”€โ”€ browser/ โ”‚ โ”œโ”€โ”€ content/ @@ -44,6 +52,8 @@ tests/integration/ โ”‚ โ”œโ”€โ”€ locale/ โ”‚ โ”œโ”€โ”€ offscreen/ โ”‚ โ”œโ”€โ”€ options/ +โ”‚ โ”œโ”€โ”€ override/ +โ”‚ โ”œโ”€โ”€ view/ โ”‚ โ””โ”€โ”€ utils/ โ”‚ โ”œโ”€โ”€ BidiClient.ts โ”‚ โ”œโ”€โ”€ CdpClient.ts @@ -55,9 +65,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 @@ -116,9 +128,14 @@ 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/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. +- `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/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 `"โ€ฆ"` with the React view, Vanilla view and React content adapters. Each shows the tags as text; no `` 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 => { + 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 element may appear. + const readText = (evaluate: (expression: string) => Promise) => { + 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: "React text", + markup: false, + }); + + await expect(readText(await open(`chrome-extension://${extensionId}/markup.html`))).resolves.toEqual({ + title: "Vanilla text", + text: "Vanilla text", + 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: "React content text", 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 @@ + +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" + } +} 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..8ba084c7 --- /dev/null +++ b/tests/integration/build/override/bookmarks/src/bookmarks.ts @@ -0,0 +1,8 @@ +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/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..6ea5a4c5 --- /dev/null +++ b/tests/integration/build/override/history/src/history.ts @@ -0,0 +1,8 @@ +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/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..b272ac57 --- /dev/null +++ b/tests/integration/build/override/newtab/src/newtab.ts @@ -0,0 +1,8 @@ +import {defineNewtab} from "adnbn"; + +export default defineNewtab({ + title: "Custom new tab", + csp: {sources: {connect: ["https://newtab.example.com"]}}, + permissions: ["search"], + 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..200e8171 --- /dev/null +++ b/tests/integration/build/override/override.integration.test.ts @@ -0,0 +1,122 @@ +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", 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`; + + 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(manifest.permissions).toContain(permission); + 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(manifest.permissions ?? []).not.toContain(permission); + 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 and its permission without the unsupported 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(manifest.permissions).toEqual(["search"]); + expect(await readdir(extensionDir)).not.toContain("history.html"); + } finally { + await fixture.dispose(); + } + }); +}); 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" +} 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} />, [<span />, "text"], + createPortal(<span />, document.body), + 42n, ]; const values: ContentScriptRenderValue[] = [...vanillaValues, ...reactValues]; 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/fixtures/view/definition.tsx b/tests/integration/types/fixtures/view/definition.tsx new file mode 100644 index 00000000..24d7df7e --- /dev/null +++ b/tests/integration/types/fixtures/view/definition.tsx @@ -0,0 +1,73 @@ +import {createPortal} from "react-dom"; +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" />, + <>fragment</>, + [<span key="span" />, "text"], + createPortal(<span />, document.body), + 42n, +]; + +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/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 + ); +}); 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 + ); +});