From b9888a859e2f1c6aa3336e6ea8481cf2b4d15ca4 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:02:49 +0300 Subject: [PATCH 1/4] feat(relay)!: support multi-frame calls and unified results Add frame and document targets, Any/All modes, and per-target outcomes. Centralize permission handling and align public exports and generated types. Protect raw virtual templates from alias rewrites and expand regression tests. Document the Relay runtime contract and user-gesture constraints. BREAKING CHANGE: Relay calls use mutually exclusive frame/document targets. Remote Relay types are exported from adnbn, with entrypoint internals under adnbn/entry/relay. DeepAsyncProxy is replaced by RpcAsyncProxy. --- package-lock.json | 38 +- package.json | 7 +- src/cli/entrypoint/file/injectors/core.ts | 11 +- .../entrypoint/finder/LocaleFinder.test.ts | 28 +- src/cli/entrypoint/parser/ContentParser.ts | 6 +- src/cli/entrypoint/parser/RelayParser.test.ts | 30 + src/cli/entrypoint/parser/RelayParser.ts | 3 +- .../relay/options/all-frames/relay.ts | 11 + src/cli/plugins/content/ContentDriver.ts | 58 +- .../plugins/content/ContentManager.test.ts | 68 +++ src/cli/plugins/content/ContentManager.ts | 67 ++- src/cli/plugins/content/Relay.ts | 29 +- .../plugins/content/RelayDeclaration.test.ts | 148 +++++ src/cli/plugins/content/RelayDeclaration.ts | 7 - src/cli/plugins/content/RelayDriver.test.ts | 155 +++++ src/cli/plugins/content/RelayDriver.ts | 58 ++ src/cli/plugins/content/index.ts | 15 +- .../tests/fixtures/relay-types/consumer.ts | 86 +++ .../tests/fixtures/relay-types/virtual.ts | 30 + src/cli/plugins/content/types.ts | 5 + src/cli/virtual/index.ts | 2 +- src/cli/virtual/relay.ts | 11 +- src/cli/virtual/virtual.d.ts | 199 ++----- src/cli/virtual/virtual.test.ts | 118 ++++ src/entry/relay/index.ts | 1 + src/main/relay.ts | 58 +- src/message/error.test.ts | 10 +- src/message/error.ts | 28 + src/message/index.ts | 3 +- src/message/providers/Message.test.ts | 52 +- src/message/providers/Message.ts | 49 +- src/offscreen/providers/Offscreen.test.ts | 4 +- src/offscreen/providers/ProxyOffscreen.ts | 4 +- src/relay/README.md | 299 ++++++++++ src/relay/RelayPermission.test.ts | 74 +++ src/relay/RelayPermission.ts | 50 +- src/relay/adapters/RelayAdapter.ts | 20 + src/relay/adapters/RelayMessagingAdapter.ts | 122 ++++ src/relay/adapters/RelayScriptingAdapter.ts | 256 ++++++++ src/relay/discovery/RelayDiscovery.test.ts | 94 +++ src/relay/discovery/RelayDiscovery.ts | 76 +++ src/relay/index.ts | 8 +- src/relay/providers/ProxyRelay.ts | 179 +++--- src/relay/providers/RegisterRelay.ts | 4 +- src/relay/providers/Relay.test.ts | 560 ++++++++++++++++-- src/relay/result.ts | 101 ++++ src/sandbox/index.ts | 4 +- src/sandbox/providers/ProxySandbox.ts | 4 +- .../providers/ProxyService.context.test.ts | 24 +- src/service/providers/ProxyService.ts | 4 +- src/service/providers/Service.test.ts | 4 +- src/transport/ProxyTransport.ts | 10 +- src/transport/RegisterTransport.test.ts | 8 +- src/transport/index.ts | 6 +- src/types/helpers.ts | 10 - src/types/relay.ts | 167 +++++- src/types/rpc.ts | 9 + src/types/transport.ts | 5 +- tests/jest.message.setup.ts | 48 +- tests/jest.modules.setup.ts | 7 + tests/jest.relay.setup.ts | 32 +- tsconfig.build.json | 5 + tsconfig.json | 35 ++ tsconfig.tests.json | 18 + tsup.config.ts | 5 +- 65 files changed, 3164 insertions(+), 483 deletions(-) create mode 100644 src/cli/entrypoint/parser/RelayParser.test.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/relay/options/all-frames/relay.ts create mode 100644 src/cli/plugins/content/ContentManager.test.ts create mode 100644 src/cli/plugins/content/RelayDeclaration.test.ts create mode 100644 src/cli/plugins/content/RelayDriver.test.ts create mode 100644 src/cli/plugins/content/RelayDriver.ts create mode 100644 src/cli/plugins/content/tests/fixtures/relay-types/consumer.ts create mode 100644 src/cli/plugins/content/tests/fixtures/relay-types/virtual.ts create mode 100644 src/cli/virtual/virtual.test.ts create mode 100644 src/relay/README.md create mode 100644 src/relay/RelayPermission.test.ts create mode 100644 src/relay/adapters/RelayAdapter.ts create mode 100644 src/relay/adapters/RelayMessagingAdapter.ts create mode 100644 src/relay/adapters/RelayScriptingAdapter.ts create mode 100644 src/relay/discovery/RelayDiscovery.test.ts create mode 100644 src/relay/discovery/RelayDiscovery.ts create mode 100644 src/relay/result.ts create mode 100644 src/types/rpc.ts create mode 100644 tsconfig.tests.json diff --git a/package-lock.json b/package-lock.json index 4ba3f0d2..0f8e5968 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@addon-core/browser": "^0.7.1", - "@addon-core/inject-script": "^0.3.1", + "@addon-core/browser": "^0.7.2", + "@addon-core/inject-script": "^0.5.0", "@addon-core/storage": "^0.7.0", "@rsdoctor/rspack-plugin": "^1.5.1", "@rspack/cli": "^1.7.5", @@ -118,31 +118,31 @@ "license": "MIT" }, "node_modules/@addon-core/browser": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@addon-core/browser/-/browser-0.7.1.tgz", - "integrity": "sha512-HLfmofwfx2oU31DrHHz0RZPwE2xh/ra7goOE7bCUZTObIptDGnlxldmiODdswra9D4b5FMQi5UuOK9TRGTAI6g==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@addon-core/browser/-/browser-0.7.2.tgz", + "integrity": "sha512-noDIPQktJOl7HVFUBZONL1iAVK4Spq1K+JgtN3OTsNPSvkgogrWUvpjq00p99R02y+YWORR70gu3tR+NDh09vw==", "license": "MIT", - "peerDependencies": { - "@types/chrome": "*" + "dependencies": { + "@types/chrome": "^0.2.2" } }, - "node_modules/@addon-core/inject-script": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@addon-core/inject-script/-/inject-script-0.3.1.tgz", - "integrity": "sha512-Y2XsWczY7+EKdHaZyQ2T7wRDZa5mcu/xT8XUM4tGCiCTYNxc9cOVR9x0nGzuX9CWzai4KXO4Akw8b+HEl/31Og==", + "node_modules/@addon-core/browser/node_modules/@types/chrome": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.2.7.tgz", + "integrity": "sha512-9kjBozQ+jyDVt1eai3VZqjHDTN95JCuRmbRHOryOltBJmzrTmUw/9r/DznmjRaQ8WlyIfeCA4WNzoj0IvormJA==", "license": "MIT", "dependencies": { - "@addon-core/browser": "^0.2.1", - "nanoid": "^5.1.5" + "@types/filesystem": "*", + "@types/har-format": "*" } }, - "node_modules/@addon-core/inject-script/node_modules/@addon-core/browser": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@addon-core/browser/-/browser-0.2.3.tgz", - "integrity": "sha512-q0wIKy682I8WxssCB5YmKzZzx1kD1wXur5ddCFD9zupZGmY7k+F7+w3gPh983oCGLDpTEk72s19cUfdQppIXGg==", + "node_modules/@addon-core/inject-script": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@addon-core/inject-script/-/inject-script-0.5.0.tgz", + "integrity": "sha512-YoeMjLwZQ584jbv1rcPLgH0gchYbAXoodmSUZsX24E8MXvPNLUV4twhd6+R3WFf99wnuMafkigm0YaS35p6cNw==", "license": "MIT", - "peerDependencies": { - "@types/chrome": "*" + "dependencies": { + "@addon-core/browser": "^0.7.2" } }, "node_modules/@addon-core/storage": { diff --git a/package.json b/package.json index e119f4ef..1073dd19 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,8 @@ "prepublishOnly": "npm run build", "build": "tsup && tsc -p tsconfig.build.json && node ./scripts/copy-dts.js && tsc-alias -p tsconfig.build.json -f -fe .js", "format": "prettier --write .", - "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck": "tsc -p tsconfig.json --noEmit && npm run typecheck:tests", + "typecheck:tests": "tsc -p tsconfig.tests.json --noEmit", "check:node-version": "node ./scripts/check-node-version.js", "test": "npm run build && jest", "test:ci": "npm run test -- --ci --passWithNoTests --coverage", @@ -126,8 +127,8 @@ "release:preview": "release-it --no-github.release --no-npm.publish --no-git.tag --ci" }, "dependencies": { - "@addon-core/browser": "^0.7.1", - "@addon-core/inject-script": "^0.3.1", + "@addon-core/browser": "^0.7.2", + "@addon-core/inject-script": "^0.5.0", "@addon-core/storage": "^0.7.0", "@rsdoctor/rspack-plugin": "^1.5.1", "@rspack/cli": "^1.7.5", diff --git a/src/cli/entrypoint/file/injectors/core.ts b/src/cli/entrypoint/file/injectors/core.ts index 8fe447af..15e664f5 100644 --- a/src/cli/entrypoint/file/injectors/core.ts +++ b/src/cli/entrypoint/file/injectors/core.ts @@ -1,6 +1,6 @@ import {Command, Mode, PackageName} from "@typing/app"; import {Browser} from "@typing/browser"; -import {RelayMethod} from "@typing/relay"; +import {RelayAllFrames, RelayMethod} from "@typing/relay"; import {ContentScriptAppend, ContentScriptDeclarative, ContentScriptMarker} from "@typing/content"; import {OffscreenReason} from "@typing/offscreen"; import {SandboxAllow, SandboxSource} from "@typing/sandbox"; @@ -83,6 +83,15 @@ export default (): Injector[] => { }); }); + Object.entries(RelayAllFrames).forEach(([key, value]) => { + resolvers.push({ + from: PackageName, + target: "RelayAllFrames", + name: key, + value, + }); + }); + Object.entries(OffscreenReason).forEach(([key, value]) => { resolvers.push({ from: PackageName, diff --git a/src/cli/entrypoint/finder/LocaleFinder.test.ts b/src/cli/entrypoint/finder/LocaleFinder.test.ts index 15e33e39..431ed9aa 100644 --- a/src/cli/entrypoint/finder/LocaleFinder.test.ts +++ b/src/cli/entrypoint/finder/LocaleFinder.test.ts @@ -3,7 +3,7 @@ import path from "path"; import LocaleFinder from "./LocaleFinder"; import {ReadonlyConfig} from "@typing/config"; -import {Command} from "@typing/app"; +import {Command, Mode} from "@typing/app"; import {Browser} from "@typing/browser"; import {Language} from "@typing/locale"; @@ -27,7 +27,7 @@ const makeFinder = (fixture: string, config: Partial = {}): Test lang: Language.English, localeDir: "locales", mergeLocales: true, - mode: "production", + mode: Mode.Production, plugins: [ { name: root, @@ -52,7 +52,7 @@ const makeLayeredFinder = (config: Partial = {}): TestLocaleFind lang: Language.English, localeDir: "locales", mergeLocales: true, - mode: "production", + mode: Mode.Production, plugins: [], rootDir: path.join(root, "project"), sharedDir: "shared", @@ -256,24 +256,10 @@ describe("LocaleFinder", () => { test("rejects ambiguous locale files in the same layer", async () => { const root = path.join(fixtures, "duplicate-layer"); - const config = { - app: "app", - appSrcDir: ".", - appsDir: "apps", - browser: Browser.Chrome, - command: Command.Build, - lang: Language.English, - localeDir: "locales", - mergeLocales: true, - mode: "production", - plugins: [], - rootDir: root, - sharedDir: "shared", - srcDir: "src", - } as ReadonlyConfig; - const finder = new TestLocaleFinder(config); - - config.plugins.push({ + const plugins: ReadonlyConfig["plugins"] = []; + const finder = makeFinder("duplicate-layer", {plugins}); + + plugins.push({ name: "adnbn:locale", locale: () => finder.files(), }); diff --git a/src/cli/entrypoint/parser/ContentParser.ts b/src/cli/entrypoint/parser/ContentParser.ts index b6296c7e..7c729a66 100644 --- a/src/cli/entrypoint/parser/ContentParser.ts +++ b/src/cli/entrypoint/parser/ContentParser.ts @@ -8,11 +8,9 @@ import { ContentScriptMarker, ContentScriptMatches, } from "@typing/content"; -import {EntrypointFile} from "@typing/entrypoint"; +import {EntrypointFile, EntrypointOptions} from "@typing/entrypoint"; -export default class< - O extends ContentScriptEntrypointOptions = ContentScriptEntrypointOptions, -> extends AbstractParser { +export default class extends AbstractParser { protected definition(): string | string[] { return ["defineContentScript", "defineContentScriptAppend"]; } diff --git a/src/cli/entrypoint/parser/RelayParser.test.ts b/src/cli/entrypoint/parser/RelayParser.test.ts new file mode 100644 index 00000000..abb8c037 --- /dev/null +++ b/src/cli/entrypoint/parser/RelayParser.test.ts @@ -0,0 +1,30 @@ +import path from "path"; + +import RelayParser from "./RelayParser"; + +import type {ReadonlyConfig} from "@typing/config"; + +const rootDir = path.resolve(__dirname, "../../../.."); +const fixtures = path.resolve(__dirname, "tests", "fixtures", "relay"); + +const parser = new RelayParser({rootDir} as ReadonlyConfig); + +const file = (...parts: string[]) => { + const filename = path.join(fixtures, ...parts); + + return { + file: filename, + import: filename, + }; +}; + +describe("RelayParser", () => { + test("parses the all-frame response capability from a real entrypoint file", () => { + expect(parser.options(file("options", "all-frames", "relay.ts"))).toEqual( + expect.objectContaining({ + method: "messaging", + allFrames: "all", + }) + ); + }); +}); diff --git a/src/cli/entrypoint/parser/RelayParser.ts b/src/cli/entrypoint/parser/RelayParser.ts index 5bb7947e..903914c2 100644 --- a/src/cli/entrypoint/parser/RelayParser.ts +++ b/src/cli/entrypoint/parser/RelayParser.ts @@ -2,7 +2,7 @@ import z from "zod"; import ContentParser from "./ContentParser"; -import {RelayEntrypointOptions, RelayMethod} from "@typing/relay"; +import {RelayAllFrames, RelayEntrypointOptions, RelayMethod} from "@typing/relay"; import {EntrypointFile} from "@typing/entrypoint"; import {ContentScriptDeclarative} from "@typing/content"; @@ -17,6 +17,7 @@ export default class extends ContentParser { protected schema(): typeof this.CommonPropertiesSchema { return super.schema().extend({ + allFrames: z.union([z.boolean(), z.nativeEnum(RelayAllFrames)]).optional(), name: z .string() .trim() diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/options/all-frames/relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/options/all-frames/relay.ts new file mode 100644 index 00000000..a275f5e8 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/all-frames/relay.ts @@ -0,0 +1,11 @@ +import {defineRelay, RelayAllFrames, RelayMethod} from "adnbn"; + +export default defineRelay({ + method: RelayMethod.Messaging, + allFrames: RelayAllFrames.All, + init() { + return { + scan: () => true, + }; + }, +}); diff --git a/src/cli/plugins/content/ContentDriver.ts b/src/cli/plugins/content/ContentDriver.ts index f6d3e955..1704322d 100644 --- a/src/cli/plugins/content/ContentDriver.ts +++ b/src/cli/plugins/content/ContentDriver.ts @@ -1,37 +1,75 @@ -import {ContentDriver, ContentItems} from "./types"; +import {ContentDriver as ContentDriverContract, ContentItems} from "./types"; import {AbstractPluginFinder, InlineNameGenerator} from "@cli/entrypoint"; import {ContentScriptEntrypointOptions} from "@typing/content"; -import {EntrypointNameGenerator} from "@typing/entrypoint"; +import {EntrypointFile, EntrypointNameGenerator, EntrypointOptions} from "@typing/entrypoint"; +import {ManifestOptionalPermissions, ManifestPermissions} from "@typing/manifest"; -export default class implements ContentDriver { - protected _items?: ContentItems; +/** + * OutputOptions stays first so existing ContentDriver consumers keep their original meaning. + * Drivers with a different input shape must override transform(). + */ +export default class< + OutputOptions extends ContentScriptEntrypointOptions = ContentScriptEntrypointOptions, + InputOptions extends EntrypointOptions = OutputOptions, +> implements ContentDriverContract { + protected _options?: Map; + + protected _items?: ContentItems; + + protected _permissions?: Promise<[ManifestPermissions, ManifestOptionalPermissions]>; protected readonly itemNames: EntrypointNameGenerator; - constructor(protected readonly finder: AbstractPluginFinder) { + constructor(protected readonly finder: AbstractPluginFinder) { this.itemNames = new InlineNameGenerator(this.finder.type()); } - protected async getItems(): Promise> { - const items: ContentItems = new Map(); + protected transform(options: InputOptions): OutputOptions { + return options as unknown as OutputOptions; + } - const files = await this.finder.plugin().options(); + protected async getOptions(): Promise> { + return (this._options ??= await this.finder.plugin().options()); + } + + protected async getItems(): Promise> { + const items: ContentItems = new Map(); + + const files = await this.getOptions(); for (const [file, options] of files) { - items.set(this.itemNames.file(file), {file, options}); + items.set(this.itemNames.file(file), {file, options: this.transform(options)}); } return items; } - public async items(): Promise> { + public async items(): Promise> { return (this._items ??= await this.getItems()); } + protected async calculatePermissions(): Promise<[ManifestPermissions, ManifestOptionalPermissions]> { + return [new Set(), new Set()]; + } + + protected getPermissions(): Promise<[ManifestPermissions, ManifestOptionalPermissions]> { + return (this._permissions ??= this.calculatePermissions()); + } + + public async permissions(): Promise { + return (await this.getPermissions())[0]; + } + + public async optionalPermissions(): Promise { + return (await this.getPermissions())[1]; + } + public clear(): this { + this._options = undefined; this._items = undefined; + this._permissions = undefined; this.itemNames.reset(); diff --git a/src/cli/plugins/content/ContentManager.test.ts b/src/cli/plugins/content/ContentManager.test.ts new file mode 100644 index 00000000..482bb527 --- /dev/null +++ b/src/cli/plugins/content/ContentManager.test.ts @@ -0,0 +1,68 @@ +import ContentManager from "./ContentManager"; + +import type {ContentDriver, ContentItems, ContentProvider} from "./types"; + +import type {ReadonlyConfig} from "@typing/config"; +import type {ContentScriptEntrypointOptions} from "@typing/content"; +import type {EntrypointFile} from "@typing/entrypoint"; +import type {ManifestOptionalPermissions, ManifestPermissions} from "@typing/manifest"; + +class DriverFixture implements ContentDriver { + public constructor( + private readonly required: ManifestPermissions, + private readonly optional: ManifestOptionalPermissions + ) {} + + public async items(): Promise> { + return new Map(); + } + + public async permissions(): Promise { + return this.required; + } + + public async optionalPermissions(): Promise { + return this.optional; + } +} + +class ProviderFixture implements ContentProvider { + public constructor(private readonly contentDriver: DriverFixture) {} + + public virtual(_file: EntrypointFile): string { + return ""; + } + + public driver(): DriverFixture { + return this.contentDriver; + } + + public clear(): this { + return this; + } +} + +describe("ContentManager permissions", () => { + test("aggregates driver permissions and gives required permissions precedence", async () => { + const manager = new ContentManager({rootDir: process.cwd()} as ReadonlyConfig) + .provider( + new ProviderFixture( + new DriverFixture( + new Set(["scripting"]), + new Set(["activeTab"]) + ) + ) + ) + .provider( + new ProviderFixture( + new DriverFixture( + new Set(["webNavigation"]), + new Set(["scripting"]) + ) + ) + ); + + await expect(manager.permissions()).resolves.toEqual(new Set(["scripting", "webNavigation"])); + await expect(manager.optionalPermissions()).resolves.toEqual(new Set(["activeTab"])); + }); +}); diff --git a/src/cli/plugins/content/ContentManager.ts b/src/cli/plugins/content/ContentManager.ts index 42ab503e..223d53bc 100644 --- a/src/cli/plugins/content/ContentManager.ts +++ b/src/cli/plugins/content/ContentManager.ts @@ -6,7 +6,12 @@ import {getContentScriptConfigFromOptions} from "./utils"; import {ReadonlyConfig} from "@typing/config"; import {ContentScriptDeclarative, ContentScriptEntrypointOptions} from "@typing/content"; import {EntrypointEntries, EntrypointFile, EntrypointType} from "@typing/entrypoint"; -import {ManifestContentScripts, ManifestHostPermissions} from "@typing/manifest"; +import { + ManifestContentScripts, + ManifestHostPermissions, + ManifestOptionalPermissions, + ManifestPermissions, +} from "@typing/manifest"; export default class { protected readonly providers = new Set>(); @@ -15,7 +20,9 @@ export default class { protected _group?: ContentGroupItems; - protected _permissions?: [ManifestHostPermissions, ManifestHostPermissions]; + protected _hostPermissions?: [ManifestHostPermissions, ManifestHostPermissions]; + + protected _permissions?: Promise<[ManifestPermissions, ManifestOptionalPermissions]>; constructor(config: ReadonlyConfig) { this.names = new ContentName(config); @@ -72,16 +79,16 @@ export default class { } public async hostPermissions(): Promise { - return (await this.calculatePermissions())[0]; + return (await this.calculateHostPermissions())[0]; } public async optionalHostPermissions(): Promise { - return (await this.calculatePermissions())[1]; + return (await this.calculateHostPermissions())[1]; } - protected async calculatePermissions(): Promise<[ManifestHostPermissions, ManifestHostPermissions]> { - if (this._permissions) { - return this._permissions; + protected async calculateHostPermissions(): Promise<[ManifestHostPermissions, ManifestHostPermissions]> { + if (this._hostPermissions) { + return this._hostPermissions; } const hostPermissions = new Set(); @@ -113,7 +120,50 @@ export default class { } } - return (this._permissions = [hostPermissions, optionalHostPermissions]); + return (this._hostPermissions = [hostPermissions, optionalHostPermissions]); + } + + public async permissions(): Promise { + return (await this.getPermissions())[0]; + } + + public async optionalPermissions(): Promise { + return (await this.getPermissions())[1]; + } + + protected getPermissions(): Promise<[ManifestPermissions, ManifestOptionalPermissions]> { + return (this._permissions ??= this.calculatePermissions()); + } + + protected async calculatePermissions(): Promise<[ManifestPermissions, ManifestOptionalPermissions]> { + const permissions: ManifestPermissions = new Set(); + const optionalPermissions: ManifestOptionalPermissions = new Set(); + + const contributions = await Promise.all( + Array.from(this.providers, async provider => { + const driver = provider.driver(); + + return Promise.all([driver.permissions(), driver.optionalPermissions()]); + }) + ); + + for (const [required, optional] of contributions) { + for (const permission of required) { + permissions.add(permission); + } + + for (const permission of optional) { + optionalPermissions.add(permission); + } + } + + for (const permission of optionalPermissions) { + if (permissions.has(permission)) { + optionalPermissions.delete(permission); + } + } + + return [permissions, optionalPermissions]; } public virtual(file: EntrypointFile): string { @@ -152,6 +202,7 @@ export default class { this.names.reset(); this._group = undefined; + this._hostPermissions = undefined; this._permissions = undefined; return this; diff --git a/src/cli/plugins/content/Relay.ts b/src/cli/plugins/content/Relay.ts index 94f15030..f4948c4d 100644 --- a/src/cli/plugins/content/Relay.ts +++ b/src/cli/plugins/content/Relay.ts @@ -1,19 +1,19 @@ -import ContentDriver from "./ContentDriver"; +import RelayDriver from "./RelayDriver"; import {ContentProvider} from "./types"; import {RelayFinder} from "@cli/entrypoint"; import {virtualRelayModule} from "@cli/virtual"; -import {RelayEntrypointOptions, RelayMethod, RelayOptions} from "@typing/relay"; -import {ContentScriptDeclarative} from "@typing/content"; +import {RelayMethod, RelayOptions} from "@typing/relay"; +import {ContentScriptEntrypointOptions} from "@typing/content"; import {EntrypointFile} from "@typing/entrypoint"; -export default class extends RelayFinder implements ContentProvider { - protected _driver?: ContentDriver; +export default class extends RelayFinder implements ContentProvider { + protected _driver?: RelayDriver; - public driver(): ContentDriver { - return (this._driver ??= new ContentDriver(this)); + public driver(): RelayDriver { + return (this._driver ??= new RelayDriver(this)); } /** @@ -46,21 +46,6 @@ export default class extends RelayFinder implements ContentProvider { - return Object.values(await this.getOptionsMap()) - .map(({method}) => method) - .includes(method); - } - - public async hasDeclarative(declarative: ContentScriptDeclarative): Promise { - return !!Object.values(await this.getOptionsMap()).filter(options => { - if (declarative === ContentScriptDeclarative.Required && options.declarative === true) { - return true; - } - return declarative === options.declarative; - }).length; - } - public clear(): this { this._driver?.clear(); diff --git a/src/cli/plugins/content/RelayDeclaration.test.ts b/src/cli/plugins/content/RelayDeclaration.test.ts new file mode 100644 index 00000000..01ee4400 --- /dev/null +++ b/src/cli/plugins/content/RelayDeclaration.test.ts @@ -0,0 +1,148 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import ts from "typescript"; + +import RelayDeclaration from "./RelayDeclaration"; + +import type {ReadonlyConfig} from "@typing/config"; + +describe("Relay declarations", () => { + const projectDir = path.resolve(__dirname, "../../../.."); + const fixtureDir = path.join(__dirname, "tests/fixtures/relay-types"); + const consumerFile = path.join(fixtureDir, "consumer.ts"); + const generatedFile = path.join(fixtureDir, "generated.d.ts"); + let rootDir: string; + let declaration: string; + + beforeAll(() => { + rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-relay-declaration-")); + + new RelayDeclaration({rootDir} as ReadonlyConfig) + .dictionary({ + scanner: + "{ scan(text: string): number; load(): Promise; nested: { ready(): boolean; count: number; }; }", + }) + .build(); + + declaration = fs.readFileSync(path.join(rootDir, ".adnbn/relay.d.ts"), "utf-8"); + }); + + afterAll(() => { + fs.rmSync(rootDir, {recursive: true, force: true}); + }); + + test("generates only registry augmentation, not accessor overloads or provider imports", () => { + expect(declaration).toContain('import "adnbn/relay"'); + expect(declaration).toContain('declare module "adnbn/relay"'); + expect(declaration).toContain("export interface RelayRegistry"); + expect(declaration).not.toContain("ProxyRelay"); + expect(declaration).not.toContain("getRelay"); + expect(declaration).not.toContain("[name: string]: any"); + }); + + test.each(["source", "package"])( + "checks the %s API and generated scalar/batch contracts", + mode => { + const configFile = ts.readConfigFile(path.join(projectDir, "tsconfig.json"), ts.sys.readFile); + const config = ts.parseJsonConfigFileContent(configFile.config, ts.sys, projectDir); + + expect(configFile.error).toBeUndefined(); + expect(config.errors).toEqual([]); + + const options: ts.CompilerOptions = { + ...(mode === "source" ? config.options : {}), + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ESNext, + strict: true, + noEmit: true, + skipLibCheck: true, + types: ["node", "chrome"], + }; + const rootNames = [consumerFile, generatedFile]; + + if (mode === "source") { + rootNames.push( + path.join(fixtureDir, "virtual.ts"), + path.join(projectDir, "src/cli/virtual/relay.ts"), + path.join(projectDir, "src/cli/virtual/virtual.d.ts") + ); + } + + // Give the real generated declaration a consumer-side location so package self-resolution works. + // The package case deliberately has neither source aliases nor ambient virtual module declarations. + const host = ts.createCompilerHost(options); + const getSourceFile = host.getSourceFile.bind(host); + + host.getSourceFile = (file, languageVersion, onError, shouldCreateNewSourceFile) => + file === generatedFile + ? ts.createSourceFile(file, declaration, languageVersion, true) + : getSourceFile(file, languageVersion, onError, shouldCreateNewSourceFile); + + const program = ts.createProgram(rootNames, 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 checker = program.getTypeChecker(); + const consumer = program.getSourceFile(consumerFile)!; + const exportsOf = (moduleName: string): string[] => { + const statement = consumer.statements.find( + statement => + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text === moduleName + ) as ts.ImportDeclaration; + const symbol = checker.getSymbolAtLocation(statement.moduleSpecifier)!; + + return checker + .getExportsOfModule(symbol) + .map(symbol => symbol.name) + .sort(); + }; + + expect(exportsOf("adnbn/relay")).toEqual(["RelayName", "RelayRegistry", "RelayTarget", "getRelay"]); + expect(exportsOf("adnbn/entry/relay")).toEqual(["Builder", "RelayUnresolvedDefinition", "default"]); + expect(exportsOf("adnbn").filter(name => /Relay/.test(name))).toEqual([ + "RelayAllFrames", + "RelayAllFramesOptions", + "RelayAnyFramesOptions", + "RelayBatchOptions", + "RelayBatchProxyTarget", + "RelayBatchRpcProxy", + "RelayCallOptions", + "RelayDefinition", + "RelayDiscoveryError", + "RelayDocumentOptions", + "RelayDocumentsOptions", + "RelayEveryFrameOptions", + "RelayExecutionOptions", + "RelayFrameError", + "RelayFrameErrorKind", + "RelayFrameOptions", + "RelayFrameResult", + "RelayFramesOptions", + "RelayFramesResult", + "RelayMethod", + "RelayNonEmptyReadonlyArray", + "RelayProxyTarget", + "RelayResultTarget", + "RelayScalarOptions", + "RelayTopFrameOptions", + "defineRelay", + "getRelay", + ]); + + const apiFile = ts.resolveModuleName("adnbn", consumerFile, options, host).resolvedModule?.resolvedFileName; + + expect(apiFile).toBe(path.join(projectDir, mode === "source" ? "src/index.ts" : "dist/index.d.ts")); + }, + 30_000 + ); +}); diff --git a/src/cli/plugins/content/RelayDeclaration.ts b/src/cli/plugins/content/RelayDeclaration.ts index 99be3af3..27a126d1 100644 --- a/src/cli/plugins/content/RelayDeclaration.ts +++ b/src/cli/plugins/content/RelayDeclaration.ts @@ -6,11 +6,4 @@ export default class extends TransportDeclaration { constructor(config: ReadonlyConfig) { super(config, TransportDeclarationLayer.Relay); } - - protected template(): string { - return super - .template() - .replace("RelayProxyTarget, RelayTarget", "RelayProxyTarget, RelayTarget, ProxyRelayParams") - .replace("(name: N): RelayProxyTarget", "(name: N, params: ProxyRelayParams): RelayProxyTarget"); - } } diff --git a/src/cli/plugins/content/RelayDriver.test.ts b/src/cli/plugins/content/RelayDriver.test.ts new file mode 100644 index 00000000..6b7ac314 --- /dev/null +++ b/src/cli/plugins/content/RelayDriver.test.ts @@ -0,0 +1,155 @@ +import RelayDriver from "./RelayDriver"; + +import RelayFinder from "@cli/entrypoint/finder/RelayFinder"; + +import type {ReadonlyConfig} from "@typing/config"; +import type {EntrypointOptionsFinder} from "@typing/entrypoint"; +import {EntrypointType} from "@typing/entrypoint"; +import {ContentScriptDeclarative} from "@typing/content"; +import {RelayAllFrames, type RelayEntrypointOptions, RelayMethod} from "@typing/relay"; + +class RelayFinderFixture extends RelayFinder { + public constructor(private readonly optionFinder: EntrypointOptionsFinder) { + super({rootDir: process.cwd()} as ReadonlyConfig); + } + + public plugin(): EntrypointOptionsFinder { + return this.optionFinder; + } +} + +describe("RelayDriver", () => { + test.each([ + [true, true], + [RelayAllFrames.Any, true], + [RelayAllFrames.All, true], + [false, false], + ] as const)("adapts Relay allFrames %s to the content-script boolean", async (allFrames, expected) => { + const file = {file: "collector.ts", import: "collector.ts"}; + const finder = new RelayFinderFixture({ + type: () => EntrypointType.Relay, + options: async () => + new Map([ + [ + file, + { + name: "collector", + allFrames, + }, + ], + ]), + } as EntrypointOptionsFinder); + + const items = await new RelayDriver(finder).items(); + + expect([...items.values()]).toEqual([ + { + file, + options: { + allFrames: expected, + }, + }, + ]); + }); + + test("does not add content-script allFrames when Relay leaves it undefined", async () => { + const file = {file: "collector.ts", import: "collector.ts"}; + const finder = new RelayFinderFixture({ + type: () => EntrypointType.Relay, + options: async () => new Map([[file, {name: "collector"}]]), + } as EntrypointOptionsFinder); + + const items = await new RelayDriver(finder).items(); + + expect([...items.values()]).toEqual([{file, options: {}}]); + }); + + test.each([ + [RelayMethod.Messaging, RelayAllFrames.All, ["webNavigation"], []], + [undefined, RelayAllFrames.All, ["webNavigation"], []], + [RelayMethod.Messaging, RelayAllFrames.Any, [], []], + [RelayMethod.Messaging, true, [], []], + [RelayMethod.Scripting, RelayAllFrames.All, [], []], + ] as const)( + "collects permissions for method %s and allFrames %s", + async (method, allFrames, expected, expectedOptional) => { + const driver = createDriver([ + { + name: "collector", + method, + allFrames, + }, + ]); + + await expect(driver.permissions()).resolves.toEqual(new Set(expected)); + await expect(driver.optionalPermissions()).resolves.toEqual(new Set(expectedOptional)); + } + ); + + test.each([ + [ContentScriptDeclarative.Required, ["scripting"], []], + [true, ["scripting"], []], + [ContentScriptDeclarative.Optional, [], ["scripting"]], + [false, [], []], + ] as const)( + "classifies scripting permission for declarative %s", + async (declarative, expected, expectedOptional) => { + const driver = createDriver([ + { + name: "collector", + method: RelayMethod.Scripting, + declarative, + }, + ]); + + await expect(driver.permissions()).resolves.toEqual(new Set(expected)); + await expect(driver.optionalPermissions()).resolves.toEqual(new Set(expectedOptional)); + } + ); + + test("keeps scripting optional when an unrelated messaging Relay is required", async () => { + const driver = createDriver([ + { + name: "messenger", + method: RelayMethod.Messaging, + declarative: ContentScriptDeclarative.Required, + }, + { + name: "injector", + method: RelayMethod.Scripting, + declarative: ContentScriptDeclarative.Optional, + }, + ]); + + await expect(driver.permissions()).resolves.toEqual(new Set()); + await expect(driver.optionalPermissions()).resolves.toEqual(new Set(["scripting"])); + }); + + test("required scripting permission takes precedence over optional", async () => { + const driver = createDriver([ + { + name: "optional", + method: RelayMethod.Scripting, + declarative: ContentScriptDeclarative.Optional, + }, + { + name: "required", + method: RelayMethod.Scripting, + declarative: ContentScriptDeclarative.Required, + }, + ]); + + await expect(driver.permissions()).resolves.toEqual(new Set(["scripting"])); + await expect(driver.optionalPermissions()).resolves.toEqual(new Set()); + }); +}); + +const createDriver = (options: RelayEntrypointOptions[]): RelayDriver => { + const finder = new RelayFinderFixture({ + type: () => EntrypointType.Relay, + options: async () => + new Map(options.map((relay, index) => [{file: `relay-${index}.ts`, import: `relay-${index}.ts`}, relay])), + } as EntrypointOptionsFinder); + + return new RelayDriver(finder); +}; diff --git a/src/cli/plugins/content/RelayDriver.ts b/src/cli/plugins/content/RelayDriver.ts new file mode 100644 index 00000000..43a3af64 --- /dev/null +++ b/src/cli/plugins/content/RelayDriver.ts @@ -0,0 +1,58 @@ +import ContentDriver from "./ContentDriver"; + +import RelayFinder from "@cli/entrypoint/finder/RelayFinder"; + +import {ContentScriptDeclarative, type ContentScriptEntrypointOptions} from "@typing/content"; +import {RelayAllFrames, type RelayEntrypointOptions, RelayMethod} from "@typing/relay"; +import type {ManifestOptionalPermissions, ManifestPermissions} from "@typing/manifest"; + +export default class extends ContentDriver { + public constructor(finder: RelayFinder) { + super(finder); + } + + protected transform(relayOptions: RelayEntrypointOptions): ContentScriptEntrypointOptions { + const {allFrames, method: _method, name: _name, ...options} = relayOptions; + + return { + ...options, + ...(allFrames === undefined ? {} : {allFrames: allFrames !== false}), + }; + } + + protected async calculatePermissions(): Promise<[ManifestPermissions, ManifestOptionalPermissions]> { + const permissions: ManifestPermissions = new Set(); + const optionalPermissions: ManifestOptionalPermissions = new Set(); + const relays = Array.from((await this.getOptions()).values()); + + if ( + relays.some( + options => + (options.method ?? RelayMethod.Messaging) === RelayMethod.Messaging && + options.allFrames === RelayAllFrames.All + ) + ) { + permissions.add("webNavigation"); + } + + if ( + relays.some( + options => + options.method === RelayMethod.Scripting && + (options.declarative === true || options.declarative === ContentScriptDeclarative.Required) + ) + ) { + permissions.add("scripting"); + } else if ( + relays.some( + options => + options.method === RelayMethod.Scripting && + options.declarative === ContentScriptDeclarative.Optional + ) + ) { + optionalPermissions.add("scripting"); + } + + return [permissions, optionalPermissions]; + } +} diff --git a/src/cli/plugins/content/index.ts b/src/cli/plugins/content/index.ts index 665c5d92..e58cc26e 100644 --- a/src/cli/plugins/content/index.ts +++ b/src/cli/plugins/content/index.ts @@ -12,8 +12,7 @@ import {EntrypointPlugin, onlyViaTopLevelEntry} from "@cli/bundler"; import {getResolvePath, getSourcePath} from "@cli/resolvers/path"; import {Command} from "@typing/app"; -import {RelayMethod, RelayOptions} from "@typing/relay"; -import {ContentScriptDeclarative} from "@typing/content"; +import {RelayOptions} from "@typing/relay"; export default definePlugin(() => { let content: Content; @@ -112,15 +111,9 @@ export default definePlugin(() => { manifest .setContentScripts(await manager.manifest()) .appendHostPermissions(await manager.hostPermissions()) - .appendOptionalHostPermissions(await manager.optionalHostPermissions()); - - if ((await relay.exists()) && (await relay.hasMethod(RelayMethod.Scripting))) { - if (await relay.hasDeclarative(ContentScriptDeclarative.Required)) { - manifest.addPermission("scripting"); - } else if (await relay.hasDeclarative(ContentScriptDeclarative.Optional)) { - manifest.addOptionalPermission("scripting"); - } - } + .appendOptionalHostPermissions(await manager.optionalHostPermissions()) + .appendPermissions(await manager.permissions()) + .appendOptionalPermissions(await manager.optionalPermissions()); }, }; }); diff --git a/src/cli/plugins/content/tests/fixtures/relay-types/consumer.ts b/src/cli/plugins/content/tests/fixtures/relay-types/consumer.ts new file mode 100644 index 00000000..b8fb09d0 --- /dev/null +++ b/src/cli/plugins/content/tests/fixtures/relay-types/consumer.ts @@ -0,0 +1,86 @@ +import * as api from "adnbn"; +import * as local from "adnbn/relay"; +import * as entry from "adnbn/entry/relay"; + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; + +const definition = api.defineRelay({ + name: "scanner", + allFrames: api.RelayAllFrames.All, + method: api.RelayMethod.Scripting, + init: () => ({scan: (text: string) => text.length}), +}); + +new entry.Builder(definition); +const unresolved: entry.RelayUnresolvedDefinition> = {}; +new entry.Builder(unresolved); + +const original = local.getRelay("scanner"); +const scalar = api.getRelay("scanner", 1); +const top = api.getRelay("scanner", {tabId: 1}); +const single = api.getRelay("scanner", {tabId: 1, allFrames: false}); +const frame = api.getRelay("scanner", {tabId: 1, frameId: 2}); +const document = api.getRelay("scanner", {tabId: 1, documentId: "document-2"}); +const broadcast = api.getRelay("scanner", {tabId: 1, allFrames: true}); +const any = api.getRelay("scanner", {tabId: 1, allFrames: api.RelayAllFrames.Any}); +const all = api.getRelay("scanner", {tabId: 1, allFrames: api.RelayAllFrames.All}); +const frames = api.getRelay("scanner", {tabId: 1, frameIds: [0, 2]}); +const documents = api.getRelay("scanner", {tabId: 1, documentIds: ["document-2"]}); + +type LocalResult = Expect, number>>; +type ScalarResult = Expect, Promise>>; +type TopResult = Expect>; +type SingleResult = Expect>; +type FrameResult = Expect>; +type DocumentResult = Expect>; +type AnyResult = Expect, Promise>>>; +type AllResult = Expect>; +type BroadcastResult = Expect>; +type FramesResult = Expect>; +type DocumentsResult = Expect>; +type AsyncScalarResult = Expect, Promise>>; +type AsyncBatchResult = Expect, Promise>>>; +type NestedScalarResult = Expect, Promise>>; +type NestedBatchResult = Expect, Promise>>>; +type PropertyResult = Expect, Promise>>>; +type NestedObjectResult = Expect< + Equal, Promise>> +>; +type ScalarAlias = Expect>>; +type BatchAlias = Expect>>; +type LocalAlias = Expect>>; +type Names = Expect>; + +scalar.scan("text"); +all.scan("text"); + +// @ts-expect-error: The generated registry restricts names in the remote accessor. +api.getRelay("unknown", 1); +// @ts-expect-error: The local accessor uses the same generated registry. +local.getRelay("unknown"); +// @ts-expect-error: A remote call needs a tab target. +api.getRelay("scanner"); +// @ts-expect-error: A local accessor does not accept a remote target. +local.getRelay("scanner", 1); +// @ts-expect-error: Selectors are mutually exclusive. +api.getRelay("scanner", {tabId: 1, frameId: 2, allFrames: api.RelayAllFrames.All}); +// @ts-expect-error: Frame and document targeting cannot be combined. +api.getRelay("scanner", {tabId: 1, frameId: 2, documentId: "document-2"}); +// @ts-expect-error: Explicit frame lists are non-empty. +api.getRelay("scanner", {tabId: 1, frameIds: []}); +// @ts-expect-error: Explicit document lists are non-empty. +api.getRelay("scanner", {tabId: 1, documentIds: []}); +// @ts-expect-error: Scalar method arguments retain their original types. +scalar.scan(1); +// @ts-expect-error: Batch method arguments retain their original types. +all.scan(1); + +// @ts-expect-error: Unresolved definitions belong to the internal entry/bootstrap module. +type InternalDefinition = api.RelayUnresolvedDefinition; +// @ts-expect-error: Remote call options are not exported from the local-access module. +type LocalCallOptions = local.RelayCallOptions; +// @ts-expect-error: Provider implementation details are not a public entrypoint. +type InternalParams = local.ProxyRelayParams; + +export {api, local, entry}; diff --git a/src/cli/plugins/content/tests/fixtures/relay-types/virtual.ts b/src/cli/plugins/content/tests/fixtures/relay-types/virtual.ts new file mode 100644 index 00000000..f91a73cf --- /dev/null +++ b/src/cli/plugins/content/tests/fixtures/relay-types/virtual.ts @@ -0,0 +1,30 @@ +import {RelayAllFrames, type ContentScriptDefinition, type RelayDefinition} from "adnbn"; +import {Builder as RelayBuilder, type RelayUnresolvedDefinition} from "adnbn/entry/relay"; +import type {TransportType} from "adnbn/transport"; +import {Builder as ContentScriptBuilder} from "virtual:content-framework"; +import * as definition from "virtual:relay-entrypoint"; + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; + +type ContentOptions = Expect[0], ContentScriptDefinition>>; +type RelayOptions = Expect< + Equal[0], RelayUnresolvedDefinition> +>; +type NamedExports = Expect< + Equal>, Readonly>> +>; +type DefaultExport = Expect< + Equal< + typeof definition.default, + RelayDefinition | RelayDefinition["init"] | undefined + > +>; + +new RelayBuilder({allFrames: RelayAllFrames.All}); +new ContentScriptBuilder({allFrames: true}); + +// @ts-expect-error: The real content constructor requires a boolean, not Relay's response mode. +new ContentScriptBuilder({allFrames: RelayAllFrames.All}); +// @ts-expect-error: The real Relay constructor does not accept arbitrary modes. +new RelayBuilder({allFrames: "invalid"}); diff --git a/src/cli/plugins/content/types.ts b/src/cli/plugins/content/types.ts index 893ced15..398841ba 100644 --- a/src/cli/plugins/content/types.ts +++ b/src/cli/plugins/content/types.ts @@ -1,5 +1,6 @@ import {ContentScriptEntrypointOptions} from "@typing/content"; import {EntrypointFile} from "@typing/entrypoint"; +import {ManifestOptionalPermissions, ManifestPermissions} from "@typing/manifest"; export interface ContentItem { file: EntrypointFile; @@ -16,6 +17,10 @@ export interface ContentNameGenerator export interface ContentDriver { items(): Promise>; + + permissions(): Promise; + + optionalPermissions(): Promise; } export interface ContentProvider { diff --git a/src/cli/virtual/index.ts b/src/cli/virtual/index.ts index d378923b..edc99f28 100644 --- a/src/cli/virtual/index.ts +++ b/src/cli/virtual/index.ts @@ -8,7 +8,7 @@ import relay from "./relay.ts?raw"; import sandbox from "./sandbox.ts?raw"; import view from "./view.ts?raw"; -import {inferEntrypointFramework} from "@cli/entrypoint"; +import {inferEntrypointFramework} from "../entrypoint/index.js"; import {PackageName} from "@typing/app"; import {EntrypointFile} from "@typing/entrypoint"; diff --git a/src/cli/virtual/relay.ts b/src/cli/virtual/relay.ts index c6a26a5c..25bb135f 100644 --- a/src/cli/virtual/relay.ts +++ b/src/cli/virtual/relay.ts @@ -1,7 +1,6 @@ -import type {RelayUnresolvedDefinition} from "adnbn"; import type {TransportType} from "adnbn/transport"; import {isValidTransportDefinition, isValidTransportInitFunction} from "adnbn/entry/transport"; -import {Builder as RelayBuilder} from "adnbn/entry/relay"; +import {Builder as RelayBuilder, type RelayUnresolvedDefinition} from "adnbn/entry/relay"; import {Builder as ContentScriptBuilder} from "virtual:content-framework"; @@ -21,6 +20,7 @@ try { } const {init, main, name, ...options} = definition; + const {method, allFrames, ...contentOptions} = options; new RelayBuilder({ name: relayName, @@ -28,7 +28,12 @@ try { main, ...options, }) - .content(new ContentScriptBuilder(options)) + .content( + new ContentScriptBuilder({ + ...contentOptions, + ...(allFrames === undefined ? {} : {allFrames: allFrames !== false}), + }) + ) .build() .catch(e => { console.error("Failed to build relay: ", e); diff --git a/src/cli/virtual/virtual.d.ts b/src/cli/virtual/virtual.d.ts index 4a6e6ec9..079755d0 100644 --- a/src/cli/virtual/virtual.d.ts +++ b/src/cli/virtual/virtual.d.ts @@ -1,3 +1,4 @@ +// Only placeholders belong here; real adnbn imports resolve to their source modules via tsconfig.json. declare module "*?raw" { const content: string; export default content; @@ -37,16 +38,20 @@ declare module "virtual:content-entrypoint" { } declare module "virtual:content-framework" { - import {ContentScriptDefinition, ContentScriptBuilder} from "@typing/content"; + export const Builder: + | typeof import("@entry/content/adapters/vanilla").Builder + | typeof import("@entry/content/adapters/react").Builder; - export const Builder = ContentScriptBuilder; - - const module: (definition: ContentScriptDefinition) => void; - export = module; + const content: + | typeof import("@entry/content/adapters/vanilla").default + | typeof import("@entry/content/adapters/react").default; + export default content; } declare module "virtual:offscreen-entrypoint" { - type OffscreenDefinition = import("@typing/offscreen").OffscreenDefinition; + type OffscreenDefinition = import("@typing/offscreen").OffscreenDefinition< + import("@typing/transport").TransportType + >; interface ModuleType extends OffscreenDefinition { default: OffscreenDefinition | OffscreenDefinition["init"] | undefined; @@ -57,14 +62,40 @@ declare module "virtual:offscreen-entrypoint" { } declare module "virtual:relay-entrypoint" { - type RelayDefinition = import("@typing/relay").RelayDefinition; - - interface ModuleType extends RelayDefinition { - default: RelayDefinition | RelayDefinition["init"] | undefined; - } - - const module: ModuleType; - export = module; + type RelayDefinition = import("@typing/relay").RelayDefinition; + + export const { + init, + main, + name, + method, + allFrames, + matches, + excludeMatches, + includeGlobs, + excludeGlobs, + runAt, + world, + matchAboutBlank, + matchOriginAsFallback, + declarative, + marker, + anchor, + mount, + render, + container, + watch, + includeBrowser, + excludeBrowser, + includeApp, + excludeApp, + mode, + manifestVersion, + debug, + }: Partial; + + const definition: RelayDefinition | RelayDefinition["init"] | undefined; + export default definition; } declare module "virtual:sandbox-entrypoint" { @@ -78,13 +109,6 @@ declare module "virtual:sandbox-entrypoint" { export = module; } -declare module "virtual:relay-framework" { - type RelayUnresolvedDefinition = import("@typing/relay").RelayUnresolvedDefinition; - - const module: (definition: RelayUnresolvedDefinition) => void; - export = module; -} - declare module "virtual:view-entrypoint" { import {ViewOptions} from "@typing/view"; @@ -99,14 +123,14 @@ declare module "virtual:view-entrypoint" { } declare module "virtual:view-framework" { - import {ViewOptions, ViewBuilder} from "@typing/view"; + export const Builder: + | typeof import("@entry/view/adapters/vanilla").Builder + | typeof import("@entry/view/adapters/react").Builder; - type ViewDefinition = import("@typing/view").ViewDefinition; - - export const Builder = ViewBuilder; - - const module: (definition: ViewDefinition) => void; - export = module; + const view: + | typeof import("@entry/view/adapters/vanilla").default + | typeof import("@entry/view/adapters/react").default; + export default view; } declare module "virtual:transport-entrypoint" { @@ -120,125 +144,6 @@ declare module "virtual:transport-entrypoint" { export = module; } -declare module "adnbn" { - export type BackgroundDefinition = import("@typing/background").BackgroundDefinition; - export type CommandUnresolvedDefinition = import("@typing/command").CommandUnresolvedDefinition; - export type ContentScriptDefinition = import("@typing/content").ContentScriptDefinition; - export type ViewOptions = import("@typing/view").ViewOptions; - export type ViewDefinition = import("@typing/view").ViewDefinition; - export type OffscreenUnresolvedDefinition = - import("@typing/offscreen").OffscreenUnresolvedDefinition; - export type RelayUnresolvedDefinition = - import("@typing/relay").RelayUnresolvedDefinition; - export type SandboxUnresolvedDefinition = - import("@typing/sandbox").SandboxUnresolvedDefinition; -} - -declare module "adnbn/transport" { - export type TransportType = import("@typing/transport").TransportType; - export type TransportOptions = import("@typing/transport").TransportOptions; - export type TransportUnresolvedDefinition< - O extends import("@typing/transport").TransportOptions, - T extends import("@typing/transport").TransportType, - > = import("@typing/transport").TransportUnresolvedDefinition; -} - -declare module "adnbn/locale" { - export function resolve(value: string): string; -} - -declare module "adnbn/entry/background" { - import type {BackgroundDefinition, BackgroundMainHandler} from "@typing/background"; - - export function isValidBackgroundDefinition(value: unknown): value is BackgroundDefinition; - - export function isValidBackgroundMainHandler(value: unknown): value is BackgroundMainHandler; - - const background: (definition: BackgroundDefinition) => void; - export default background; -} - -declare module "adnbn/entry/command" { - import type {CommandDefinition, CommandExecute, CommandUnresolvedDefinition} from "@typing/command"; - - export function isValidCommandDefinition(value: unknown): value is CommandDefinition; - - export function isValidCommandExecuteFunction(value: unknown): value is CommandExecute; - - const command: (definition: CommandUnresolvedDefinition) => void; - export default command; -} - -declare module "adnbn/entry/content" { - import type {ContentScriptDefinition, ContentScriptRenderValue} from "@typing/content"; - - export function isContentScriptDefinition(value: unknown): value is ContentScriptDefinition; - - export function isValidContentScriptDefinitionRenderValue(value: unknown): value is ContentScriptRenderValue; -} - -declare module "adnbn/entry/view" { - import type {ViewOptions, ViewDefinition, ViewRenderValue} from "@typing/view"; - - export function isViewDefinition(value: unknown): value is ViewDefinition; - - export function isValidViewDefinitionRenderValue(value: unknown): value is ViewRenderValue; -} - -declare module "adnbn/entry/transport" { - export function isValidTransportDefinition(value: unknown): value is any; - - export function isValidTransportInitFunction(value: unknown): value is (...args: any[]) => any; -} - -declare module "adnbn/entry/offscreen" { - import type {OffscreenDefinition, OffscreenUnresolvedDefinition} from "@typing/offscreen"; - import type {TransportType} from "@typing/transport"; - import type {ViewBuilder} from "@typing/view"; - - export class Builder { - constructor(options: OffscreenDefinition | OffscreenUnresolvedDefinition); - - view(builder: ViewBuilder): this; - - build(): Promise; - } -} - -declare module "adnbn/entry/relay" { - import type {RelayDefinition, RelayUnresolvedDefinition} from "@typing/relay"; - import type {TransportType} from "@typing/transport"; - import type {ContentScriptBuilder} from "@typing/content"; - - export class Builder { - constructor(options: RelayDefinition | RelayUnresolvedDefinition); - - content(builder: ContentScriptBuilder): this; - - build(): Promise; - } -} - -declare module "adnbn/entry/sandbox" { - import type {SandboxDefinition, SandboxUnresolvedDefinition} from "@typing/sandbox"; - import type {TransportType} from "@typing/transport"; - import type {ViewBuilder} from "@typing/view"; - - export class Builder { - constructor(options: SandboxDefinition | SandboxUnresolvedDefinition); - - view(builder: ViewBuilder): this; - - build(): Promise; - } -} - -declare module "adnbn/offscreen" { - export class OffscreenBackground { - build(): void | Promise; - } -} - declare module "adnbn/entry/:entry" { import type {TransportUnresolvedDefinition, TransportOptions, TransportType} from "@typing/transport"; diff --git a/src/cli/virtual/virtual.test.ts b/src/cli/virtual/virtual.test.ts new file mode 100644 index 00000000..783ef734 --- /dev/null +++ b/src/cli/virtual/virtual.test.ts @@ -0,0 +1,118 @@ +import {execFileSync} from "child_process"; +import path from "path"; +import ts from "typescript"; + +type Generator = keyof typeof import("./index"); + +describe("Built virtual modules", () => { + const projectDir = path.resolve(__dirname, "../../.."); + const cases: {generator: Generator; imports: string[]}[] = [ + { + generator: "virtualBackgroundModule", + imports: ["adnbn", "adnbn/entry/background", "{entry}"], + }, + { + generator: "virtualCommandModule", + imports: ["adnbn", "adnbn/locale", "adnbn/entry/command", "{entry}"], + }, + { + generator: "virtualContentScriptModule", + imports: ["adnbn", "adnbn/entry/content", "adnbn/entry/content/{framework}", "{entry}"], + }, + { + generator: "virtualServiceModule", + imports: ["adnbn/transport", "adnbn/entry/transport", "adnbn/entry/service", "{entry}"], + }, + { + generator: "virtualOffscreenModule", + imports: [ + "adnbn", + "adnbn/transport", + "adnbn/entry/transport", + "adnbn/entry/offscreen", + "adnbn/entry/view/{framework}", + "{entry}", + ], + }, + { + generator: "virtualOffscreenBackgroundModule", + imports: ["adnbn/offscreen"], + }, + { + generator: "virtualRelayModule", + imports: [ + "adnbn/transport", + "adnbn/entry/transport", + "adnbn/entry/relay", + "adnbn/entry/content/{framework}", + "{entry}", + ], + }, + { + generator: "virtualSandboxModule", + imports: [ + "adnbn", + "adnbn/transport", + "adnbn/entry/transport", + "adnbn/entry/sandbox", + "adnbn/entry/view/{framework}", + "{entry}", + ], + }, + { + generator: "virtualViewModule", + imports: ["adnbn", "adnbn/locale", "adnbn/entry/view", "adnbn/entry/view/{framework}", "{entry}"], + }, + ]; + let generated: Record<"ts" | "tsx", Record>; + + beforeAll(() => { + // Run the final JS artifact in Node, without Jest transforms, source aliases, or module mocks. + generated = JSON.parse( + execFileSync( + process.execPath, + [ + "--input-type=module", + "-e", + ` + import * as generators from "./dist/cli/virtual/index.js"; + + const generated = Object.fromEntries(["ts", "tsx"].map(extension => { + const file = {file: "entry." + extension, import: "./entry." + extension}; + const modules = Object.fromEntries(Object.entries(generators).map(([name, generate]) => + [name, generate(file, "example")] + )); + + return [extension, modules]; + })); + + process.stdout.write(JSON.stringify(generated)); + `, + ], + {cwd: projectDir, encoding: "utf8", timeout: 10_000} + ) + ); + }); + + test("covers every built generator", () => { + expect(Object.keys(generated.ts).sort()).toEqual(cases.map(({generator}) => generator).sort()); + }); + + describe.each([ + {extension: "ts" as const, framework: "vanilla"}, + {extension: "tsx" as const, framework: "react"}, + ])("with $framework entrypoints", ({extension, framework}) => { + test.each(cases)("$generator preserves package imports and resolves placeholders", ({generator, imports}) => { + const source = generated[extension][generator]; + const importedFiles = ts.preProcessFile(source).importedFiles.map(file => file.fileName); + + expect(importedFiles).toEqual( + imports.map(specifier => + specifier.replace("{framework}", framework).replace("{entry}", `./entry.${extension}`) + ) + ); + expect(source).not.toContain("virtual:"); + expect(source).not.toContain(":entry"); + }); + }); +}); diff --git a/src/entry/relay/index.ts b/src/entry/relay/index.ts index 230e7fd9..962da2d8 100644 --- a/src/entry/relay/index.ts +++ b/src/entry/relay/index.ts @@ -1,5 +1,6 @@ import Builder from "./Builder"; export {Builder}; +export type {RelayUnresolvedDefinition} from "@typing/relay"; export default Builder.resolver(); diff --git a/src/main/relay.ts b/src/main/relay.ts index 446c234c..bc81421b 100644 --- a/src/main/relay.ts +++ b/src/main/relay.ts @@ -1,12 +1,46 @@ import RelayPermission from "@relay/RelayPermission"; import {ProxyRelay, type ProxyRelayParams} from "@relay/providers"; -import type {RelayName, RelayProxyTarget} from "@relay/index"; +import type {RelayName} from "@relay/index"; import type {TransportType} from "@typing/transport"; -import {RelayDefinition, RelayMethod, RelayOptions, RelayOptionsMap, RelayUnresolvedDefinition} from "@typing/relay"; +import { + RelayDefinition, + RelayAllFrames, + RelayFrameErrorKind, + RelayMethod, + RelayOptions, + RelayOptionsMap, + type RelayBatchOptions, + type RelayBatchProxyTarget, + type RelayProxyTarget, + type RelayScalarOptions, +} from "@typing/relay"; -export {RelayMethod}; -export type {RelayDefinition, RelayUnresolvedDefinition}; +export {RelayAllFrames, RelayFrameErrorKind, RelayMethod}; +export {RelayDiscoveryError} from "@relay/discovery/RelayDiscovery"; +export type { + RelayAllFramesOptions, + RelayAnyFramesOptions, + RelayBatchOptions, + RelayBatchProxyTarget, + RelayBatchRpcProxy, + RelayCallOptions, + RelayDocumentOptions, + RelayDocumentsOptions, + RelayExecutionOptions, + RelayEveryFrameOptions, + RelayFrameError, + RelayFrameOptions, + RelayFrameResult, + RelayFramesOptions, + RelayFramesResult, + RelayNonEmptyReadonlyArray, + RelayProxyTarget, + RelayResultTarget, + RelayScalarOptions, + RelayTopFrameOptions, + RelayDefinition, +} from "@typing/relay"; export const defineRelay = (options: RelayDefinition): RelayDefinition => { return options; @@ -25,16 +59,22 @@ const getRelayOptionsMap = (): RelayOptionsMap => { return relays; }; -export const getRelay = (name: N, params: ProxyRelayParams): RelayProxyTarget => { - const relays = getRelayOptionsMap(); +export function getRelay(name: N, params: number | RelayScalarOptions): RelayProxyTarget; - RelayPermission.init(relays); +export function getRelay(name: N, params: RelayBatchOptions): RelayBatchProxyTarget; +export function getRelay( + name: N, + params: ProxyRelayParams +): RelayProxyTarget | RelayBatchProxyTarget { + const relays = getRelayOptionsMap(); const options = relays.get(name); if (!options) { throw new Error(`Failed to get relay "${name}"`); } - return new ProxyRelay(name, options, params).get(); -}; + return new ProxyRelay(name, options, params, RelayPermission.getInstance(relays)).get() as + | RelayProxyTarget + | RelayBatchProxyTarget; +} diff --git a/src/message/error.test.ts b/src/message/error.test.ts index bb151138..47d2aab8 100644 --- a/src/message/error.test.ts +++ b/src/message/error.test.ts @@ -1,4 +1,4 @@ -import {restoreError, serializeError} from "./error"; +import {isRemoteMessageError, markRemoteMessageError, restoreError, serializeError} from "./error"; describe("message error", () => { test("serializes a real Error with name, message and stack", () => { @@ -36,4 +36,12 @@ describe("message error", () => { expect(restored).toBeInstanceOf(Error); expect(restored.message).toBe("Request failed."); }); + + test("brands restored remote errors without changing their native class", () => { + const restored = markRemoteMessageError(restoreError({name: "TypeError", message: "boom"})); + + expect(restored).toBeInstanceOf(TypeError); + expect(isRemoteMessageError(restored)).toBe(true); + expect(isRemoteMessageError(new TypeError("local"))).toBe(false); + }); }); diff --git a/src/message/error.ts b/src/message/error.ts index 8b6e566b..07ab7ab6 100644 --- a/src/message/error.ts +++ b/src/message/error.ts @@ -1,5 +1,20 @@ import {MessageError} from "@typing/message"; +const RemoteMessageErrorMarker = Symbol("RemoteMessageError"); + +export class UnsupportedMessageTargetError extends Error { + public override readonly cause?: unknown; + + public constructor(message: string, cause?: unknown) { + super(message); + this.name = "UnsupportedMessageTargetError"; + + if (cause !== undefined) { + this.cause = cause; + } + } +} + const build = (name: string, message: string, stack?: string): MessageError => { return stack ? {name, message, stack} : {name, message}; }; @@ -69,3 +84,16 @@ export const restoreError = (error?: MessageError): Error => { return restored; }; + +export const markRemoteMessageError = (error: T): T => { + Object.defineProperty(error, RemoteMessageErrorMarker, {value: true}); + + return error; +}; + +export const isRemoteMessageError = (error: unknown): error is Error => { + return ( + error instanceof Error && + (error as Error & {[RemoteMessageErrorMarker]?: boolean})[RemoteMessageErrorMarker] === true + ); +}; diff --git a/src/message/index.ts b/src/message/index.ts index c7ac7209..19cac7b1 100644 --- a/src/message/index.ts +++ b/src/message/index.ts @@ -1,4 +1,5 @@ import {Message} from "@message/providers"; +import {UnsupportedMessageTargetError} from "@message/error"; import { MessageData, @@ -26,7 +27,7 @@ export type { MessageSender, }; -export {Message}; +export {Message, UnsupportedMessageTargetError}; export interface MessageRegistry extends MessageDictionary {} diff --git a/src/message/providers/Message.test.ts b/src/message/providers/Message.test.ts index 984141c9..b74f0ca4 100644 --- a/src/message/providers/Message.test.ts +++ b/src/message/providers/Message.test.ts @@ -1,6 +1,9 @@ import Message from "./Message"; +import {getBrowserInfo} from "@addon-core/browser"; import * as env from "@main/env"; +import {isRemoteMessageError, UnsupportedMessageTargetError} from "../error"; + type MessageMap = { getStringLength: (data: string) => number; toUpperCase: (str: string) => string; @@ -17,9 +20,17 @@ type MessageMap = { let message: Message; const mockedEnv = env as jest.Mocked; +const mockedGetBrowserInfo = getBrowserInfo as jest.MockedFunction; beforeEach(async () => { jest.clearAllMocks(); + mockedEnv.isBrowser.mockReturnValue(false); + mockedGetBrowserInfo.mockResolvedValue({ + name: "Firefox", + vendor: "Mozilla", + version: "153.0", + buildID: "test", + }); message = new Message(); message["manager"].clear(); }); @@ -163,7 +174,7 @@ describe("send method", () => { expect(result).toBe(4); }); - test("sends a message to tab when options is a object with tabId, frameId and documentId in Firefox", async () => { + test("preserves documentId for Firefox 153 and newer", async () => { mockedEnv.isBrowser.mockReturnValue(true); message.watch("getStringLength", str => str.length); @@ -173,12 +184,38 @@ describe("send method", () => { expect(chrome.tabs.sendMessage).toHaveBeenCalledWith( 123, expect.objectContaining({type: "getStringLength", data: "test"}), - {frameId: 1}, + {frameId: 1, documentId: "1"}, expect.any(Function) ); + expect(mockedGetBrowserInfo).toHaveBeenCalledTimes(1); expect(result).toBe(4); }); + test("rejects documentId targeting on Firefox older than 153", async () => { + mockedEnv.isBrowser.mockReturnValue(true); + mockedGetBrowserInfo.mockResolvedValue({ + name: "Firefox", + vendor: "Mozilla", + version: "152.0", + buildID: "test", + }); + + await expect( + message.send("getStringLength", "test", {tabId: 123, documentId: "document-1"}) + ).rejects.toBeInstanceOf(UnsupportedMessageTargetError); + expect(chrome.tabs.sendMessage).not.toHaveBeenCalled(); + }); + + test("caches the Firefox version used for documentId capability checks", async () => { + mockedEnv.isBrowser.mockReturnValue(true); + message.watch("getStringLength", str => str.length); + + await message.send("getStringLength", "test", {tabId: 123, documentId: "document-1"}); + await message.send("getStringLength", "test", {tabId: 123, documentId: "document-2"}); + + expect(mockedGetBrowserInfo).toHaveBeenCalledTimes(1); + }); + test("rejects when a sync handler throws", async () => { message.watch("throwSync", data => { throw new TypeError(data); @@ -191,6 +228,17 @@ describe("send method", () => { await expect(message.send("throwSync", "sync boom")).rejects.toBeInstanceOf(TypeError); }); + test("marks restored handler errors as remote", async () => { + message.watch("throwSync", data => { + throw new TypeError(data); + }); + + const error = await message.send("throwSync", "sync boom").catch(cause => cause); + + expect(error).toBeInstanceOf(TypeError); + expect(isRemoteMessageError(error)).toBe(true); + }); + test("rejects when an async handler rejects", async () => { message.watch("throwAsync", async data => { throw new RangeError(data); diff --git a/src/message/providers/Message.ts b/src/message/providers/Message.ts index 88bb0fc1..f4673fc5 100644 --- a/src/message/providers/Message.ts +++ b/src/message/providers/Message.ts @@ -1,6 +1,6 @@ -import {sendMessage, sendTabMessage} from "@addon-core/browser"; +import {getBrowserInfo, sendMessage, sendTabMessage} from "@addon-core/browser"; -import {restoreError} from "../error"; +import {markRemoteMessageError, restoreError, UnsupportedMessageTargetError} from "../error"; import {isBrowser} from "@main/env"; @@ -27,7 +27,9 @@ import MessageManager from "../MessageManager"; import {GeneralHandler, MapHandler, SingleHandler} from "../handlers"; export default class Message extends AbstractMessage { + private static readonly FirefoxDocumentIdMinVersion = 153; private static instance: Message | null = null; + private firefoxDocumentIdVersion?: Promise<{major: number; version: string}>; public static getInstance() { return (this.instance ??= new Message()); @@ -48,7 +50,7 @@ export default class Message extends AbstractMessag return this.unwrap(response); } - private dispatch>( + private async dispatch>( message: MessageBody, options?: MessageSendOptions ): Promise> | MessageResponse | undefined> { @@ -62,8 +64,8 @@ export default class Message extends AbstractMessag const {tabId, ...other} = options; - if (isBrowser(Browser.Firefox)) { - delete other.documentId; + if (other.documentId !== undefined && isBrowser(Browser.Firefox)) { + await this.assertFirefoxDocumentTargetSupport(); } return sendTabMessage(tabId, message, other); @@ -80,7 +82,42 @@ export default class Message extends AbstractMessag return response.payload; } - throw restoreError(response.error); + throw markRemoteMessageError(restoreError(response.error)); + } + + private async assertFirefoxDocumentTargetSupport(): Promise { + this.firefoxDocumentIdVersion ??= getBrowserInfo().then(info => { + const major = Number.parseInt(info.version.split(".")[0], 10); + + if (!Number.isInteger(major)) { + throw new UnsupportedMessageTargetError( + `Cannot determine whether Firefox ${JSON.stringify(info.version)} supports documentId message targets.` + ); + } + + return {major, version: info.version}; + }); + + let browserVersion: {major: number; version: string}; + + try { + browserVersion = await this.firefoxDocumentIdVersion; + } catch (error) { + if (error instanceof UnsupportedMessageTargetError) { + throw error; + } + + throw new UnsupportedMessageTargetError( + "Cannot determine whether the current Firefox version supports documentId message targets.", + error + ); + } + + if (browserVersion.major < Message.FirefoxDocumentIdMinVersion) { + throw new UnsupportedMessageTargetError( + `Messaging by documentId requires Firefox ${Message.FirefoxDocumentIdMinVersion} or newer; current version is ${browserVersion.version}.` + ); + } } private isMessageResult(response: unknown): response is MessageResult { diff --git a/src/offscreen/providers/Offscreen.test.ts b/src/offscreen/providers/Offscreen.test.ts index 1cba4515..8e5dcfd2 100644 --- a/src/offscreen/providers/Offscreen.test.ts +++ b/src/offscreen/providers/Offscreen.test.ts @@ -9,7 +9,7 @@ import ProxyOffscreen from "./ProxyOffscreen"; import RegisterOffscreen from "./RegisterOffscreen"; import OffscreenManager from "../OffscreenManager"; -import {DeepAsyncProxy} from "@typing/helpers"; +import {RpcAsyncProxy} from "@typing/rpc"; import {MessageTypeSeparator} from "@typing/message"; jest.mock("@offscreen/utils", () => ({isOffscreen: jest.fn()})); @@ -167,7 +167,7 @@ const MatchService = { }; type OffscreenType = typeof MatchService; -type OffscreenProxyType = DeepAsyncProxy; +type OffscreenProxyType = RpcAsyncProxy; const offscreenName = "math"; const parameters = { diff --git a/src/offscreen/providers/ProxyOffscreen.ts b/src/offscreen/providers/ProxyOffscreen.ts index 7cada995..e39f76ff 100644 --- a/src/offscreen/providers/ProxyOffscreen.ts +++ b/src/offscreen/providers/ProxyOffscreen.ts @@ -11,7 +11,7 @@ import OffscreenBridge from "../OffscreenBridge"; import {isOffscreen} from "../utils"; import {Browser} from "@typing/browser"; -import {DeepAsyncProxy} from "@typing/helpers"; +import {RpcAsyncProxy} from "@typing/rpc"; import {TransportDictionary, TransportManager, TransportMessage, TransportName} from "@typing/transport"; type CreateParameters = chrome.offscreen.CreateParameters; @@ -22,7 +22,7 @@ const ActiveLockName = "adnbn:offscreen:active"; export default class ProxyOffscreen< N extends TransportName, - T = DeepAsyncProxy, + T = RpcAsyncProxy, > extends ProxyTransport { protected message: TransportMessage; diff --git a/src/relay/README.md b/src/relay/README.md new file mode 100644 index 00000000..61dedb90 --- /dev/null +++ b/src/relay/README.md @@ -0,0 +1,299 @@ +# Relay + +Relay exposes an object living in a content-script frame as a typed RPC API. Extension contexts such as a popup or background can call its methods, read its data, and receive results without implementing message routing themselves. + +This README describes the Relay runtime in this directory and the entrypoint lifecycle in [`src/entry/relay`](../entry/relay). It is a guide for developers and AI agents changing this layer; it documents the current implementation, not proposed APIs. + +## Responsibilities and boundaries + +- Create and register a named instance in each frame where the Relay entrypoint runs. +- Turn remote method/property access into the shared transport's `{path, args}` calls. +- Address the top frame, a specific frame/document, explicit lists, or all frames in one tab. +- Select Messaging or Scripting and normalize their results into the Relay contract. +- Contribute the required manifest permissions through the content-plugin pipeline. + +Relay is not a background service, a multi-tab coordinator, or a mechanism for accessing arbitrary page JavaScript. Each frame has its own instance and state. A call does not install the Relay entrypoint or bypass its matches, host access, execution world, or browser restrictions. + +## Quick start + +Define a Relay in the extension's source directory: + +```ts title="src/relays/scanner.relay.ts" +import {defineRelay, RelayAllFrames, RelayMethod} from "adnbn"; + +export default defineRelay({ + name: "scanner", + matches: ["https://example.com/*"], + method: RelayMethod.Messaging, + allFrames: RelayAllFrames.All, + init() { + return { + scan() { + return { + url: location.href, + title: document.title, + links: document.links.length, + }; + }, + }; + }, +}); +``` + +The framework generates the Relay registry/types from the entrypoint's `init` contract. Build or run the extension's watch command to generate them; an empty registry does not know the name `"scanner"`. + +```ts title="src/popup/scanTab.ts" +import {getRelay, RelayAllFrames} from "adnbn"; + +export async function scanTab(tabId: number) { + const top = await getRelay("scanner", tabId).scan(); + console.log("Top frame", top); + + const outcomes = await getRelay("scanner", { + tabId, + allFrames: RelayAllFrames.All, + timeoutMs: 5_000, + }).scan(); + + for (const outcome of outcomes) { + if (outcome.status === "fulfilled") { + console.log(outcome.target, outcome.result); + } else { + console.error(outcome.target, outcome.error.kind, outcome.error.message); + } + } +} +``` + +`method` belongs to the entrypoint configuration, not the call target. It defaults to `RelayMethod.Messaging`. To expose separate Messaging and Scripting endpoints, define separate named Relays. + +The two `getRelay` imports serve different contexts: + +| Import | Usage | Returned value | +| ------------- | ------------------------------------------------------------------------------------------- | ----------------------- | +| `adnbn` | `getRelay(name, target)` from a remote caller | Async RPC proxy | +| `adnbn/relay` | `getRelay(name)` where that Relay is already registered in the current content-script frame | Original local instance | + +The local accessor is not a way to address another frame. The remote accessor rejects use in a context containing the Relay manager; use the local instance there. + +### Export ownership + +- [`adnbn`](../main/relay.ts) exports `defineRelay`, the remote `getRelay`, and the public definition, call-target, proxy, result, and error contracts. Import `RelayAllFrames`, `RelayMethod`, `RelayFrameErrorKind`, and `RelayDiscoveryError` here. +- [`adnbn/relay`](./index.ts) exports only the local `getRelay` and `RelayRegistry`, `RelayName`, and `RelayTarget` types. The generated `.adnbn/relay.d.ts` augments this registry; both accessors derive their types from it. It does not redeclare accessor overloads. +- [`adnbn/entry/relay`](../entry/relay/index.ts) is the internal bootstrap interface: `Builder`, its default resolver, and `RelayUnresolvedDefinition`. The unresolved type represents a definition assembled by the virtual module, not the public `defineRelay` contract. + +`ProxyRelay`, `RegisterRelay`, and `ProxyRelayParams` are internal implementation details, not exports of `adnbn/relay`. Remote call/result types are available from `adnbn`, not the local-access module. There are no legacy export aliases. + +## Targets and return types + +There is one remote `getRelay` API with scalar and batch overloads. For a method returning `T` or `Promise`: + +| Call target | Delivery scope | Return type | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------- | +| `tabId`, `{tabId}`, or `{tabId, allFrames: false}` | Top frame (`frameId: 0`) | `Promise` | +| `{tabId, frameId: 7}` | One frame | `Promise` | +| `{tabId, documentId}` | One document | `Promise` | +| `{tabId, frameIds: [0, 7]}` | Selected frames | `Promise>` | +| `{tabId, documentIds: [firstId, secondId]}` | Selected documents | `Promise>` | +| `{tabId, allFrames: true}` or `RelayAllFrames.Any` as the `allFrames` value | Broadcast/native all-frame injection; one selected outcome | `Promise>` | +| `{tabId, allFrames: RelayAllFrames.All}` | All discovered Messaging targets / all observed Scripting outcomes | `Promise>` | + +`true` is an alias for `Any`, **not** `All`. Even a one-element `frameIds` or `documentIds` list uses the batch contract. + +The selectors `allFrames`, `frameId`, `frameIds`, `documentId`, and `documentIds` are mutually exclusive. This includes combining `allFrames: false` with another selector. Both TypeScript and runtime validation enforce the contract. IDs must be valid non-negative integers or non-empty document strings; lists must be non-empty and contain no duplicates. `timeoutMs`, when provided, must be finite and greater than zero. + +Use a literal target or narrow a runtime choice before calling `getRelay`; do not hide an ambiguous scalar/batch choice behind `any`. Reusable ID lists can use the exported `RelayNonEmptyReadonlyArray` type. + +Proxy methods always return promises. Nested paths are supported, and primitive properties are read by calling them: a local `version: 1` becomes `await proxy.version()`. Scalar proxy typing is shared [`RpcAsyncProxy`](../types/rpc.ts); batch typing is Relay-specific [`RelayBatchRpcProxy`](../types/relay.ts). + +### Batch outcomes + +`RelayFramesResult` is a readonly array, not a map keyed by browser objects. Each element has one of these shapes: + +```ts title="Relay outcome shapes" +import type {RelayFrameError, RelayResultTarget} from "adnbn"; + +type Outcome = + | {target: RelayResultTarget; status: "fulfilled"; result: T} + | {target: RelayResultTarget; status: "rejected"; error: RelayFrameError}; +``` + +- Addressed outcomes carry `{tabId, frameId, documentId?}` or `{tabId, documentId}`. An output may contain both frame and document IDs even though input selectors are exclusive. +- `Any` uses `{tabId, allFrames: RelayAllFrames.Any}`. It does not claim to identify the responding frame, even when Scripting knew the selected frame internally. +- An unaddressable Scripting `All` failure can use `{tabId, allFrames: RelayAllFrames.All}`. Never manufacture `frameId: 0` for an operation-wide error. +- Addressed results are sorted by frame ID, then document ID; do not interpret array order as response timing or requested-list order. + +### `Any`: execute everywhere, observe one outcome + +**`Any` can execute a mutating method in multiple frames even though it returns only one outcome. It does not choose one frame to execute.** + +Messaging sends one native tab message without a frame/document selector. Every matching listener receives it, but only the first response is observable. That response can be a remote error; there is no preference for success and no guarantee that frame `0` answers. This follows [native tab messaging](https://developer.chrome.com/docs/extensions/reference/api/tabs#method-sendMessage) and [first-response handling](https://developer.chrome.com/docs/extensions/develop/concepts/messaging#responses). + +Scripting performs native all-frame injection, normalizes/sorts the returned outcomes, and selects the first fulfilled outcome, falling back to the first rejected one. It waits for the injection operation; this is **not** a race that resolves immediately when one frame succeeds. A slow method in another frame can still delay it. If the injector returns no outcomes, Relay returns `[]`. + +Use `Any` for a broadcast signal when a complete report is unnecessary. It still returns an outcome array, not `void`, and is not a delivery receipt for every frame. If the caller ignores the result, it must still handle possible call-level rejection. Use idempotent operations or application-level request IDs when repeated side effects matter. + +### `All`: inspect every available outcome + +- **Messaging:** discover a snapshot of frame addresses, then send an independently addressed call to each. Frames without a Relay listener are not filtered out; their delivery failures are reported. +- **Scripting:** use native `allFrames: true` and return all outcomes exposed by Inject Script. This is not an independently enumerated inventory of every frame in the tab. Inaccessible or unobservable execution may produce an operation-level failure rather than an address for every missing frame. + +Neither mode freezes the page: frames can navigate, disappear, or appear after dispatch. For an explicitly known set, use `frameIds` or `documentIds`. Both transports isolate ordinary target-level failures for explicit lists; Inject Script starts separate native calls for the selected targets. + +## Configuration and permissions + +### Entrypoint coverage is not the call target + +`defineRelay({allFrames: ...})` controls content-script coverage and build-time permission requirements. `getRelay(name, {allFrames: ...})` controls delivery and return type for that call. One does not replace or implicitly set the other. + +The Relay driver converts entrypoint `true`, `Any`, and `All` to the content-script boolean `allFrames: true`. The virtual Relay module applies the same conversion when constructing the runtime content builder, without passing Relay's `method` option to it. `false` remains false, and an omitted value remains omitted. The regular `ContentScriptEntrypointOptions.allFrames` type stays boolean; only Relay replaces it with its own type. Relay's own builder retains the original mode. + +An all-frame call cannot make a top-frame-only entrypoint appear inside an iframe. Each frame must still satisfy registration and access requirements. + +### Permission contributions + +| Entrypoint configuration | Relay-specific manifest contribution | +| ---------------------------------------------------------------------- | ------------------------------------ | +| Messaging + `allFrames: RelayAllFrames.All` | Required `webNavigation` | +| Messaging + omitted/false/true/`Any` | No frame-discovery permission | +| Scripting + `declarative: true` or `ContentScriptDeclarative.Required` | Required `scripting` | +| Scripting + `ContentScriptDeclarative.Optional` | Optional `scripting` | + +Scripting defaults to optional declarative registration when `declarative` is omitted. `declarative: false` is not a supported Scripting configuration. Scripting does not require `webNavigation` for any target mode. Host and optional host permissions are handled through the existing content-script configuration; this table does not replace them. + +The build cannot infer future call-site targets. If a Messaging Relay is configured with `Any` but a caller requests `All`, the manifest must already contain required `webNavigation`, either from another entrypoint or explicit framework configuration. There is no separate `frameDiscoveryFallback` flag, and optional-only `webNavigation` is not sufficient for the current discovery guard. + +Permission ownership follows this path: + +```text +RelayParser / RelayFinder + -> RelayDriver: Relay-specific permission rules using original Relay options + -> ContentManager: aggregate content and Relay driver contributions + -> manifest: required / optional permissions and host permissions +``` + +[`RelayDriver`](../cli/plugins/content/RelayDriver.ts) retains the original options for permission calculation because its content transformation removes `method`/`name` and reduces `allFrames` to a boolean. [`ContentManager`](../cli/plugins/content/ContentManager.ts) must remain unaware of Relay enums. Drivers and the manager expose separate `permissions()` and `optionalPermissions()` methods; required API permissions take precedence over optional duplicates. + +### Runtime permission gate + +[`RelayPermission`](./RelayPermission.ts) caches permission state for configured Relays in the calling runtime context. `getInstance(relays)` stores one instance on `globalThis`; the constructor configures it, and idempotent `start()` subscribes to permission additions/removals and starts a refresh. + +`ProxyRelay` checks this gate once per logical call, not once per frame. An allowed call proceeds synchronously into the adapter; otherwise it requests the configured permissions and rejects if the user denies them. Messaging does not request Scripting permissions. The cache is not a security boundary: the browser still enforces access. + +The options map comes from the bundler-generated `__ADNBN_RELAY_OPTIONS__`. An existing permission instance is not reconfigured when another map is passed. Runtime option hot replacement, revisions, and subscription disposal for HMR are not implemented; do not assume build-time cache clearing provides them. + +## Discovery and transport internals + +### Discovery is only for Messaging `All` + +[`RelayDiscovery`](./discovery/RelayDiscovery.ts) reads the manifest synchronously and calls the `getAllFrames` wrapper from `@addon-core/browser`. Missing required `webNavigation`, an unreadable manifest, or enumeration failure rejects the call with `RelayDiscoveryError`. + +It discovers frame addresses, **not registered Relay instances**. It does not use `runtime.getContexts`: [extension contexts](https://developer.chrome.com/docs/extensions/reference/api/runtime#type-ExtensionContext) are not an inventory of content-script frames in ordinary web pages. + +There is no discovery ping, startup registry, fallback mode, or fixed collection window. Messaging `Any`, explicit targets, and all Scripting calls skip discovery. Do not reintroduce a guessed deadline to present an incomplete set of responders as an exhaustive frame list. + +### Runtime paths + +```text +getRelay(name, target) -> ProxyRelay -> permission gate -> selected adapter + Messaging -> RelayMessage -> shared Message/RegisterTransport -> local instance + Scripting -> Inject Script -> frame-local RelayManager.property -> local instance +``` + +[`ProxyRelay`](./providers/ProxyRelay.ts) owns target validation, lazy `_target`/`_adapter` initialization, and permission gating. The adapters own delivery and result normalization; the shared [`ProxyTransport`](../transport/ProxyTransport.ts) still owns RPC path construction. Relay does not introduce a separate transport protocol for Service, Offscreen, or Sandbox. + +[`RelayMessagingAdapter`](./adapters/RelayMessagingAdapter.ts) uses the existing message envelope and structured remote-error marker. [`RelayScriptingAdapter`](./adapters/RelayScriptingAdapter.ts) translates Relay-owned options into `@addon-core/inject-script` options and wraps the method result in a serializable success/error envelope. This preserves a successful `undefined` return and distinguishes a remote method error from an injection failure. + +The Scripting callback must remain self-contained: runtime imports or caller closures cannot be referenced from injected code. It receives its arguments and manager key explicitly. This restriction applies to the adapter's injected wrapper, not to regular bundled Relay methods. The current adapter uses the default isolated injection world and does not forward the content-script `world` option; keep Scripting Relays in that world so they share the manager. + +If the frame-local manager already exists, Scripting starts the method without a preliminary timer/await. `Any` fails immediately when the manager is absent. Addressed calls and `All` retain up to 10 manager checks, 300 ms apart, to tolerate startup races. These checks wait for the manager, not a complete inventory or guaranteed readiness of every named Relay. + +### Entrypoint lifecycle + +The generated [`virtual Relay module`](../cli/virtual/relay.ts) combines the definition with its resolved name and attaches a content builder to [`Builder`](../entry/relay/Builder.ts). Each build: + +1. Destroys the previous transport/content state. +2. Creates the instance through `init(options)` and registers it through [`TransportBuilder`](../entry/relay/TransportBuilder.ts). +3. Builds the content context. +4. Calls optional `main(instance, context, options)`. + +Virtual templates are checked against the actual package source exports. The [`virtual module declarations`](../cli/virtual/virtual.d.ts) describe placeholders and derive framework constructors from the real adapters; do not add handwritten ambient declarations that shadow `adnbn` or its real subpaths. + +Registration precedes `main`; do not assume an asynchronous `main` finishes before the first remote call. Keep synchronously required state in `init` or explicitly coordinate readiness in the exposed API. + +[`RegisterRelay`](./providers/RegisterRelay.ts) registers Messaging listeners through the shared `RegisterTransport`. Scripting registers only the local instance in [`RelayManager`](./RelayManager.ts), exposed under `RelayGlobalKey`. Destroying the builder unregisters the transport and destroys the content context. + +Messaging handlers receive the shared request-scoped `this.$sender` context. Scripting invokes the local instance directly and does not synthesize a messaging sender. Sender metadata describes the caller, not the frame selected by `Any`; do not use it to invent responder identity. + +## Errors, timeouts, and data + +Scalar calls resolve the method value or throw. Batch calls represent ordinary per-target failures as rejected elements and retain successful siblings. **A batch promise can still reject** for invalid targets, missing configuration, denied permissions, discovery errors, or unsupported/invalid injection requests. Catch call-level errors as well as inspecting returned outcomes. + +`RelayFrameErrorKind` is a string enum. Batch errors contain `kind`, `name`, `message`, and optional `stack`: + +| Kind | Meaning | +| -------------- | ------------------------------------------------------------------------------ | +| `Remote` | The Relay method/property invocation failed inside its RPC envelope. | +| `Execution` | The injected wrapper or result processing failed outside that envelope. | +| `Delivery` | The transport could not deliver or observe the operation. | +| `Timeout` | The configured wait expired. | +| `TargetGone` | The target/receiver was reported missing or disappeared. | +| `Unobservable` | Inject Script received neither a usable native result nor an observable error. | + +Messaging identifies `Remote` structurally when unwrapping an error envelope. Only the `TargetGone`/`Delivery` distinction uses browser-error text heuristics; `TargetGone` can therefore mean a missing Relay listener, not proof that the frame was destroyed. Do not classify business errors by matching their message text. + +`timeoutMs` bounds each addressed Messaging response (or the single `Any` response). It does not bound Messaging discovery or permission prompts. Without it, Relay adds no Messaging timer. Scripting passes it to Inject Script; the installed package defaults to 4,000 ms and handles explicit-target/operation timeouts itself. + +A timeout stops waiting, not execution. A rejected call is not proof that no frame ran the method, especially after partial Scripting execution. Do not automatically retry a mutating batch without deduplication. Relay keeps the common error fields; package-specific timeout metadata is not part of its public error contract. + +Use JSON-compatible arguments and result data. Do not send DOM nodes, functions, class instances, `Blob`, `ArrayBuffer`, or cyclic structures; convert them to plain data. The Scripting envelope supports a method returning `undefined`, but that does not make arbitrary undefined-valued argument/object structures portable. Generated proxy types are a developer aid, not runtime serialization validation or an authorization boundary. + +### Document targeting + +`frameId` identifies a frame; `documentId` addresses a particular document. Navigation can change the document without giving the caller a stable document identity through the frame ID alone. Never silently remove a requested document selector or replace it with a broadcast. + +The shared [`Message`](../message/providers/Message.ts) implementation checks Firefox's version through a cached `getBrowserInfo()` promise: the current guard accepts document targeting from version 153, and rejects older or undetectable versions with `UnsupportedMessageTargetError`. In a batch this is represented as a per-target failure. This guard belongs to Messaging, not Inject Script. + +Scripting delegates native document-target capability checks to Inject Script. MV2 document targets are unsupported and reject; a capability failure must not silently fall back to frame addressing. + +## User activation + +Relay relies on the browser's user-activation rules; it does not create activation or guarantee that it remains available in the target frame. Extension permissions determine whether a call is allowed, while user activation determines whether an activation-gated operation can run. Granting permissions does not replace a user gesture. + +Scripting deliberately preserves a short dispatch path when the cached permission state allows the call: no awaited permission refresh or discovery before injection, and no delayed manager check when the manager exists. Explicit target injections are started synchronously before their results are awaited. Keep these properties when refactoring. + +Messaging uses native message delivery and inherits the browser's activation-propagation behavior. [Chromium's extension messaging implementation](https://github.com/chromium/chromium/blob/main/extensions/renderer/api/messaging/native_renderer_messaging_service.cc) can propagate a user-gesture notification to the receiving frame, but Relay does not promise this across all browsers and contexts. Messaging does not inherently mean activation is lost, and an `await` does not universally clear it: [transient activation](https://html.spec.whatwg.org/multipage/interaction.html#tracking-user-activation) can expire or be consumed. + +Permission prompts, frame discovery, startup retries, and work inside the remote handler can introduce delays before an activation-gated operation. The synchronous Scripting path does not extend its guarantees to those stages. Prepare the target and permissions where practical, dispatch from the user-triggered handler, and avoid unrelated asynchronous work before the sensitive operation. Activation must be available in the context where that operation executes, not merely in the caller. + +## Maintenance checklist + +- Preserve the scalar/batch overloads and `true` as an alias of `Any`. Do not add a second `getRelayAll` API or make `Any` return `void`. +- Keep Relay targets independent of `InjectScriptOptions`. Translate them inside the Scripting adapter; keep ordinary content-script types boolean-only for `allFrames`. +- Keep discovery out of `Any` and Scripting. Missing strict-Messaging permission must not silently change `All` to `Any`. +- Preserve operational targets when an actual frame/document identity is unavailable, and preserve partial successes. +- Keep transport-specific behavior in adapters, Relay permission rules in `RelayDriver`, and generic aggregation in `ContentManager`. +- Keep the public export boundaries and the `adnbn/relay` registry augmentation stable. Test generated declarations against both source modules and built package exports; an `any`-typed virtual constructor must not hide incompatible options. +- Preserve the Scripting synchronous-start path, fail-fast `Any`, and fulfilled-result preference. Do not give Messaging that same success-preference promise. +- Update types, parsers, drivers, runtime tests, and this README together when changing a public contract. Shared RPC/message changes also require checking the other transport layers. + +### Verification + +Run from the framework repository root. Build first so targeted tests can resolve the package's public exports: + +```bash +npm run build +npm run typecheck +npm run test:relay -- --runInBand +npm run test:message -- --runInBand +``` + +`typecheck` includes `typecheck:tests`; passing Jest alone does not prove test files are type-correct. For the full non-browser regression suite, including shared transport and content aggregation tests: + +```bash +npm test -- --runInBand --testPathIgnorePatterns=tests/integration/browser +``` + +Important coverage lives in [`Relay.test.ts`](./providers/Relay.test.ts), [`RelayDiscovery.test.ts`](./discovery/RelayDiscovery.test.ts), [`RelayPermission.test.ts`](./RelayPermission.test.ts), [`RelayParser.test.ts`](../cli/entrypoint/parser/RelayParser.test.ts), [`RelayDriver.test.ts`](../cli/plugins/content/RelayDriver.test.ts), [`RelayDeclaration.test.ts`](../cli/plugins/content/RelayDeclaration.test.ts), and [`ContentManager.test.ts`](../cli/plugins/content/ContentManager.test.ts). + +Keep regression cases for a rejected top frame plus successful iframe in Scripting `Any`, no manager retries in `Any`, partial explicit-batch failures, remote Messaging errors, document capability rejection, and permission precedence. Native frame enumeration, injection rejection semantics, and user activation require verification in a real extension context; browser mocks cannot establish those guarantees. diff --git a/src/relay/RelayPermission.test.ts b/src/relay/RelayPermission.test.ts new file mode 100644 index 00000000..c77e5d87 --- /dev/null +++ b/src/relay/RelayPermission.test.ts @@ -0,0 +1,74 @@ +import {containsPermissions, onPermissionsAdded, onPermissionsRemoved} from "@addon-core/browser"; + +import RelayPermission, {RelayPermissionGlobalKey} from "./RelayPermission"; + +import {ContentScriptDeclarative} from "@typing/content"; +import {RelayMethod, type RelayOptionsMap} from "@typing/relay"; + +const mockedContainsPermissions = containsPermissions as jest.MockedFunction; +const mockedOnPermissionsAdded = onPermissionsAdded as jest.MockedFunction; +const mockedOnPermissionsRemoved = onPermissionsRemoved as jest.MockedFunction; + +describe("RelayPermission", () => { + beforeEach(() => { + delete globalThis[RelayPermissionGlobalKey]; + mockedContainsPermissions.mockReset(); + mockedOnPermissionsAdded.mockClear(); + mockedOnPermissionsRemoved.mockClear(); + }); + + afterEach(() => { + delete globalThis[RelayPermissionGlobalKey]; + }); + + test("checks every Relay stored in its Map", async () => { + mockedContainsPermissions.mockResolvedValue(true); + const relays: RelayOptionsMap = new Map([ + [ + "scanner", + { + name: "scanner", + method: RelayMethod.Scripting, + declarative: ContentScriptDeclarative.Optional, + matches: ["https://example.com/*"], + }, + ], + ]); + + const permission = RelayPermission.getInstance(relays); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockedContainsPermissions).toHaveBeenCalledWith({ + origins: ["https://example.com/*"], + permissions: ["scripting"], + }); + expect(permission.allow("scanner")).toBe(true); + expect(mockedOnPermissionsAdded).toHaveBeenCalledTimes(1); + expect(mockedOnPermissionsRemoved).toHaveBeenCalledTimes(1); + }); + + test("returns one started instance for the current runtime context", () => { + const relays: RelayOptionsMap = new Map([ + [ + "messaging", + { + name: "messaging", + method: RelayMethod.Messaging, + declarative: false, + matches: [], + }, + ], + ]); + + const first = RelayPermission.getInstance(relays); + const second = RelayPermission.getInstance(relays); + + second.start(); + + expect(second).toBe(first); + expect(first.allow("messaging")).toBe(true); + expect(mockedOnPermissionsAdded).toHaveBeenCalledTimes(1); + expect(mockedOnPermissionsRemoved).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/relay/RelayPermission.ts b/src/relay/RelayPermission.ts index 20ce6d23..d8e4663e 100644 --- a/src/relay/RelayPermission.ts +++ b/src/relay/RelayPermission.ts @@ -10,22 +10,17 @@ export interface RelayPermissionValue { permissions?: Permissions; } -export default class RelayPermission { - private static _instance?: RelayPermission; +export const RelayPermissionGlobalKey = "adnbnRelayPermission"; +export default class RelayPermission { private permissions = new Map(); + private started = false; - public static getInstance(): RelayPermission { - return (this._instance ??= new RelayPermission()); + private constructor(relays: RelayOptionsMap) { + this.configure(relays); } - public static init(relays: RelayOptionsMap) { - if (this._instance || relays.size === 0) { - return; - } - - const instance = RelayPermission.getInstance(); - + private configure(relays: RelayOptionsMap): void { for (const [name, {declarative, method, matches}] of relays) { if (declarative === false && method === RelayMethod.Scripting) { console.warn( @@ -46,15 +41,40 @@ export default class RelayPermission { permissions: ["scripting"], }; - instance.set(name, {allow, permissions}); + this.set(name, {allow, permissions}); + } + } + + public static getInstance(relays: RelayOptionsMap): RelayPermission { + const current = globalThis[RelayPermissionGlobalKey] as RelayPermission | undefined; + + if (current) { + return current; } - const checkPermissions = async () => await instance.check(); + const instance = new RelayPermission(relays); + + globalThis[RelayPermissionGlobalKey] = instance; + + return instance.start(); + } + + public start(): this { + if (this.started) { + return this; + } + + this.started = true; + + const checkPermissions = (): void => { + void this.check().catch(error => console.error(error)); + }; onPermissionsAdded(checkPermissions); onPermissionsRemoved(checkPermissions); + checkPermissions(); - checkPermissions().catch(e => console.error(e)); + return this; } public set(name: string, value: Partial): this { @@ -114,6 +134,6 @@ export default class RelayPermission { } private async check(): Promise { - await Promise.allSettled(Object.keys(this.permissions).map(name => this.contains(name))); + await Promise.allSettled([...this.permissions.keys()].map(name => this.contains(name))); } } diff --git a/src/relay/adapters/RelayAdapter.ts b/src/relay/adapters/RelayAdapter.ts new file mode 100644 index 00000000..290f28ad --- /dev/null +++ b/src/relay/adapters/RelayAdapter.ts @@ -0,0 +1,20 @@ +import {RelayAllFrames, type RelayBatchOptions, type RelayCallOptions} from "@typing/relay"; + +export default abstract class RelayAdapter { + protected constructor( + protected readonly name: string, + protected readonly target: RelayCallOptions + ) {} + + protected isBatchTarget(target: RelayCallOptions): target is RelayBatchOptions { + return ( + target.allFrames === true || + target.allFrames === RelayAllFrames.Any || + target.allFrames === RelayAllFrames.All || + target.frameIds !== undefined || + target.documentIds !== undefined + ); + } + + public abstract invoke(args: any[], path?: string): Promise; +} diff --git a/src/relay/adapters/RelayMessagingAdapter.ts b/src/relay/adapters/RelayMessagingAdapter.ts new file mode 100644 index 00000000..e4681bf3 --- /dev/null +++ b/src/relay/adapters/RelayMessagingAdapter.ts @@ -0,0 +1,122 @@ +import {isRemoteMessageError} from "@message/error"; + +import type {MessageSendOptions} from "@typing/message"; +import {RelayAllFrames, RelayFrameErrorKind, type RelayAddressTarget, type RelayCallOptions} from "@typing/relay"; +import type {TransportMessage} from "@typing/transport"; + +import RelayDiscovery from "../discovery/RelayDiscovery"; +import RelayMessage from "../RelayMessage"; +import { + fulfilledRelayFrame, + messageErrorKind, + rejectedRelayFrame, + RelayFrameTimeoutError, + sortRelayFrameResults, + withRelayTimeout, +} from "../result"; +import RelayAdapter from "./RelayAdapter"; + +export default class RelayMessagingAdapter extends RelayAdapter { + private readonly discovery = new RelayDiscovery(); + private readonly message: TransportMessage; + + public constructor(name: string, target: RelayCallOptions) { + super(name, target); + + this.message = new RelayMessage(name); + } + + public async invoke(args: any[], path?: string): Promise { + if (!this.isBatchTarget(this.target)) { + const target = this.scalarTarget(); + + return withRelayTimeout( + this.message.send({path, args}, this.messageOptions(target)), + this.target.timeoutMs + ); + } + + if (this.target.allFrames === true || this.target.allFrames === RelayAllFrames.Any) { + const target = {tabId: this.target.tabId, allFrames: RelayAllFrames.Any} as const; + + try { + const result = await withRelayTimeout( + this.message.send({path, args}, {tabId: this.target.tabId}), + this.target.timeoutMs + ); + + return [fulfilledRelayFrame(target, result)]; + } catch (error) { + const kind = + error instanceof RelayFrameTimeoutError + ? RelayFrameErrorKind.Timeout + : isRemoteMessageError(error) + ? RelayFrameErrorKind.Remote + : messageErrorKind(error); + + return [rejectedRelayFrame(target, error, kind)]; + } + } + + const targets = await this.batchTargets(); + const results = await Promise.all( + targets.map(async target => { + try { + const result = await withRelayTimeout( + this.message.send({path, args}, this.messageOptions(target)), + this.target.timeoutMs + ); + + return fulfilledRelayFrame(target, result); + } catch (error) { + const kind = + error instanceof RelayFrameTimeoutError + ? RelayFrameErrorKind.Timeout + : isRemoteMessageError(error) + ? RelayFrameErrorKind.Remote + : messageErrorKind(error); + + return rejectedRelayFrame(target, error, kind); + } + }) + ); + + return sortRelayFrameResults(results); + } + + private scalarTarget(): RelayAddressTarget { + if (this.target.frameId !== undefined) { + return {tabId: this.target.tabId, frameId: this.target.frameId}; + } + + if (this.target.documentId !== undefined) { + return {tabId: this.target.tabId, documentId: this.target.documentId}; + } + + return {tabId: this.target.tabId, frameId: 0}; + } + + private async batchTargets(): Promise { + if (this.target.allFrames === RelayAllFrames.All) { + return this.discovery.discover(this.target.tabId); + } + + if (this.target.frameIds !== undefined) { + return this.target.frameIds.map(frameId => ({tabId: this.target.tabId, frameId})); + } + + return (this.target.documentIds ?? []).map(documentId => ({tabId: this.target.tabId, documentId})); + } + + private messageOptions(target: RelayAddressTarget): MessageSendOptions { + if (target.frameId !== undefined) { + return { + tabId: target.tabId, + frameId: target.frameId, + ...(target.documentId === undefined ? {} : {documentId: target.documentId}), + }; + } + + return {tabId: target.tabId, documentId: target.documentId}; + } +} diff --git a/src/relay/adapters/RelayScriptingAdapter.ts b/src/relay/adapters/RelayScriptingAdapter.ts new file mode 100644 index 00000000..e2e52e60 --- /dev/null +++ b/src/relay/adapters/RelayScriptingAdapter.ts @@ -0,0 +1,256 @@ +import injectScriptFactory, { + type InjectScriptContract, + type InjectScriptOptions, + type InjectScriptResult, + type InjectScriptResultTarget, + type InjectScriptTarget, + type JsonValue, +} from "@addon-core/inject-script"; + +import {restoreError} from "@message/error"; + +import type {MessageError} from "@typing/message"; +import { + RelayAllFrames, + RelayFrameErrorKind, + RelayGlobalKey, + type RelayCallOptions, + type RelayDocumentOptions, + type RelayFrameOptions, + type RelayFramesResult, + type RelayResultTarget, + type RelayScalarOptions, +} from "@typing/relay"; + +import {fulfilledRelayFrame, injectScriptErrorKind, rejectedRelayFrame, sortRelayFrameResults} from "../result"; +import RelayAdapter from "./RelayAdapter"; + +type RelayInvocationResult = + | { + ok: true; + hasResult: false; + } + | { + ok: true; + hasResult: true; + result: any; + } + | { + ok: false; + error: MessageError; + }; + +export default class RelayScriptingAdapter extends RelayAdapter { + private _injectScript?: InjectScriptContract; + + public constructor(name: string, target: RelayCallOptions) { + super(name, target); + } + + private get injectScript(): InjectScriptContract { + return (this._injectScript ??= injectScriptFactory(this.injectScriptOptions())); + } + + public async invoke(args: any[], path?: string): Promise { + const func = ( + name: string, + path: string | null, + args: JsonValue[], + key: string, + retryManager: boolean + ): Promise => { + const serialize = (error: unknown): MessageError => { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + ...(error.stack ? {stack: error.stack} : {}), + }; + } + + if (typeof error === "object" && error !== null) { + const record = error as Record; + + return { + name: typeof record.name === "string" ? record.name : "Error", + message: + typeof record.message === "string" + ? record.message + : (() => { + try { + return JSON.stringify(error) ?? String(error); + } catch { + return String(error); + } + })(), + ...(typeof record.stack === "string" ? {stack: record.stack} : {}), + }; + } + + return {name: "Error", message: String(error)}; + }; + + const invoke = (manager: any): Promise => { + try { + return Promise.resolve(manager.property(name, {path, args})).then( + result => + result === undefined + ? {ok: true as const, hasResult: false as const} + : {ok: true as const, hasResult: true as const, result}, + error => ({ok: false as const, error: serialize(error)}) + ); + } catch (error) { + return Promise.resolve({ok: false as const, error: serialize(error)}); + } + }; + + const manager = globalThis[key]; + + if (manager) { + // Keep the normal Scripting path synchronous until the remote method starts. + return invoke(manager); + } + + if (!retryManager) { + return Promise.reject(new Error("Relay manager not found.")); + } + + return new Promise((resolve, reject) => { + const maxAttempts = 10; + const delay = 300; + let attempts = 0; + + const findManager = () => { + const delayedManager = globalThis[key]; + + if (delayedManager) { + resolve(invoke(delayedManager)); + return; + } + + attempts++; + + if (attempts >= maxAttempts) { + reject(new Error(`Relay manager not found after ${maxAttempts} attempts.`)); + return; + } + + setTimeout(findManager, delay); + }; + + findManager(); + }); + }; + + const scriptArgs: [string, string | null, JsonValue[], string, boolean] = [ + this.name, + path ?? null, + args as JsonValue[], + RelayGlobalKey, + !this.isAnyFramesTarget(), + ]; + const outcomes = await this.injectScript.run(func, scriptArgs); + const results = this.normalize(outcomes); + + if (this.isAnyFramesTarget()) { + const result = results.find(result => result.status === "fulfilled") ?? results[0]; + + return result + ? [ + { + ...result, + target: {tabId: this.target.tabId, allFrames: RelayAllFrames.Any}, + }, + ] + : []; + } + + if (this.isBatchTarget(this.target)) { + return results; + } + + return this.unwrap(results); + } + + private normalize(outcomes: readonly InjectScriptResult[]): RelayFramesResult { + return sortRelayFrameResults( + outcomes.map(outcome => { + const target = this.resultTarget(outcome.target); + + if (!outcome.success) { + return rejectedRelayFrame(target, outcome.error, injectScriptErrorKind(outcome.error.kind)); + } + + if (!outcome.value.ok) { + return rejectedRelayFrame(target, outcome.value.error, RelayFrameErrorKind.Remote); + } + + return fulfilledRelayFrame(target, outcome.value.hasResult ? outcome.value.result : undefined); + }) + ); + } + + private resultTarget(target: InjectScriptResultTarget): RelayResultTarget { + if (target.allFrames === true) { + return { + tabId: target.tabId, + allFrames: this.target.allFrames === RelayAllFrames.All ? RelayAllFrames.All : RelayAllFrames.Any, + }; + } + + return target; + } + + private unwrap(results: RelayFramesResult): any { + const result = results[0]; + + if (!result) { + throw new Error(`Relay "${this.name}" did not return a frame result.`); + } + + if (result.status === "fulfilled") { + return result.result; + } + + throw restoreError(result.error); + } + + private injectScriptOptions(): InjectScriptOptions { + let injectTarget: InjectScriptTarget; + + if ( + this.target.allFrames === true || + this.target.allFrames === RelayAllFrames.Any || + this.target.allFrames === RelayAllFrames.All + ) { + injectTarget = {tabId: this.target.tabId, allFrames: true}; + } else if (this.target.frameIds !== undefined) { + injectTarget = {tabId: this.target.tabId, frameIds: this.target.frameIds}; + } else if (this.target.documentIds !== undefined) { + injectTarget = {tabId: this.target.tabId, documentIds: this.target.documentIds}; + } else if (this.isFrameTarget(this.target)) { + injectTarget = {tabId: this.target.tabId, frameIds: [this.target.frameId]}; + } else if (this.isDocumentTarget(this.target)) { + injectTarget = {tabId: this.target.tabId, documentIds: [this.target.documentId]}; + } else { + injectTarget = {tabId: (this.target as RelayScalarOptions).tabId}; + } + + return { + target: injectTarget, + ...(this.target.timeoutMs === undefined ? {} : {timeoutMs: this.target.timeoutMs}), + }; + } + + private isFrameTarget(target: RelayCallOptions): target is RelayFrameOptions { + return target.frameId !== undefined; + } + + private isDocumentTarget(target: RelayCallOptions): target is RelayDocumentOptions { + return target.documentId !== undefined; + } + + private isAnyFramesTarget(): boolean { + return this.target.allFrames === true || this.target.allFrames === RelayAllFrames.Any; + } +} diff --git a/src/relay/discovery/RelayDiscovery.test.ts b/src/relay/discovery/RelayDiscovery.test.ts new file mode 100644 index 00000000..5e54cdf7 --- /dev/null +++ b/src/relay/discovery/RelayDiscovery.test.ts @@ -0,0 +1,94 @@ +import {getAllFrames, getManifest} from "@addon-core/browser"; + +import RelayDiscovery, {RelayDiscoveryError} from "./RelayDiscovery"; + +const mockedGetAllFrames = getAllFrames as jest.MockedFunction; +const mockedGetManifest = getManifest as jest.MockedFunction; + +const manifest = { + manifest_version: 3, + name: "Relay test", + version: "1.0.0", +} satisfies ReturnType; + +describe("RelayDiscovery", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedGetAllFrames.mockReset(); + mockedGetManifest.mockReset(); + mockedGetManifest.mockReturnValue({...manifest, permissions: []}); + }); + + test("requires webNavigation for strict Messaging allFrames discovery", async () => { + const discovery = new RelayDiscovery(); + + await expect(discovery.discover(5)).rejects.toEqual( + expect.objectContaining>({ + name: "RelayDiscoveryError", + message: expect.stringContaining('requires the "webNavigation" permission'), + }) + ); + expect(mockedGetAllFrames).not.toHaveBeenCalled(); + }); + + test("reports an unavailable manifest instead of silently degrading discovery", async () => { + mockedGetManifest.mockImplementation(() => { + throw new Error("runtime.getManifest is unavailable"); + }); + const discovery = new RelayDiscovery(); + + await expect(discovery.discover(5)).rejects.toEqual( + expect.objectContaining>({ + name: "RelayDiscoveryError", + message: expect.stringContaining("could not verify"), + cause: expect.objectContaining({message: "runtime.getManifest is unavailable"}), + }) + ); + expect(mockedGetAllFrames).not.toHaveBeenCalled(); + }); + + test("returns deterministic unique targets through webNavigation", async () => { + mockedGetManifest.mockReturnValue({...manifest, permissions: ["webNavigation"]}); + const topFrame: chrome.webNavigation.GetAllFrameResultDetails = { + frameId: 0, + documentId: "document-0", + documentLifecycle: "active", + frameType: "outermost_frame", + parentFrameId: -1, + processId: 1, + errorOccurred: false, + url: "https://example.com/", + }; + const childFrame: chrome.webNavigation.GetAllFrameResultDetails = { + ...topFrame, + frameId: 2, + documentId: "document-2", + frameType: "sub_frame", + parentFrameId: 0, + parentDocumentId: "document-0", + url: "https://example.com/frame", + }; + mockedGetAllFrames.mockResolvedValue([childFrame, topFrame, childFrame]); + const discovery = new RelayDiscovery(); + + await expect(discovery.discover(5)).resolves.toEqual([ + {tabId: 5, frameId: 0, documentId: "document-0"}, + {tabId: 5, frameId: 2, documentId: "document-2"}, + ]); + expect(mockedGetAllFrames).toHaveBeenCalledWith(5); + }); + + test("reports webNavigation discovery failures", async () => { + mockedGetManifest.mockReturnValue({...manifest, permissions: ["webNavigation"]}); + mockedGetAllFrames.mockRejectedValue(new Error("No tab with id 5")); + const discovery = new RelayDiscovery(); + + await expect(discovery.discover(5)).rejects.toEqual( + expect.objectContaining>({ + name: "RelayDiscoveryError", + message: expect.stringContaining("webNavigation.getAllFrames"), + cause: expect.objectContaining({message: "No tab with id 5"}), + }) + ); + }); +}); diff --git a/src/relay/discovery/RelayDiscovery.ts b/src/relay/discovery/RelayDiscovery.ts new file mode 100644 index 00000000..45fcc6c2 --- /dev/null +++ b/src/relay/discovery/RelayDiscovery.ts @@ -0,0 +1,76 @@ +import {getAllFrames, getManifest} from "@addon-core/browser"; + +import type {RelayAddressTarget} from "@typing/relay"; + +export class RelayDiscoveryError extends Error { + public constructor(message: string, cause?: unknown) { + super(message, cause === undefined ? undefined : {cause}); + this.name = "RelayDiscoveryError"; + } +} + +/** Enumerates frame addresses for strict Messaging All calls, without checking whether Relay is registered. */ +export default class RelayDiscovery { + public async discover(tabId: number): Promise { + this.assertSupported(); + + try { + const frames = await getAllFrames(tabId); + + return this.normalize( + frames.map(frame => ({ + tabId, + frameId: frame.frameId, + ...(frame.documentId ? {documentId: frame.documentId} : {}), + })) + ); + } catch (error) { + throw new RelayDiscoveryError( + `Relay failed to discover frames in tab ${tabId} through webNavigation.getAllFrames().`, + error + ); + } + } + + private assertSupported(): void { + let permissions: string[]; + + try { + permissions = getManifest().permissions ?? []; + } catch (error) { + throw new RelayDiscoveryError( + 'Relay could not verify the "webNavigation" permission required for Messaging "allFrames".', + error + ); + } + + if (!permissions.includes("webNavigation")) { + throw new RelayDiscoveryError( + 'Messaging Relay with RelayAllFrames.All requires the "webNavigation" permission. Configure the Relay entrypoint with allFrames: RelayAllFrames.All so the framework can declare it automatically, or use RelayAllFrames.Any, "frameIds" or "documentIds".' + ); + } + } + + private normalize(targets: RelayAddressTarget[]): RelayAddressTarget[] { + const unique = new Map(); + + for (const target of targets) { + const key = + target.frameId === undefined + ? `${target.tabId}:document:${target.documentId}` + : `${target.tabId}:frame:${target.frameId}`; + + unique.set(key, target); + } + + return [...unique.values()].sort((a, b) => { + const frameDifference = (a.frameId ?? Number.MAX_SAFE_INTEGER) - (b.frameId ?? Number.MAX_SAFE_INTEGER); + + if (frameDifference !== 0) { + return frameDifference; + } + + return (a.documentId ?? "").localeCompare(b.documentId ?? ""); + }); + } +} diff --git a/src/relay/index.ts b/src/relay/index.ts index 6b13f9d2..b5838e8e 100644 --- a/src/relay/index.ts +++ b/src/relay/index.ts @@ -1,8 +1,6 @@ -import {ProxyRelay, RegisterRelay, Relay, type ProxyRelayParams} from "./providers"; +import Relay from "./providers/Relay"; -import type {TransportProxyTarget, TransportTarget} from "@transport/index"; - -export {type ProxyRelayParams, ProxyRelay, RegisterRelay}; +import type {TransportTarget} from "@typing/transport"; export interface RelayRegistry {} @@ -10,8 +8,6 @@ export type RelayName = Extract; export type RelayTarget = TransportTarget; -export type RelayProxyTarget = TransportProxyTarget; - export const getRelay = (name: N): RelayTarget => { return new Relay(name).get(); }; diff --git a/src/relay/providers/ProxyRelay.ts b/src/relay/providers/ProxyRelay.ts index 3db71807..19c6b744 100644 --- a/src/relay/providers/ProxyRelay.ts +++ b/src/relay/providers/ProxyRelay.ts @@ -1,116 +1,151 @@ -import injectScriptFactory, {type InjectScriptContract, type InjectScriptOptions} from "@addon-core/inject-script"; - import ProxyTransport from "@transport/ProxyTransport"; +import RelayAdapter from "../adapters/RelayAdapter"; +import RelayMessagingAdapter from "../adapters/RelayMessagingAdapter"; +import RelayScriptingAdapter from "../adapters/RelayScriptingAdapter"; import RelayManager from "../RelayManager"; -import RelayMessage from "../RelayMessage"; import RelayPermission from "../RelayPermission"; - import {isRelayContext} from "../utils"; -import {RelayGlobalKey, RelayMethod, RelayOptions} from "@typing/relay"; -import {DeepAsyncProxy} from "@typing/helpers"; -import {MessageSendOptions} from "@typing/message"; -import {TransportDictionary, TransportManager, TransportMessage, TransportName} from "@typing/transport"; +import type {RpcAsyncProxy} from "@typing/rpc"; +import {RelayAllFrames, RelayMethod, type RelayCallOptions, type RelayOptions} from "@typing/relay"; +import type {TransportDictionary, TransportManager, TransportName} from "@typing/transport"; -export type ProxyRelayParams = - | number - | (Omit & { - frameId?: number; - documentId?: string; - }); +export type ProxyRelayParams = number | RelayCallOptions; export default class ProxyRelay< N extends TransportName, - T = DeepAsyncProxy, + T = RpcAsyncProxy, > extends ProxyTransport { - private injectScript: InjectScriptContract; - private message: TransportMessage; + private static readonly SelectorKeys = ["allFrames", "frameId", "frameIds", "documentId", "documentIds"] as const; + + private _adapter?: RelayAdapter; + private _target?: RelayCallOptions; constructor( name: N, - protected options: RelayOptions, - protected params: ProxyRelayParams + protected readonly options: RelayOptions, + private readonly params: ProxyRelayParams, + private readonly permission: RelayPermission ) { super(name); + } + + private get adapter(): RelayAdapter { + return (this._adapter ??= + this.options.method === RelayMethod.Scripting + ? new RelayScriptingAdapter(this.name, this.target) + : new RelayMessagingAdapter(this.name, this.target)); + } + + private get target(): RelayCallOptions { + if (this._target) { + return this._target; + } + + const target: Record = + typeof this.params === "number" ? {tabId: this.params} : {...this.params}; + + this.validateTabId(target.tabId); + this.validateTimeout(target.timeoutMs); - this.message = new RelayMessage(name); + const selected = ProxyRelay.SelectorKeys.filter(key => target[key] !== undefined); + + if (selected.length > 1) { + this.invalid(`selectors are mutually exclusive; received ${selected.map(key => `"${key}"`).join(", ")}.`); + } + + if ( + target.allFrames !== undefined && + target.allFrames !== false && + target.allFrames !== true && + target.allFrames !== RelayAllFrames.Any && + target.allFrames !== RelayAllFrames.All + ) { + this.invalid('"allFrames" accepts only false, true, RelayAllFrames.Any or RelayAllFrames.All.'); + } + + if (target.frameId !== undefined) { + this.validateFrameId(target.frameId); + } + + if (target.frameIds !== undefined) { + this.validateArray(target.frameIds, "frameIds", (item, property) => this.validateFrameId(item, property)); + } + + if (target.documentId !== undefined) { + this.validateDocumentId(target.documentId); + } - this.injectScript = injectScriptFactory({ - ...(typeof params === "number" ? {tabId: params} : params), - timeFallback: 4000, - }); + if (target.documentIds !== undefined) { + this.validateArray(target.documentIds, "documentIds", (item, property) => + this.validateDocumentId(item, property) + ); + } + + return (this._target = target as unknown as RelayCallOptions); } protected manager(): TransportManager { return RelayManager.getInstance(); } - protected permission(): RelayPermission { - return RelayPermission.getInstance(); - } - protected async apply(args: any[], path?: string): Promise { - if (!this.permission().allow(this.name)) { - if (!(await this.permission().request(this.name))) { + if (!this.permission.allow(this.name)) { + if (!(await this.permission.request(this.name))) { throw new Error( `ProxyRelay: User denied required permissions for relay "${this.name}" at path "${path}". Cannot proceed with the operation.` ); } } - return this.options.method === RelayMethod.Scripting - ? this.scriptingApply(args, path) - : this.messagingApply(args, path); + return this.adapter.invoke(args, path); } - private async scriptingApply(args: any[], path?: string): Promise { - const func = async (name: string, path: string, args: any[], key: string) => { - try { - const awaitManager = async (maxAttempts = 10, delay = 300): Promise => { - for (let count = 0; count < maxAttempts; count++) { - const manager = globalThis[key]; - - if (manager) return manager; + private validateTabId(tabId: unknown): void { + if (!Number.isInteger(tabId) || (tabId as number) < 0) { + this.invalid('"tabId" must be a non-negative integer.'); + } + } - await new Promise(resolve => setTimeout(resolve, delay)); - } + private validateFrameId(frameId: unknown, property = "frameId"): void { + if (!Number.isInteger(frameId) || (frameId as number) < 0) { + this.invalid(`"${property}" must contain only non-negative integers.`); + } + } - throw new Error(`Relay manager not found after ${maxAttempts} attempts.`); - }; + private validateDocumentId(documentId: unknown, property = "documentId"): void { + if (typeof documentId !== "string" || documentId.trim() === "") { + this.invalid(`"${property}" must contain only non-empty strings.`); + } + } - const manager: RelayManager = await awaitManager(); + private validateTimeout(timeoutMs: unknown): void { + if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || (timeoutMs as number) <= 0)) { + this.invalid('"timeoutMs" must be greater than zero.'); + } + } - return await manager.property(name, {path, args}); - } catch (e) { - console.error( - `ProxyRelay.scriptingApply(): failed to access relay "${name}" at path "${path}" via injected script; manager with key "${key}" is unavailable or property not found. URL: ${document.location.href}`, - e - ); + private validateArray( + value: unknown, + property: "frameIds" | "documentIds", + validate: (item: unknown, property: string) => void + ): void { + if (!Array.isArray(value) || value.length === 0) { + this.invalid(`"${property}" must be a non-empty array.`); + } - throw e; - } - }; + const items = value as unknown[]; - const result = await this.injectScript.run(func, [this.name, path!, args, RelayGlobalKey]); + items.forEach(item => validate(item, property)); - return result?.[0]?.result; + if (new Set(items).size !== items.length) { + this.invalid(`"${property}" must not contain duplicate values.`); + } } - private async messagingApply(args: any[], path?: string): Promise { - const options: MessageSendOptions = - typeof this.params === "number" - ? { - tabId: this.params, - frameId: 0, - } - : { - tabId: this.params.tabId, - frameId: this.params.frameId || 0, - documentId: this.params.documentId, - }; - - return this.message.send({path, args}, options); + private invalid(message: string): never { + throw new TypeError(`Invalid Relay target: ${message}`); } public get(): T { @@ -120,6 +155,8 @@ export default class ProxyRelay< ); } + void this.target; + return super.get(); } } diff --git a/src/relay/providers/RegisterRelay.ts b/src/relay/providers/RegisterRelay.ts index 9a70ad3b..27571997 100644 --- a/src/relay/providers/RegisterRelay.ts +++ b/src/relay/providers/RegisterRelay.ts @@ -39,9 +39,9 @@ export default class< this.manager().add(this.name, relay); return relay; - } else { - return super.register(...args); } + + return super.register(...args); } public get(): T { diff --git a/src/relay/providers/Relay.test.ts b/src/relay/providers/Relay.test.ts index 274953f9..4ea8892d 100644 --- a/src/relay/providers/Relay.test.ts +++ b/src/relay/providers/Relay.test.ts @@ -1,22 +1,50 @@ -import ProxyRelay from "./ProxyRelay"; +import {getAllFrames, getManifest} from "@addon-core/browser"; + +import TransportMessage from "@transport/TransportMessage"; +import {markRemoteMessageError} from "@message/error"; + +import ProxyRelay, {type ProxyRelayParams} from "./ProxyRelay"; import RegisterRelay from "./RegisterRelay"; import RelayManager from "../RelayManager"; import RelayPermission from "../RelayPermission"; import {isRelayContext} from "../utils"; -import {RelayGlobalKey, RelayMethod, RelayOptions} from "@typing/relay"; -import {DeepAsyncProxy} from "@typing/helpers"; +import { + RelayAllFrames, + RelayBatchRpcProxy, + RelayFrameErrorKind, + RelayGlobalKey, + RelayMethod, + RelayOptions, +} from "@typing/relay"; +import {RpcAsyncProxy} from "@typing/rpc"; + +const mockedGetAllFrames = getAllFrames as jest.MockedFunction; +const mockedGetManifest = getManifest as jest.MockedFunction; +const activationOrder: string[] = []; +let relayPermission: RelayPermission; + +const manifest = { + manifest_version: 3, + name: "Relay test", + version: "1.0.0", +} satisfies ReturnType; beforeEach(async () => { jest.clearAllMocks(); + activationOrder.splice(0); RelayManager.getInstance().clear(); new RegisterRelay(relayName, RelayMethod.Scripting, () => MatchRelay).register(); - jest.spyOn(RelayPermission, "getInstance").mockReturnValue({ + relayPermission = { allow: jest.fn().mockReturnValue(true), - } as any); + request: jest.fn().mockResolvedValue(true), + } as unknown as RelayPermission; + + mockedGetManifest.mockReturnValue({...manifest, permissions: []}); + mockedGetAllFrames.mockResolvedValue([]); }); const MatchRelay = { @@ -24,6 +52,13 @@ const MatchRelay = { asyncSum: (a: number, b: number): Promise => { return new Promise(resolve => setTimeout(() => resolve(a + b), 100)); }, + activation: (): boolean => { + activationOrder.push("relay"); + return true; + }, + fail: (): never => { + throw new TypeError("Remote failure"); + }, one: 1, obj: { concat: (a: string, b: string): string => a + " " + b, @@ -32,7 +67,8 @@ const MatchRelay = { }; type RelayType = typeof MatchRelay; -type RelayProxyType = DeepAsyncProxy; +type RelayProxyType = RpcAsyncProxy; +type RelayBatchProxyType = RelayBatchRpcProxy; const relayName = "math"; @@ -41,12 +77,22 @@ const options: RelayOptions = { method: RelayMethod.Scripting, }; +const createProxyRelay = (relayOptions: RelayOptions, params: ProxyRelayParams) => { + return new ProxyRelay(relayName, relayOptions, params, relayPermission); +}; + const expectScriptInjection = (expected: Partial>) => { const [injection] = (chrome.scripting.executeScript as jest.Mock).mock.calls.at(-1); expect(injection).toEqual(expect.objectContaining(expected)); }; +const expectScriptTargets = (expected: chrome.scripting.InjectionTarget[]) => { + const targets = (chrome.scripting.executeScript as jest.Mock).mock.calls.map(([injection]) => injection.target); + + expect(targets).toEqual(expected); +}; + describe("ProxyRelay", () => { beforeEach(async () => { (isRelayContext as jest.Mock).mockReturnValue(false); @@ -55,7 +101,7 @@ describe("ProxyRelay", () => { test("throws an error when get() is called in content script context", async () => { (isRelayContext as jest.Mock).mockReturnValue(true); - const proxy = new ProxyRelay(relayName, options, 1); + const proxy = createProxyRelay(options, 1); expect(() => proxy.get()).toThrow( `You are trying to get proxy relay "${relayName}" from script content. You can get original relay instead` @@ -63,13 +109,31 @@ describe("ProxyRelay", () => { }); test("returns a proxy when called not in content script context", () => { - const relay = new ProxyRelay(relayName, options, 1).get(); + const relay = createProxyRelay(options, 1).get(); expect(relay["__proxy"]).toBe(true); }); + test("validates the target before returning a proxy", () => { + expect(() => createProxyRelay(options, {tabId: 1, frameId: 0, allFrames: true} as any).get()).toThrow( + "selectors are mutually exclusive" + ); + expect(() => createProxyRelay(options, {tabId: 1, frameIds: []} as any).get()).toThrow( + '"frameIds" must be a non-empty array' + ); + expect(() => createProxyRelay(options, {tabId: 1, frameIds: [0, 0]} as any).get()).toThrow( + '"frameIds" must not contain duplicate values' + ); + expect(() => createProxyRelay(options, {tabId: 1, documentIds: [""]} as any).get()).toThrow( + '"documentIds" must contain only non-empty strings' + ); + expect(() => createProxyRelay(options, {tabId: 1, allFrames: "invalid"} as any).get()).toThrow( + '"allFrames" accepts only false, true, RelayAllFrames.Any or RelayAllFrames.All' + ); + }); + test("invokes remote methods using chrome.scripting", async () => { - const relay = new ProxyRelay(relayName, options, 1).get(); + const relay = createProxyRelay(options, 1).get(); expect(await relay.sum(1, 2)).toBe(3); @@ -78,12 +142,12 @@ describe("ProxyRelay", () => { expectScriptInjection({ target: {tabId: 1}, func: expect.any(Function), - args: [relayName, "sum", [1, 2], RelayGlobalKey], + args: [relayName, "sum", [1, 2], RelayGlobalKey, true], }); }); test("accesses primitive value as method on the relay object", async () => { - const relay = new ProxyRelay(relayName, options, { + const relay = createProxyRelay(options, { tabId: 1, frameId: 2, }).get(); @@ -92,60 +156,488 @@ describe("ProxyRelay", () => { expectScriptInjection({ target: {tabId: 1, frameIds: [2]}, func: expect.any(Function), - args: [relayName, "one", [], RelayGlobalKey], + args: [relayName, "one", [], RelayGlobalKey, true], + }); + }); + + test("maps a scalar document target to Inject Script", async () => { + const relay = createProxyRelay(options, { + tabId: 1, + documentId: "document-2", + }).get(); + + expect(await relay.one()).toBe(1); + expectScriptInjection({ + target: {tabId: 1, documentIds: ["document-2"]}, + func: expect.any(Function), + args: [relayName, "one", [], RelayGlobalKey, true], }); }); test("accesses nested method or property ", async () => { - const relay = new ProxyRelay(relayName, options, 1).get(); + const relay = createProxyRelay(options, 1).get(); expect(await relay.obj.concat("Hello", "world")).toBe("Hello world"); expectScriptInjection({ target: {tabId: 1}, func: expect.any(Function), - args: [relayName, "obj.concat", ["Hello", "world"], RelayGlobalKey], + args: [relayName, "obj.concat", ["Hello", "world"], RelayGlobalKey, true], }); expect(await relay.obj.zero()).toBe(0); expectScriptInjection({ target: {tabId: 1}, func: expect.any(Function), - args: [relayName, "obj.zero", [], RelayGlobalKey], + args: [relayName, "obj.zero", [], RelayGlobalKey, true], }); }); test("calls async method on proxy and returns resolved value", async () => { - const relay = new ProxyRelay(relayName, options, 1).get(); + const relay = createProxyRelay(options, 1).get(); expect(await relay.asyncSum(1, 2)).toBe(3); }); - test("uses scriptingApply method when RelayMethod is 'scripting'", async () => { - const proxy = new ProxyRelay(relayName, options, 1); - const relay = proxy.get(); - const scriptingApplySpy = jest.spyOn(proxy as any, "scriptingApply"); - scriptingApplySpy.mockResolvedValue("scripting result"); + test("starts a Scripting relay method before returning control to the caller", async () => { + const relay = createProxyRelay(options, 1).get(); + + const result = relay.activation(); + activationOrder.push("caller"); + + expect(activationOrder).toEqual(["relay", "caller"]); + await expect(result).resolves.toBe(true); + }); + + test("returns fulfilled outcomes from every frame selected by Scripting", async () => { + const relay = createProxyRelay(options, { + tabId: 1, + frameIds: [2, 0], + }).get(); + + await expect(relay.sum(1, 2)).resolves.toEqual([ + {target: {tabId: 1, frameId: 0, documentId: "document-0"}, status: "fulfilled", result: 3}, + {target: {tabId: 1, frameId: 2, documentId: "document-2"}, status: "fulfilled", result: 3}, + ]); + + expectScriptTargets([ + {tabId: 1, frameIds: [2]}, + {tabId: 1, frameIds: [0]}, + ]); + }); + + test("returns addressed outcomes for documentIds selected by Scripting", async () => { + const relay = createProxyRelay(options, { + tabId: 1, + documentIds: ["document-2", "document-0"], + }).get(); - const result = await relay.sum(1, 2); + await expect(relay.sum(1, 2)).resolves.toEqual([ + { + target: {tabId: 1, frameId: 0, documentId: "document-0"}, + status: "fulfilled", + result: 3, + }, + { + target: {tabId: 1, frameId: 2, documentId: "document-2"}, + status: "fulfilled", + result: 3, + }, + ]); + + expectScriptTargets([ + {tabId: 1, documentIds: ["document-2"]}, + {tabId: 1, documentIds: ["document-0"]}, + ]); + }); - expect(scriptingApplySpy).toHaveBeenCalledWith([1, 2], "sum"); - expect(result).toBe("scripting result"); + test("normalizes an unobservable Scripting frame outcome", async () => { + (chrome.scripting.executeScript as jest.Mock).mockImplementationOnce(async () => [{frameId: 0}]); + const relay = createProxyRelay(options, { + tabId: 1, + frameIds: [0], + }).get(); - scriptingApplySpy.mockRestore(); + await expect(relay.sum(1, 2)).resolves.toEqual([ + { + target: {tabId: 1, frameId: 0}, + status: "rejected", + error: expect.objectContaining({ + kind: RelayFrameErrorKind.Unobservable, + message: "The browser did not expose an observable injected function result.", + }), + }, + ]); }); - test("uses messagingApply method when RelayMethod is 'messaging'", async () => { - const proxy = new ProxyRelay(relayName, {...options, method: RelayMethod.Messaging}, 1); - const relay = proxy.get(); - const messagingApplySpy = jest.spyOn(proxy as any, "messagingApply"); - messagingApplySpy.mockResolvedValue("messaging result"); + test("keeps a target-gone failure isolated in an explicit Scripting batch", async () => { + (chrome.scripting.executeScript as jest.Mock) + .mockImplementationOnce(async injection => [ + { + frameId: 0, + result: await injection.func(...injection.args), + }, + ]) + .mockImplementationOnce(async () => { + throw new Error("No frame with id 2 in tab 1"); + }); + const relay = createProxyRelay(options, { + tabId: 1, + frameIds: [0, 2], + }).get(); - const result = await relay.sum(1, 2); + await expect(relay.sum(1, 2)).resolves.toEqual([ + {target: {tabId: 1, frameId: 0}, status: "fulfilled", result: 3}, + { + target: {tabId: 1, frameId: 2}, + status: "rejected", + error: expect.objectContaining({ + kind: RelayFrameErrorKind.TargetGone, + message: "No frame with id 2 in tab 1", + }), + }, + ]); + }); - expect(messagingApplySpy).toHaveBeenCalledWith([1, 2], "sum"); - expect(result).toBe("messaging result"); + test.each([true, RelayAllFrames.Any] as const)( + "returns one operation outcome for Scripting allFrames %s", + async allFrames => { + const relay = createProxyRelay(options, { + tabId: 1, + allFrames, + }).get(); + + await expect(relay.fail()).resolves.toEqual([ + { + target: {tabId: 1, allFrames: RelayAllFrames.Any}, + status: "rejected", + error: expect.objectContaining({ + kind: RelayFrameErrorKind.Remote, + name: "TypeError", + message: "Remote failure", + }), + }, + ]); + + expectScriptInjection({target: {tabId: 1, allFrames: true}}); + expect(mockedGetAllFrames).not.toHaveBeenCalled(); + } + ); + + test.each([true, RelayAllFrames.Any] as const)( + "returns a fulfilled Scripting outcome for allFrames %s when another frame is rejected", + async allFrames => { + (chrome.scripting.executeScript as jest.Mock).mockResolvedValueOnce([ + {frameId: 0, error: new Error("Relay manager not found.")}, + {frameId: 2, result: {ok: true, hasResult: true, result: 3}}, + ]); + const relay = createProxyRelay(options, { + tabId: 1, + allFrames, + }).get(); + + await expect(relay.sum(1, 2)).resolves.toEqual([ + { + target: {tabId: 1, allFrames: RelayAllFrames.Any}, + status: "fulfilled", + result: 3, + }, + ]); + } + ); + + test.each([true, RelayAllFrames.Any] as const)( + "does not retry a missing Relay manager for Scripting allFrames %s", + async allFrames => { + (chrome.scripting.executeScript as jest.Mock).mockImplementationOnce(async injection => { + const injectedArgs = [...injection.args]; + injectedArgs[3] = `${RelayGlobalKey}Missing`; + const setTimeoutSpy = jest.spyOn(globalThis, "setTimeout"); + const execution = injection.func(...injectedArgs); + + expect(injectedArgs.at(-1)).toBe(false); + expect(setTimeoutSpy).not.toHaveBeenCalled(); + setTimeoutSpy.mockRestore(); + + const error = await execution.catch((error: unknown) => error); + + return [{frameId: 0, error}]; + }); + const relay = createProxyRelay(options, { + tabId: 1, + allFrames, + }).get(); + + await expect(relay.sum(1, 2)).resolves.toEqual([ + { + target: {tabId: 1, allFrames: RelayAllFrames.Any}, + status: "rejected", + error: expect.objectContaining({ + kind: RelayFrameErrorKind.Execution, + message: "Relay manager not found.", + }), + }, + ]); + } + ); + + test("keeps manager retries for addressed Scripting calls", async () => { + (chrome.scripting.executeScript as jest.Mock).mockImplementationOnce(async injection => { + const injectedArgs = [...injection.args]; + injectedArgs[3] = `${RelayGlobalKey}Missing`; + const setTimeoutSpy = jest + .spyOn(globalThis, "setTimeout") + .mockImplementation(() => 0 as unknown as ReturnType); + + injection.func(...injectedArgs); + + expect(injectedArgs.at(-1)).toBe(true); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 300); + setTimeoutSpy.mockRestore(); + + return [{frameId: 0, error: new Error("Relay manager not found.")}]; + }); + const relay = createProxyRelay(options, {tabId: 1, frameId: 0}).get(); - messagingApplySpy.mockRestore(); + await expect(relay.sum(1, 2)).rejects.toThrow("Relay manager not found."); + }); + + test("returns every Scripting outcome for RelayAllFrames.All", async () => { + const relay = createProxyRelay(options, { + tabId: 1, + allFrames: RelayAllFrames.All, + }).get(); + + await expect(relay.fail()).resolves.toEqual([ + { + target: {tabId: 1, frameId: 0, documentId: "document-0"}, + status: "rejected", + error: expect.objectContaining({ + kind: RelayFrameErrorKind.Remote, + name: "TypeError", + message: "Remote failure", + }), + }, + { + target: {tabId: 1, frameId: 2, documentId: "document-2"}, + status: "rejected", + error: expect.objectContaining({ + kind: RelayFrameErrorKind.Remote, + name: "TypeError", + message: "Remote failure", + }), + }, + ]); + + expectScriptInjection({target: {tabId: 1, allFrames: true}}); + expect(mockedGetAllFrames).not.toHaveBeenCalled(); + }); + + test("checks Relay permission once for a batch call", async () => { + const permission = { + allow: jest.fn().mockReturnValue(true), + request: jest.fn(), + }; + relayPermission = permission as unknown as RelayPermission; + + const relay = createProxyRelay(options, { + tabId: 1, + allFrames: true, + }).get(); + + await relay.sum(1, 2); + + expect(permission.allow).toHaveBeenCalledTimes(1); + expect(permission.request).not.toHaveBeenCalled(); + }); + + test("fans Messaging calls out to explicit frameIds and keeps partial failures", async () => { + const send = jest.spyOn(TransportMessage.prototype, "send").mockImplementation(async (_data, target) => { + const frameId = typeof target === "object" ? target.frameId : undefined; + + if (frameId === 2) { + throw new Error("Frame with ID 2 was removed"); + } + + return frameId; + }); + const relay = createProxyRelay( + {...options, method: RelayMethod.Messaging}, + {tabId: 1, frameIds: [2, 0]} + ).get(); + + await expect(relay.sum(1, 2)).resolves.toEqual([ + {target: {tabId: 1, frameId: 0}, status: "fulfilled", result: 0}, + { + target: {tabId: 1, frameId: 2}, + status: "rejected", + error: expect.objectContaining({ + kind: RelayFrameErrorKind.TargetGone, + message: "Frame with ID 2 was removed", + }), + }, + ]); + + expect(send).toHaveBeenCalledTimes(2); + send.mockRestore(); + }); + + test("returns a per-frame timeout from Messaging", async () => { + const send = jest.spyOn(TransportMessage.prototype, "send").mockImplementation(() => new Promise(() => {})); + const relay = createProxyRelay( + {...options, method: RelayMethod.Messaging}, + {tabId: 1, frameIds: [2], timeoutMs: 5} + ).get(); + + await expect(relay.sum(1, 2)).resolves.toEqual([ + { + target: {tabId: 1, frameId: 2}, + status: "rejected", + error: expect.objectContaining({ + kind: RelayFrameErrorKind.Timeout, + name: "RelayFrameTimeoutError", + }), + }, + ]); + + send.mockRestore(); + }); + + test("passes timeoutMs to Inject Script", async () => { + (chrome.scripting.executeScript as jest.Mock).mockImplementationOnce(() => new Promise(() => {})); + const relay = createProxyRelay(options, { + tabId: 1, + frameIds: [0], + timeoutMs: 5, + }).get(); + + await expect(relay.sum(1, 2)).resolves.toEqual([ + { + target: {tabId: 1, frameId: 0}, + status: "rejected", + error: expect.objectContaining({ + kind: RelayFrameErrorKind.Timeout, + message: "Script execution timed out after 5 ms.", + }), + }, + ]); + }); + + test("classifies restored remote Messaging errors structurally", async () => { + const send = jest + .spyOn(TransportMessage.prototype, "send") + .mockRejectedValue(markRemoteMessageError(new TypeError("Remote failure"))); + const relay = createProxyRelay( + {...options, method: RelayMethod.Messaging}, + {tabId: 1, frameIds: [0]} + ).get(); + + await expect(relay.fail()).resolves.toEqual([ + { + target: {tabId: 1, frameId: 0}, + status: "rejected", + error: expect.objectContaining({ + kind: RelayFrameErrorKind.Remote, + name: "TypeError", + message: "Remote failure", + }), + }, + ]); + + send.mockRestore(); + }); + + test.each([true, RelayAllFrames.Any] as const)( + "returns one native Messaging outcome for allFrames %s without discovery", + async allFrames => { + const send = jest.spyOn(TransportMessage.prototype, "send").mockResolvedValue(3); + const relay = createProxyRelay( + {...options, method: RelayMethod.Messaging}, + {tabId: 1, allFrames} + ).get(); + + await expect(relay.sum(1, 2)).resolves.toEqual([ + { + target: {tabId: 1, allFrames: RelayAllFrames.Any}, + status: "fulfilled", + result: 3, + }, + ]); + + expect(mockedGetAllFrames).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledWith({path: "sum", args: [1, 2]}, {tabId: 1}); + send.mockRestore(); + } + ); + + test("keeps allFrames false on the scalar top-frame contract", async () => { + const send = jest.spyOn(TransportMessage.prototype, "send").mockResolvedValue(3); + const relay = createProxyRelay( + {...options, method: RelayMethod.Messaging}, + {tabId: 1, allFrames: false} + ).get(); + + await expect(relay.sum(1, 2)).resolves.toBe(3); + + expect(mockedGetAllFrames).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledWith({path: "sum", args: [1, 2]}, {tabId: 1, frameId: 0}); + send.mockRestore(); + }); + + test("discovers strict Messaging allFrames targets through webNavigation", async () => { + mockedGetManifest.mockReturnValue({...manifest, permissions: ["webNavigation"]}); + const topFrame: chrome.webNavigation.GetAllFrameResultDetails = { + frameId: 0, + documentId: "document-0", + documentLifecycle: "active", + frameType: "outermost_frame", + parentFrameId: -1, + processId: 1, + errorOccurred: false, + url: "https://example.com/", + }; + const childFrame: chrome.webNavigation.GetAllFrameResultDetails = { + ...topFrame, + frameId: 3, + documentId: "document-3", + frameType: "sub_frame", + parentFrameId: 0, + parentDocumentId: "document-0", + url: "https://example.com/frame", + }; + mockedGetAllFrames.mockResolvedValue([childFrame, topFrame]); + const send = jest.spyOn(TransportMessage.prototype, "send").mockImplementation(async (_data, target) => { + return typeof target === "object" ? target.frameId : undefined; + }); + const relay = createProxyRelay( + {...options, method: RelayMethod.Messaging}, + {tabId: 1, allFrames: RelayAllFrames.All} + ).get(); + + await expect(relay.sum(1, 2)).resolves.toEqual([ + { + target: {tabId: 1, frameId: 0, documentId: "document-0"}, + status: "fulfilled", + result: 0, + }, + { + target: {tabId: 1, frameId: 3, documentId: "document-3"}, + status: "fulfilled", + result: 3, + }, + ]); + + expect(mockedGetAllFrames).toHaveBeenCalledWith(1); + expect(send).toHaveBeenNthCalledWith( + 1, + {path: "sum", args: [1, 2]}, + {tabId: 1, frameId: 0, documentId: "document-0"} + ); + expect(send).toHaveBeenNthCalledWith( + 2, + {path: "sum", args: [1, 2]}, + {tabId: 1, frameId: 3, documentId: "document-3"} + ); + send.mockRestore(); }); }); diff --git a/src/relay/result.ts b/src/relay/result.ts new file mode 100644 index 00000000..b72ca9e1 --- /dev/null +++ b/src/relay/result.ts @@ -0,0 +1,101 @@ +import {serializeError} from "@message/error"; + +import {InjectScriptTargetErrorKind} from "@addon-core/inject-script"; + +import { + RelayFrameErrorKind, + type RelayFrameResult, + type RelayFramesResult, + type RelayResultTarget, +} from "@typing/relay"; + +export class RelayFrameTimeoutError extends Error { + public constructor(public readonly timeoutMs: number) { + super(`Relay frame did not respond within ${timeoutMs}ms.`); + this.name = "RelayFrameTimeoutError"; + } +} + +export const fulfilledRelayFrame = (target: RelayResultTarget, result: T): RelayFrameResult => ({ + target, + status: "fulfilled", + result, +}); + +export const rejectedRelayFrame = ( + target: RelayResultTarget, + error: unknown, + kind: RelayFrameErrorKind +): RelayFrameResult => ({ + target, + status: "rejected", + error: { + kind, + ...serializeError(error), + }, +}); + +export const messageErrorKind = (error: unknown): RelayFrameErrorKind => { + const message = error instanceof Error ? error.message : String(error); + + if ( + /receiving end does not exist|no matching message handler|frame.*(?:removed|not found)|no frame with id|invalid frame id/i.test( + message + ) + ) { + return RelayFrameErrorKind.TargetGone; + } + + return RelayFrameErrorKind.Delivery; +}; + +export const injectScriptErrorKind = (kind: `${InjectScriptTargetErrorKind}`): RelayFrameErrorKind => { + switch (kind) { + case InjectScriptTargetErrorKind.Execution: + return RelayFrameErrorKind.Execution; + case InjectScriptTargetErrorKind.Delivery: + return RelayFrameErrorKind.Delivery; + case InjectScriptTargetErrorKind.Timeout: + return RelayFrameErrorKind.Timeout; + case InjectScriptTargetErrorKind.TargetGone: + return RelayFrameErrorKind.TargetGone; + case InjectScriptTargetErrorKind.Unobservable: + return RelayFrameErrorKind.Unobservable; + default: + throw new TypeError(`Unsupported Inject Script target error kind: ${kind}`); + } +}; + +export const withRelayTimeout = (promise: Promise, timeoutMs?: number): Promise => { + if (timeoutMs === undefined) { + return promise; + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new RelayFrameTimeoutError(timeoutMs)), timeoutMs); + + promise.then( + result => { + clearTimeout(timeout); + resolve(result); + }, + error => { + clearTimeout(timeout); + reject(error); + } + ); + }); +}; + +export const sortRelayFrameResults = (results: RelayFramesResult): RelayFramesResult => { + return [...results].sort((a, b) => { + const frameDifference = + (a.target.frameId ?? Number.MAX_SAFE_INTEGER) - (b.target.frameId ?? Number.MAX_SAFE_INTEGER); + + if (frameDifference !== 0) { + return frameDifference; + } + + return (a.target.documentId ?? "").localeCompare(b.target.documentId ?? ""); + }); +}; diff --git a/src/sandbox/index.ts b/src/sandbox/index.ts index 64c55b14..9fb2406f 100644 --- a/src/sandbox/index.ts +++ b/src/sandbox/index.ts @@ -1,6 +1,6 @@ import {ProxySandbox, RegisterSandbox} from "./providers"; -import type {DeepAsyncProxy} from "@typing/helpers"; +import type {RpcAsyncProxy} from "@typing/rpc"; export {ProxySandbox, RegisterSandbox}; @@ -8,4 +8,4 @@ export interface SandboxRegistry {} export type SandboxName = Extract; -export type SandboxProxyTarget = DeepAsyncProxy; +export type SandboxProxyTarget = RpcAsyncProxy; diff --git a/src/sandbox/providers/ProxySandbox.ts b/src/sandbox/providers/ProxySandbox.ts index e960682c..5a91fc1c 100644 --- a/src/sandbox/providers/ProxySandbox.ts +++ b/src/sandbox/providers/ProxySandbox.ts @@ -3,11 +3,11 @@ import ProxyTransport from "@transport/ProxyTransport"; import SandboxManager from "../SandboxManager"; import SandboxMessage from "../SandboxMessage"; -import type {DeepAsyncProxy} from "@typing/helpers"; +import type {RpcAsyncProxy} from "@typing/rpc"; import type {SandboxParameters} from "@typing/sandbox"; import type {TransportDictionary, TransportManager, TransportName} from "@typing/transport"; -export default class> extends ProxyTransport { +export default class> extends ProxyTransport { constructor( name: N, private readonly parameters: SandboxParameters diff --git a/src/service/providers/ProxyService.context.test.ts b/src/service/providers/ProxyService.context.test.ts index 739e927e..4b52ca3e 100644 --- a/src/service/providers/ProxyService.context.test.ts +++ b/src/service/providers/ProxyService.context.test.ts @@ -45,9 +45,11 @@ describe("ProxyService context detection", () => { test("creates a service proxy in an offscreen-like context with no runtime.getManifest", async () => { const expectedResult = "background:value"; - const sendMessage = jest.fn((_message, callback) => { - callback({[MessageResultEnvelopeProperty]: true, ok: true, payload: expectedResult}); - }); + const sendMessage = jest.fn, Parameters>( + (_message, callback) => { + callback({[MessageResultEnvelopeProperty]: true, ok: true, payload: expectedResult}); + } + ); setRuntime({sendMessage}); setExtensionDocument("/offscreen.html"); @@ -72,13 +74,15 @@ describe("ProxyService context detection", () => { }); test("restores serialized background errors in an offscreen-like context", async () => { - const sendMessage = jest.fn((_message, callback) => { - callback({ - [MessageResultEnvelopeProperty]: true, - ok: false, - error: {name: "TypeError", message: "background failed"}, - }); - }); + const sendMessage = jest.fn, Parameters>( + (_message, callback) => { + callback({ + [MessageResultEnvelopeProperty]: true, + ok: false, + error: {name: "TypeError", message: "background failed"}, + }); + } + ); setRuntime({sendMessage}); setExtensionDocument("/offscreen.html"); diff --git a/src/service/providers/ProxyService.ts b/src/service/providers/ProxyService.ts index c871a31c..96f15534 100644 --- a/src/service/providers/ProxyService.ts +++ b/src/service/providers/ProxyService.ts @@ -5,10 +5,10 @@ import ProxyTransport from "@transport/ProxyTransport"; import ServiceManager from "../ServiceManager"; import ServiceMessage from "../ServiceMessage"; -import type {DeepAsyncProxy} from "@typing/helpers"; +import type {RpcAsyncProxy} from "@typing/rpc"; import type {TransportDictionary, TransportManager, TransportMessage, TransportName} from "@typing/transport"; -export default class> extends ProxyTransport { +export default class> extends ProxyTransport { protected message: TransportMessage; constructor(name: N) { diff --git a/src/service/providers/Service.test.ts b/src/service/providers/Service.test.ts index 18dcab11..ffca1087 100644 --- a/src/service/providers/Service.test.ts +++ b/src/service/providers/Service.test.ts @@ -6,7 +6,7 @@ import ProxyService from "./ProxyService"; import RegisterService from "./RegisterService"; import ServiceManager from "../ServiceManager"; -import {DeepAsyncProxy} from "@typing/helpers"; +import {RpcAsyncProxy} from "@typing/rpc"; import {MessageTypeSeparator} from "@typing/message"; jest.mock("@addon-core/browser", () => { @@ -39,7 +39,7 @@ const MatchService = { }; type ServiceType = typeof MatchService; -type ServiceProxyType = DeepAsyncProxy; +type ServiceProxyType = RpcAsyncProxy; const serviceName = "math"; diff --git a/src/transport/ProxyTransport.ts b/src/transport/ProxyTransport.ts index 9d34f6f7..789d3c77 100644 --- a/src/transport/ProxyTransport.ts +++ b/src/transport/ProxyTransport.ts @@ -1,12 +1,12 @@ import BaseTransport from "./BaseTransport"; -import type {DeepAsyncProxy} from "@typing/helpers"; +import type {RpcAsyncProxy} from "@typing/rpc"; import type {TransportDictionary, TransportName} from "@typing/transport"; -export default abstract class< - N extends TransportName, - T = DeepAsyncProxy, -> extends BaseTransport { +export default abstract class> extends BaseTransport< + N, + T +> { protected abstract apply(args: any[], path?: string): any; protected createProxy(path?: string): T { diff --git a/src/transport/RegisterTransport.test.ts b/src/transport/RegisterTransport.test.ts index 22493aa8..1344bc16 100644 --- a/src/transport/RegisterTransport.test.ts +++ b/src/transport/RegisterTransport.test.ts @@ -11,8 +11,14 @@ class TestMessage implements TransportMessage { public send(): void {} - public watch(handler: (data: TransportMessageData, sender: MessageSender) => any): void { + public watch(handler: (data: TransportMessageData, sender: MessageSender) => any): () => void { this.handler = handler; + + return () => { + if (this.handler === handler) { + this.handler = undefined; + } + }; } public dispatch(data: TransportMessageData, sender: MessageSender): Promise { diff --git a/src/transport/index.ts b/src/transport/index.ts index 66d4682a..b37e76d9 100644 --- a/src/transport/index.ts +++ b/src/transport/index.ts @@ -1,8 +1,10 @@ -import type {DeepAsyncProxy} from "@typing/helpers"; +import type {RpcAsyncProxy} from "@typing/rpc"; + +export type {RpcAsyncProxy, RpcAsyncProxyObject} from "@typing/rpc"; export type TransportTarget = T[K]; -export type TransportProxyTarget = DeepAsyncProxy; +export type TransportProxyTarget = RpcAsyncProxy; export type { TransportDefinition, diff --git a/src/types/helpers.ts b/src/types/helpers.ts index 7fc4438b..4e420431 100644 --- a/src/types/helpers.ts +++ b/src/types/helpers.ts @@ -9,13 +9,3 @@ export type PickNonFunctionProperties = { export type ExcludeFunctionsFromProperties = { [K in keyof T]: ExcludeFunction; }; - -export type DeepAsyncProxy = { - [K in keyof T]: T[K] extends (...args: any[]) => any - ? (...args: Parameters) => Promise>> - : T[K] extends object - ? DeepAsyncProxyObject - : () => Promise>; -}; - -export type DeepAsyncProxyObject = (() => Promise>) & DeepAsyncProxy; diff --git a/src/types/relay.ts b/src/types/relay.ts index b8d9cbd3..6f8c6fad 100644 --- a/src/types/relay.ts +++ b/src/types/relay.ts @@ -1,7 +1,9 @@ import {EntrypointOptions} from "@typing/entrypoint"; import {ContentScriptConfig, ContentScriptContext, ContentScriptDefinition} from "@typing/content"; -import {TransportConfig, TransportDefinition, TransportType} from "@typing/transport"; +import {TransportConfig, TransportDefinition, TransportType, type TransportProxyTarget} from "@typing/transport"; import {Awaiter} from "@typing/helpers"; +import type {MessageError} from "@typing/message"; +import type {RelayRegistry} from "@relay/index"; export const RelayGlobalKey = "adnbnRelay"; @@ -10,7 +12,166 @@ export enum RelayMethod { Messaging = "messaging", } -export interface RelayConfig extends TransportConfig, ContentScriptConfig { +export enum RelayAllFrames { + /** + * Executes the method in every frame reached by the selected transport and returns one outcome: + * Scripting prefers a fulfilled result, while Messaging returns the first response. + * + * Warning: mutating methods can execute in multiple frames even though only one outcome is returned. + */ + Any = "any", + + /** Executes the method in every discovered frame and returns an outcome for each frame. */ + All = "all", +} + +export enum RelayFrameErrorKind { + Remote = "remote", + Execution = "execution", + Delivery = "delivery", + Timeout = "timeout", + TargetGone = "target-gone", + Unobservable = "unobservable", +} + +export type RelayNonEmptyReadonlyArray = readonly [T, ...T[]]; + +export interface RelayExecutionOptions { + /** Maximum execution time per addressed Messaging frame or for the Inject Script operation. */ + timeoutMs?: number; +} + +interface RelayTargetBase extends RelayExecutionOptions { + tabId: number; +} + +export interface RelayTopFrameOptions extends RelayTargetBase { + allFrames?: false; + frameId?: never; + frameIds?: never; + documentId?: never; + documentIds?: never; +} + +export interface RelayFrameOptions extends RelayTargetBase { + frameId: number; + allFrames?: never; + frameIds?: never; + documentId?: never; + documentIds?: never; +} + +export interface RelayFramesOptions extends RelayTargetBase { + frameIds: RelayNonEmptyReadonlyArray; + allFrames?: never; + frameId?: never; + documentId?: never; + documentIds?: never; +} + +export interface RelayDocumentOptions extends RelayTargetBase { + documentId: string; + allFrames?: never; + frameId?: never; + frameIds?: never; + documentIds?: never; +} + +export interface RelayDocumentsOptions extends RelayTargetBase { + documentIds: RelayNonEmptyReadonlyArray; + allFrames?: never; + frameId?: never; + frameIds?: never; + documentId?: never; +} + +/** + * Broadcasts the call to all frames without requiring an exhaustive frame list and returns one outcome. + * `true` is an alias for `RelayAllFrames.Any`. + * + * Warning: mutating methods can execute in multiple frames even though only one outcome is returned. + */ +export interface RelayAnyFramesOptions extends RelayTargetBase { + allFrames: true | RelayAllFrames.Any; + frameId?: never; + frameIds?: never; + documentId?: never; + documentIds?: never; +} + +export interface RelayEveryFrameOptions extends RelayTargetBase { + allFrames: RelayAllFrames.All; + frameId?: never; + frameIds?: never; + documentId?: never; + documentIds?: never; +} + +export type RelayAllFramesOptions = RelayAnyFramesOptions | RelayEveryFrameOptions; + +export type RelayScalarOptions = RelayTopFrameOptions | RelayFrameOptions | RelayDocumentOptions; + +export type RelayBatchOptions = RelayFramesOptions | RelayDocumentsOptions | RelayAllFramesOptions; + +export type RelayCallOptions = RelayScalarOptions | RelayBatchOptions; + +export type RelayAddressTarget = + | { + tabId: number; + frameId: number; + documentId?: string; + allFrames?: never; + } + | { + tabId: number; + frameId?: never; + documentId: string; + allFrames?: never; + }; + +export type RelayOperationTarget = { + tabId: number; + allFrames: RelayAllFrames; + frameId?: never; + documentId?: never; +}; + +export type RelayResultTarget = RelayAddressTarget | RelayOperationTarget; + +export interface RelayFrameError extends MessageError { + kind: RelayFrameErrorKind; +} + +export type RelayFrameResult = + | { + target: RelayResultTarget; + status: "fulfilled"; + result: T; + } + | { + target: RelayResultTarget; + status: "rejected"; + error: RelayFrameError; + }; + +export type RelayFramesResult = readonly RelayFrameResult[]; + +export type RelayBatchRpcProxy = { + [K in keyof T]: T[K] extends (...args: any[]) => any + ? (...args: Parameters) => Promise>>> + : T[K] extends object + ? RelayBatchRpcProxyObject + : () => Promise>>; +}; + +export type RelayBatchRpcProxyObject = (() => Promise>>) & RelayBatchRpcProxy; + +export type RelayProxyTarget = TransportProxyTarget; + +export type RelayBatchProxyTarget = RelayBatchRpcProxy; + +export interface RelayConfig extends TransportConfig, Omit { + allFrames?: boolean | RelayAllFrames; method?: RelayMethod; } @@ -29,7 +190,7 @@ export type RelayMainHandler = ( export interface RelayDefinition extends Omit, "main">, - Omit, + Omit, RelayEntrypointOptions { main?: RelayMainHandler; } diff --git a/src/types/rpc.ts b/src/types/rpc.ts new file mode 100644 index 00000000..b8aeccda --- /dev/null +++ b/src/types/rpc.ts @@ -0,0 +1,9 @@ +export type RpcAsyncProxy = { + [K in keyof T]: T[K] extends (...args: any[]) => any + ? (...args: Parameters) => Promise>> + : T[K] extends object + ? RpcAsyncProxyObject + : () => Promise>; +}; + +export type RpcAsyncProxyObject = (() => Promise>) & RpcAsyncProxy; diff --git a/src/types/transport.ts b/src/types/transport.ts index b4bffade..0d0bcd60 100644 --- a/src/types/transport.ts +++ b/src/types/transport.ts @@ -1,8 +1,9 @@ import {Required} from "utility-types"; import {EntrypointOptions} from "@typing/entrypoint"; -import {Awaiter, DeepAsyncProxy} from "@typing/helpers"; +import {Awaiter} from "@typing/helpers"; import {MessageSender, MessageSendOptions} from "@typing/message"; +import {RpcAsyncProxy} from "@typing/rpc"; export type TransportType = ((...args: any[]) => Promise) | {[key: string]: any | TransportType}; @@ -14,7 +15,7 @@ export type TransportName = Extract; export type TransportTarget = T[K]; -export type TransportProxyTarget = DeepAsyncProxy; +export type TransportProxyTarget = RpcAsyncProxy; export interface TransportManager { add(name: K, instance: TransportDictionary[K]): this; diff --git a/tests/jest.message.setup.ts b/tests/jest.message.setup.ts index 2a272a47..e18826b0 100644 --- a/tests/jest.message.setup.ts +++ b/tests/jest.message.setup.ts @@ -1,33 +1,37 @@ type MessageSender = chrome.runtime.MessageSender; -let listener: ((...args: any[]) => boolean | void) | null = null; +type MessageListener = (...args: any[]) => boolean | void; -chrome.runtime.onMessage.addListener = jest.fn(cb => (listener = cb)); -chrome.runtime.onMessage.removeListener = jest.fn(cb => listener === cb && (listener = null)); -chrome.runtime.onMessage.hasListeners = jest.fn(() => !!listener); +const listeners = new Set(); -chrome.runtime.sendMessage = jest.fn().mockImplementation((msg, callback) => { - if (!listener) return; +chrome.runtime.onMessage.addListener = jest.fn((callback: MessageListener) => listeners.add(callback)); +chrome.runtime.onMessage.removeListener = jest.fn((callback: MessageListener) => listeners.delete(callback)); +chrome.runtime.onMessage.hasListeners = jest.fn(() => listeners.size > 0); +const dispatch = (message: unknown, sender: MessageSender, callback?: (response: any) => void): void => { let called = false; - const result = listener(msg, {} as MessageSender, (response: any) => { - callback?.(response); - called = true; - }); + let asynchronous = false; - if (result === true) return; - if (!called) callback?.(undefined); -}); + for (const listener of listeners) { + const result = listener(message, sender, (response: any) => { + if (called) return; -chrome.tabs.sendMessage = jest.fn().mockImplementation((tabId, msg, options, callback) => { - if (!listener) return; + called = true; + callback?.(response); + }); - let called = false; - const result = listener(msg, {} as MessageSender, (response: any) => { - callback?.(response); - called = true; - }); + asynchronous ||= result === true; + } + + if (!asynchronous && !called) { + callback?.(undefined); + } +}; + +chrome.runtime.sendMessage = jest.fn().mockImplementation((message, callback) => { + dispatch(message, {} as MessageSender, callback); +}); - if (result === true) return; - if (!called) callback?.(undefined); +chrome.tabs.sendMessage = jest.fn().mockImplementation((tabId, message, options, callback) => { + dispatch(message, {} as MessageSender, callback); }); diff --git a/tests/jest.modules.setup.ts b/tests/jest.modules.setup.ts index ea8af8df..91d320f5 100644 --- a/tests/jest.modules.setup.ts +++ b/tests/jest.modules.setup.ts @@ -2,6 +2,7 @@ jest.mock("@addon-core/browser", () => ({ __esModule: true, throwRuntimeError: jest.fn(), + getManifest: jest.fn(() => ({manifest_version: 3, name: "Test extension", version: "1.0.0", permissions: []})), getManifestVersion: jest.fn(), isAvailableScripting: jest.fn(), @@ -20,6 +21,12 @@ jest.mock("@addon-core/browser", () => ({ containsPermissions: jest.fn(() => true), requestPermissions: jest.fn(() => true), + onPermissionsAdded: jest.fn(() => jest.fn()), + onPermissionsRemoved: jest.fn(() => jest.fn()), + + getContexts: jest.fn(), + getAllFrames: jest.fn(), + getBrowserInfo: jest.fn(() => Promise.resolve({name: "Firefox", version: "153.0"})), executeScript: (...args: Parameters) => { return chrome.scripting.executeScript(...args); diff --git a/tests/jest.relay.setup.ts b/tests/jest.relay.setup.ts index 116cda62..d56547e3 100644 --- a/tests/jest.relay.setup.ts +++ b/tests/jest.relay.setup.ts @@ -1,14 +1,28 @@ -import {RelayGlobalKey} from "../src/types/relay"; +const resolveScriptingResult = async ( + injection: Parameters[0] +): Promise[]> => { + const {func, target} = injection; -const resolveScriptingResult = async ({ - args, -}: chrome.scripting.ScriptInjection): Promise => { - const [name, path, callArgs] = args || []; - const relay = (globalThis as any)[RelayGlobalKey].get(name); - const target = path?.split(".").reduce((acc: any, key: string) => acc?.[key], relay); - const result = typeof target === "function" ? await target(...callArgs) : target; + if (!func) { + return []; + } - return [{result}]; + const args = "args" in injection ? injection.args : []; + const frameIds = target.allFrames ? [0, 2] : target.frameIds || [0]; + const documentIds = target.documentIds || []; + const targets = documentIds.length + ? documentIds.map(documentId => ({ + documentId, + frameId: Number.parseInt(documentId.match(/\d+$/)?.[0] ?? "0", 10), + })) + : frameIds.map(frameId => ({frameId, documentId: `document-${frameId}`})); + + return Promise.all( + targets.map(async target => ({ + ...target, + result: await func(...args), + })) + ); }; chrome.scripting = { diff --git a/tsconfig.build.json b/tsconfig.build.json index 8c581ebc..6c3afcf4 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -8,6 +8,11 @@ "outDir": "./dist", "sourceMap": false }, + "tsc-alias": { + "fileExtensions": { + "inputGlob": "d.ts" + } + }, "include": ["./src/**/*"], "exclude": [ "**/*.test.ts", diff --git a/tsconfig.json b/tsconfig.json index 01df14cc..164b8cf4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,41 @@ ], "baseUrl": "./src", "paths": { + "adnbn": ["index.ts"], + "adnbn/entry/content/*": [ + "entry/content/adapters/*/index.ts" + ], + "adnbn/entry/view/*": [ + "entry/view/adapters/*/index.ts" + ], + "adnbn/entry/*": [ + "entry/*/index.ts" + ], + "adnbn/locale": [ + "locale/index.ts" + ], + "adnbn/locale/*": [ + "locale/adapters/*/index.ts" + ], + "adnbn/message": [ + "message/index.ts" + ], + "adnbn/message/*": [ + "message/adapters/*/index.ts" + ], + "adnbn/offscreen": [ + "offscreen/index.ts" + ], + "adnbn/relay": ["relay/index.ts"], + "adnbn/sandbox": [ + "sandbox/index.ts" + ], + "adnbn/service": [ + "service/index.ts" + ], + "adnbn/transport": [ + "transport/index.ts" + ], "@cli/*": ["cli/*"], "@entry/*": ["entry/*"], "@frame/*": ["frame/*"], diff --git a/tsconfig.tests.json b/tsconfig.tests.json new file mode 100644 index 00000000..ede40a0d --- /dev/null +++ b/tsconfig.tests.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.d.ts", + "tests/**/*.test.ts", + "tests/jest*.ts" + ], + "exclude": [ + "src/**/tests/fixtures/**", + "dist", + "node_modules" + ] +} diff --git a/tsup.config.ts b/tsup.config.ts index adcdaa01..ea2953c1 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -91,9 +91,10 @@ export default defineConfig([ clean: false, dts: false, sourcemap: false, - external: [/^@cli/], + external: ["../entrypoint/index.js"], + // Raw templates are source text; output-wide import rewrites corrupt their package specifiers. // @ts-ignore - esbuildPlugins: [fixVirtualIndexImportPlugin(), rawPlugin(), fixImportsPlugin()], + esbuildPlugins: [rawPlugin()], esbuildOptions: options => { options.outbase = "src"; }, From 8ca422ca44c195ee3f30bc0e230b7feb7c5180ca Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:10:42 +0300 Subject: [PATCH 2/4] fix(release): use Addon Bone in GitHub release headings Brand release titles and changelog headings with the framework name. Verify pre-1.0 breaking bumps and generated release notes. --- .release-it.cjs | 5 ++-- tests/release-it.test.ts | 64 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/.release-it.cjs b/.release-it.cjs index 9651a5f5..a578b776 100644 --- a/.release-it.cjs +++ b/.release-it.cjs @@ -191,7 +191,7 @@ const createReleaseConfig = () => { github: { release: true, - releaseName: "v${version}", + releaseName: "Addon Bone v${version}", autoGenerate: false, releaseNotes: ({changelog}) => changelog, }, @@ -226,8 +226,7 @@ const createReleaseConfig = () => { whatBump, writerOpts: { - headerPartial: - "## 🚀 Release {{#if name}}`{{name}}` {{else}}{{#if @root.pkg}}`{{@root.pkg.name}}` {{/if}}{{/if}}v{{version}} ({{date}})\n\n", + headerPartial: "## 🚀 Release Addon Bone v{{version}} ({{date}})\n\n", footerPartial: `{{#if @root.contributors.length}}\n### 🙌 Contributors\n\n{{#each @root.contributors}}- {{#if url}}{{#if name}}[{{name}}]({{url}}){{#if login}} (@{{login}}){{/if}}{{else}}[@{{login}}]({{url}}){{/if}}{{else}}{{#if email}}{{#if name}}[{{name}}](mailto:{{email}}){{else}}{{email}}{{/if}}{{else}}{{name}}{{/if}}{{/if}} — commits: {{count}}\n{{/each}}{{/if}}`, mainTemplate: "{{> header}}\n" + diff --git a/tests/release-it.test.ts b/tests/release-it.test.ts index 8423b248..8f8d922e 100644 --- a/tests/release-it.test.ts +++ b/tests/release-it.test.ts @@ -1,3 +1,6 @@ +import {execFileSync} from "node:child_process"; +import path from "node:path"; + type ReleaseCommit = { type?: string; header?: string; @@ -57,3 +60,64 @@ describe("release-it version policy", () => { expect(whatBump([{type}], "0.6.0")).toBeNull(); }); }); + +describe("release-it GitHub release notes", () => { + let release: {version: string; name: string; notes: string}; + + beforeAll(() => { + // Exercise the installed ESM parser and writer without Jest transforms or release side effects. + release = JSON.parse( + execFileSync( + process.execPath, + [ + "--input-type=module", + "-e", + ` + import {CommitParser} from "conventional-commits-parser"; + import {Bumper} from "conventional-recommended-bump"; + import {writeChangelogString} from "conventional-changelog-writer"; + import semver from "semver"; + import createReleaseConfig from "./.release-it.cjs"; + + const config = createReleaseConfig(); + const options = config.plugins["@release-it/conventional-changelog"]; + const commit = new CommitParser(options.parserOpts).parse( + "feat(relay)!: update relay targets\\n\\nBREAKING CHANGE: targets are mutually exclusive" + ); + const {releaseType} = await new Bumper().commits([commit]).bump( + commits => options.whatBump(commits, "0.8.0") + ); + const version = semver.inc("0.8.0", releaseType); + const changelog = await writeChangelogString([commit], { + ...options.context, + version, + date: "2026-08-27", + }, options.writerOpts); + + process.stdout.write(JSON.stringify({ + version, + name: config.github.releaseName.replace("\${version}", version), + notes: config.github.releaseNotes({changelog}), + })); + `, + ], + {cwd: path.resolve(__dirname, ".."), encoding: "utf8", timeout: 10_000} + ) + ); + }); + + test("recommends 0.9.0 for a parsed pre-1.0 breaking change", () => { + expect(release.version).toBe("0.9.0"); + }); + + test("uses the framework name in the GitHub release title and notes heading", () => { + expect(release.name).toBe("Addon Bone v0.9.0"); + expect(release.notes).toMatch(/^## 🚀 Release Addon Bone v0\.9\.0 \(2026-08-27\)/); + expect(release.notes).not.toContain("`adnbn`"); + }); + + test("preserves the breaking changes section in a pre-1.0 release", () => { + expect(release.notes).toContain("### 💥 Breaking Changes"); + expect(release.notes).toContain("targets are mutually exclusive"); + }); +}); From a644da84b972b241b199833541c2d5c430da35cc Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:24:30 +0300 Subject: [PATCH 3/4] test(relay): normalize declaration paths across platforms Normalize virtual declaration and resolved API paths before comparison. Exercise POSIX and Windows separators for source and package contracts. --- .../plugins/content/RelayDeclaration.test.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/cli/plugins/content/RelayDeclaration.test.ts b/src/cli/plugins/content/RelayDeclaration.test.ts index 01ee4400..eaf5dcd3 100644 --- a/src/cli/plugins/content/RelayDeclaration.test.ts +++ b/src/cli/plugins/content/RelayDeclaration.test.ts @@ -41,9 +41,14 @@ describe("Relay declarations", () => { expect(declaration).not.toContain("[name: string]: any"); }); - test.each(["source", "package"])( - "checks the %s API and generated scalar/batch contracts", - mode => { + test.each( + ["source", "package"].flatMap(mode => [ + {mode, pathStyle: "POSIX", generatedPath: generatedFile.replace(/\\/g, "/")}, + {mode, pathStyle: "Windows", generatedPath: generatedFile.replace(/\//g, "\\")}, + ]) + )( + "checks the $mode API and generated scalar/batch contracts with $pathStyle paths", + ({mode, generatedPath}) => { const configFile = ts.readConfigFile(path.join(projectDir, "tsconfig.json"), ts.sys.readFile); const config = ts.parseJsonConfigFileContent(configFile.config, ts.sys, projectDir); @@ -60,7 +65,7 @@ describe("Relay declarations", () => { skipLibCheck: true, types: ["node", "chrome"], }; - const rootNames = [consumerFile, generatedFile]; + const rootNames = [consumerFile, generatedPath]; if (mode === "source") { rootNames.push( @@ -74,9 +79,11 @@ describe("Relay declarations", () => { // The package case deliberately has neither source aliases nor ambient virtual module declarations. const host = ts.createCompilerHost(options); const getSourceFile = host.getSourceFile.bind(host); + // TypeScript uses forward slashes even when the root file has a native Windows path. + const generatedFileName = generatedPath.replace(/\\/g, "/"); host.getSourceFile = (file, languageVersion, onError, shouldCreateNewSourceFile) => - file === generatedFile + file.replace(/\\/g, "/") === generatedFileName ? ts.createSourceFile(file, declaration, languageVersion, true) : getSourceFile(file, languageVersion, onError, shouldCreateNewSourceFile); @@ -141,7 +148,9 @@ describe("Relay declarations", () => { const apiFile = ts.resolveModuleName("adnbn", consumerFile, options, host).resolvedModule?.resolvedFileName; - expect(apiFile).toBe(path.join(projectDir, mode === "source" ? "src/index.ts" : "dist/index.d.ts")); + expect(apiFile).toBe( + path.join(projectDir, mode === "source" ? "src/index.ts" : "dist/index.d.ts").replace(/\\/g, "/") + ); }, 30_000 ); From df3b4682381ca645232f08e29160bba14d6d75be Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:25:20 +0300 Subject: [PATCH 4/4] feat(locale)!: complete catalogs and unify locale contracts Fill ordinary gaps from the assembled app default and validate plural keys before JSON generation. Expose native language names through providers and React, and generate registry-only declarations. Preserve known empty native messages and return nonzero CLI status on initialization failures. BREAKING CHANGE: Secondary locales must define default plural keys. Direct LocaleProvider implementations must add languageNames(). Generated types now augment LocaleNativeStructure. --- .../locale/LocaleStructureValidator.test.ts | 25 ++ .../locale/LocaleStructureValidator.ts | 8 + .../plugins/GenerateJsonPlugin.test.ts | 31 +++ src/cli/bundler/plugins/GenerateJsonPlugin.ts | 13 +- src/cli/index.test.ts | 75 ++++++ src/cli/index.ts | 2 + src/cli/plugins/locale/Locale.test.ts | 248 +++++++++++++++++ src/cli/plugins/locale/Locale.ts | 9 +- .../declaration/LocaleDeclaration.test.ts | 255 ++++++++++++++++++ .../locale/declaration/LocaleDeclaration.ts | 8 +- .../plugins/locale/declaration/locale.d.ts | 127 +-------- .../completion/layered/plugin/locales/en.yaml | 4 + .../completion/layered/plugin/locales/fr.yaml | 2 + .../apps/alpha/app-src/locales/en.chrome.yaml | 2 + .../src/apps/alpha/app-src/locales/en.yaml | 4 + .../apps/alpha/app-src/locales/fr.chrome.yaml | 1 + .../src/apps/alpha/app-src/locales/fr.yaml | 2 + .../project/src/apps/alpha/locales/en.yaml | 3 + .../project/src/apps/alpha/locales/fr.yaml | 1 + .../src/apps/beta/app-src/locales/fr.yaml | 1 + .../project/src/apps/beta/locales/fr.yaml | 1 + .../layered/project/src/locales/en.yaml | 3 + .../layered/project/src/locales/fr.yaml | 1 + .../project/src/shared/locales/en.yaml | 4 + .../project/src/shared/locales/fr.yaml | 2 + .../missing-plural/src/locales/en.yaml | 3 + .../missing-plural/src/locales/fr.yaml | 1 + .../completion/single/src/locales/de.yaml | 7 + .../completion/single/src/locales/en.yaml | 9 + .../completion/single/src/locales/fr.yaml | 8 + .../tests/fixtures/completion/updated/en.yaml | 9 + .../exit-code/invalid-config/adnbn.config.ts | 3 + .../exit-code/missing-plural/adnbn.config.ts | 3 + .../missing-plural/src/locales/en.yaml | 3 + .../missing-plural/src/locales/fr.yaml | 1 + .../exit-code/success/adnbn.config.ts | 3 + .../exit-code/success/src/locales/en.yaml | 3 + .../exit-code/success/src/locales/fr.yaml | 3 + src/locale/adapters/react/LocaleProvider.tsx | 2 + src/locale/adapters/react/context.ts | 9 +- .../adapters/react/localeProvider.test.ts | 115 ++++++++ src/locale/helpers.test.ts | 56 ++++ src/locale/helpers.ts | 3 +- src/locale/providers/AbstractLocale.ts | 6 + src/locale/providers/CustomLocale.test.ts | 8 + src/locale/providers/DynamicLocale.test.ts | 81 ++++++ src/locale/providers/NativeLocale.test.ts | 83 ++++++ src/locale/providers/NativeLocale.ts | 8 +- src/types/locale.test.ts | 16 ++ src/types/locale.ts | 61 +++++ 50 files changed, 1192 insertions(+), 144 deletions(-) create mode 100644 src/cli/bundler/plugins/GenerateJsonPlugin.test.ts create mode 100644 src/cli/index.test.ts create mode 100644 src/cli/plugins/locale/Locale.test.ts create mode 100644 src/cli/plugins/locale/declaration/LocaleDeclaration.test.ts create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/layered/plugin/locales/en.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/layered/plugin/locales/fr.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/app-src/locales/en.chrome.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/app-src/locales/en.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/app-src/locales/fr.chrome.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/app-src/locales/fr.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/locales/en.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/locales/fr.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/beta/app-src/locales/fr.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/beta/locales/fr.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/locales/en.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/locales/fr.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/shared/locales/en.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/shared/locales/fr.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/missing-plural/src/locales/en.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/missing-plural/src/locales/fr.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/single/src/locales/de.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/single/src/locales/en.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/single/src/locales/fr.yaml create mode 100644 src/cli/plugins/locale/tests/fixtures/completion/updated/en.yaml create mode 100644 src/cli/tests/fixtures/exit-code/invalid-config/adnbn.config.ts create mode 100644 src/cli/tests/fixtures/exit-code/missing-plural/adnbn.config.ts create mode 100644 src/cli/tests/fixtures/exit-code/missing-plural/src/locales/en.yaml create mode 100644 src/cli/tests/fixtures/exit-code/missing-plural/src/locales/fr.yaml create mode 100644 src/cli/tests/fixtures/exit-code/success/adnbn.config.ts create mode 100644 src/cli/tests/fixtures/exit-code/success/src/locales/en.yaml create mode 100644 src/cli/tests/fixtures/exit-code/success/src/locales/fr.yaml create mode 100644 src/locale/adapters/react/localeProvider.test.ts create mode 100644 src/locale/helpers.test.ts create mode 100644 src/locale/providers/NativeLocale.test.ts diff --git a/src/cli/builders/locale/LocaleStructureValidator.test.ts b/src/cli/builders/locale/LocaleStructureValidator.test.ts index 697e2b97..80076f7a 100644 --- a/src/cli/builders/locale/LocaleStructureValidator.test.ts +++ b/src/cli/builders/locale/LocaleStructureValidator.test.ts @@ -67,6 +67,31 @@ describe("LocaleStructureValidator", () => { ); }); + test("rejects an omitted plural key before default-locale completion", () => { + const builders = makeBuilders([ + [Language.English, {cart: {items: ["{{count}} item", "{{count}} items"]}}], + [Language.French, {title: "Panier"}], + ]); + + const validator = new LocaleStructureValidator(Language.English); + + expect(() => validator.validate(builders)).toThrow( + 'Locale "fr" is missing plural key "cart.items" required by default locale "en"' + ); + expect(validator.isValid(builders)).toBe(false); + }); + + test("accepts a target-language plural supplied by an earlier merge", () => { + const builders = makeBuilders([ + [Language.English, {cart: {items: ["item", "items"]}}], + [Language.French, {cart: {items: ["article", "articles"]}}], + ]); + + builders.get(Language.French)!.merge({title: "Panier"}); + + expect(new LocaleStructureValidator(Language.English).isValid(builders)).toBe(true); + }); + test("warns about keys outside the default locale contract", () => { const builders = makeBuilders([ [Language.English, {app: {name: "My App"}}], diff --git a/src/cli/builders/locale/LocaleStructureValidator.ts b/src/cli/builders/locale/LocaleStructureValidator.ts index d556417b..f429d9e6 100644 --- a/src/cli/builders/locale/LocaleStructureValidator.ts +++ b/src/cli/builders/locale/LocaleStructureValidator.ts @@ -31,6 +31,14 @@ export default class implements LocaleContractValidator { const structure = builder.structure(); + for (const [key, expected] of Object.entries(defaultStructure)) { + if (expected.plural && !Object.hasOwn(structure, key)) { + throw new Error( + `Locale "${language}" is missing plural key "${key}" required by default locale "${this.defaultLanguage}"` + ); + } + } + for (const [key, locale] of Object.entries(structure)) { const expected = defaultStructure[key]; diff --git a/src/cli/bundler/plugins/GenerateJsonPlugin.test.ts b/src/cli/bundler/plugins/GenerateJsonPlugin.test.ts new file mode 100644 index 00000000..0c2903f5 --- /dev/null +++ b/src/cli/bundler/plugins/GenerateJsonPlugin.test.ts @@ -0,0 +1,31 @@ +import {rspack, Compiler} from "@rspack/core"; +import GenerateJsonPlugin from "./GenerateJsonPlugin"; + +describe("GenerateJsonPlugin watch updates", () => { + let compiler: Compiler; + + afterEach(async () => { + await new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); + }); + + test("propagates validation failures to the compiler and allows a later valid update", async () => { + const error = new Error('Locale "fr" is missing plural key "cart.items" required by default locale "en"'); + const update = jest.fn(async () => ({"messages.json": {title: "Updated"}})); + update.mockRejectedValueOnce(error); + compiler = rspack({ + mode: "none", + entry: {}, + plugins: [new GenerateJsonPlugin({}).watch(update)], + }); + + await expect(compiler.hooks.watchRun.promise(compiler)).rejects.toThrow(error); + await expect(compiler.hooks.watchRun.promise(compiler)).resolves.toBeUndefined(); + expect(update).toHaveBeenCalledTimes(2); + }); + + test("allows watch builds without an update callback", async () => { + compiler = rspack({mode: "none", entry: {}, plugins: [new GenerateJsonPlugin({})]}); + + await expect(compiler.hooks.watchRun.promise(compiler)).resolves.toBeUndefined(); + }); +}); diff --git a/src/cli/bundler/plugins/GenerateJsonPlugin.ts b/src/cli/bundler/plugins/GenerateJsonPlugin.ts index e60e5b88..890de2d5 100644 --- a/src/cli/bundler/plugins/GenerateJsonPlugin.ts +++ b/src/cli/bundler/plugins/GenerateJsonPlugin.ts @@ -21,14 +21,11 @@ export default class GenerateJsonPlugin { public apply(compiler: Compiler): void { compiler.hooks.watchRun.tapPromise(this.pluginName, async () => { - try { - const update = this.update; - - if (update) { - this.data = await update(); - } - } catch (e) { - console.error("GenerateJsonPlugin: Error updating data", e); + const update = this.update; + + if (update) { + // Let the compiler fail this rebuild rather than emit stale JSON. + this.data = await update(); } }); diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts new file mode 100644 index 00000000..2cc7785d --- /dev/null +++ b/src/cli/index.test.ts @@ -0,0 +1,75 @@ +/** @jest-environment node */ + +import {spawnSync} from "child_process"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +const cli = path.resolve(__dirname, "../../bin/adnbn.js"); +const fixtures = path.join(__dirname, "tests/fixtures/exit-code"); + +describe("CLI exit codes", () => { + let root: string; + let artifact: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-cli-exit-code-")); + artifact = path.join(root, "dist/exit-code-chrome-mv3"); + }); + + afterEach(() => { + fs.rmSync(root, {recursive: true, force: true}); + }); + + const run = (command: "build" | "watch", fixture: string) => { + fs.cpSync(path.join(fixtures, fixture), root, {recursive: true}); + + const result = spawnSync(process.execPath, [cli, command, root, "-a", "exit-code", "-b", "chrome"], { + cwd: root, + encoding: "utf8", + timeout: 30_000, + maxBuffer: 4 * 1024 * 1024, + }); + + if (result.error) { + throw result.error; + } + + expect(result.signal).toBeNull(); + + return {status: result.status, output: `${result.stdout}\n${result.stderr}`}; + }; + + test("build exits with 0 and emits an extension on success", () => { + const result = run("build", "success"); + + expect(result.status).toBe(0); + expect(JSON.parse(fs.readFileSync(path.join(artifact, "manifest.json"), "utf8"))).toMatchObject({ + manifest_version: 3, + version: "1.0.0", + default_locale: "en", + }); + expect(JSON.parse(fs.readFileSync(path.join(artifact, "_locales/fr/messages.json"), "utf8"))).toMatchObject({ + cart_items: {message: "article|articles"}, + locale: {message: "fr"}, + }); + }); + + test.each(["build", "watch"] as const)("%s exits with 1 when a plural key is missing at startup", command => { + const result = run(command, "missing-plural"); + + expect(result.output).toContain( + 'Locale "fr" is missing plural key "cart.items" required by default locale "en"' + ); + expect(result.status).toBe(1); + expect(fs.existsSync(path.join(artifact, "manifest.json"))).toBe(false); + }); + + test.each(["build", "watch"] as const)("%s exits with 1 when configuration is invalid", command => { + const result = run(command, "invalid-config"); + + expect(result.output).toContain('Invalid language "unsupported" provided by config'); + expect(result.status).toBe(1); + expect(fs.existsSync(path.join(artifact, "manifest.json"))).toBe(false); + }); +}); diff --git a/src/cli/index.ts b/src/cli/index.ts index f2133154..72e7fb3e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -38,6 +38,7 @@ cli.command("watch [root]", "Start watch mode") }); } catch (e) { consola.error(e); + process.exitCode = 1; } }); @@ -63,6 +64,7 @@ cli.command("build [root]", "Build for production") }); } catch (e) { consola.error(e); + process.exitCode = 1; } }); diff --git a/src/cli/plugins/locale/Locale.test.ts b/src/cli/plugins/locale/Locale.test.ts new file mode 100644 index 00000000..a2405add --- /dev/null +++ b/src/cli/plugins/locale/Locale.test.ts @@ -0,0 +1,248 @@ +jest.mock("@addon-core/browser", () => ({ + getI18nMessage: jest.fn(() => "de"), +})); + +import fs from "fs"; +import os from "os"; +import path from "path"; + +import {getI18nMessage} from "@addon-core/browser"; +import Locale from "./Locale"; +import DynamicLocale from "@locale/providers/DynamicLocale"; +import NativeLocale from "@locale/providers/NativeLocale"; +import {getLocaleFilename} from "@locale/utils"; +import {Command, Mode, Workspace} from "@typing/app"; +import {Browser} from "@typing/browser"; +import {ReadonlyConfig} from "@typing/config"; +import {Language, LocaleMessages} from "@typing/locale"; +import {GenerateJsonPluginData} from "@cli/bundler"; + +const fixtures = path.resolve(__dirname, "tests/fixtures/completion"); + +const makeLocale = (fixture: string, overrides: Partial = {}): Locale => { + const plugins = [...(overrides.plugins ?? [])]; + const config = { + app: "alpha", + appSrcDir: ".", + appsDir: "apps", + browser: Browser.Chrome, + command: Command.Build, + lang: Language.English, + localeDir: "locales", + mergeLocales: true, + mode: Mode.Production, + rootDir: path.join(fixtures, fixture), + sharedDir: ".", + srcDir: "src", + workspace: Workspace.Single, + ...overrides, + plugins, + } as ReadonlyConfig; + const locale = new Locale(config); + + plugins.push({name: "adnbn:locale", locale: () => locale.files()}); + + return locale; +}; + +const makeLayeredLocale = (config: Partial = {}): Locale => + makeLocale("layered/project", { + appSrcDir: "app-src", + sharedDir: "shared", + workspace: Workspace.Multi, + plugins: [{name: path.join(fixtures, "layered/plugin"), locale: true}], + ...config, + }); + +const messages = (json: GenerateJsonPluginData, lang: Language): LocaleMessages => + json[getLocaleFilename(lang)] as LocaleMessages; + +describe("locale JSON completion", () => { + let consoleWarnSpy: jest.SpyInstance; + const temporaryDirectories: string[] = []; + const fetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, "fetch"); + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); + jest.mocked(getI18nMessage).mockImplementation(() => "de"); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + + if (fetchDescriptor) { + Object.defineProperty(globalThis, "fetch", fetchDescriptor); + } else { + Reflect.deleteProperty(globalThis, "fetch"); + } + + for (const root of temporaryDirectories.splice(0)) { + fs.rmSync(root, {recursive: true, force: true}); + } + }); + + test("completes ordinary keys while keeping translations, empty strings and extra keys", async () => { + const json = await makeLocale("single").json(); + + expect(messages(json, Language.French)).toEqual({ + app_title: {message: "Titre français"}, + app_greeting: {message: "Hello {{ name }}"}, + empty: {message: ""}, + zero: {message: "0"}, + cart_items: {message: "{{count}} article|{{count}} articles"}, + extra: {message: "Seulement en français"}, + locale: {message: "fr"}, + }); + expect(messages(json, Language.English).locale).toEqual({message: "en"}); + expect(messages(json, Language.German).locale).toEqual({message: "de"}); + expect(Object.keys(json).sort()).toEqual([ + getLocaleFilename(Language.German), + getLocaleFilename(Language.English), + getLocaleFilename(Language.French), + ]); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Locale "fr" contains unknown key "extra" not found in default locale "en"' + ); + }); + + test("does not change source files, builders or the default-only contract", async () => { + const locale = makeLocale("single"); + const builders = await locale.builders(); + const structure = await locale.structure(); + const sources = [...(await locale.files())].map(({file}) => [file, fs.readFileSync(file, "utf8")]); + const english = [...builders.get(Language.English)!.get()]; + const french = [...builders.get(Language.French)!.get()]; + const first = await locale.json(); + + expect(await locale.json()).toEqual(first); + expect(await locale.builders()).toBe(builders); + expect([...builders.get(Language.English)!.get()]).toEqual(english); + expect([...builders.get(Language.French)!.get()]).toEqual(french); + expect(builders.get(Language.French)!.get().has("app.greeting")).toBe(false); + expect(await locale.structure()).toEqual(structure); + expect((await locale.keys()).has("extra")).toBe(false); + + for (const [file, content] of sources) { + expect(fs.readFileSync(file, "utf8")).toBe(content); + } + }); + + test.each([ + [Browser.Chrome, "Alpha Chrome English", "Traduction alpha Chrome"], + [Browser.Firefox, "Alpha app source English", "Traduction alpha app source"], + ])("uses the fully layered default and target for %s", async (browser, fallbackTitle, translated) => { + const french = messages(await makeLayeredLocale({browser}).json(), Language.French); + + expect(french).toMatchObject({ + fallbackTitle: {message: fallbackTitle}, + translated: {message: translated}, + pluginOnly: {message: "Plugin default"}, + sourceOnly: {message: "Source default"}, + sharedOnly: {message: "Shared default"}, + appOnly: {message: "Alpha app default"}, + appSourceOnly: {message: "Alpha app source default"}, + pluginItems: {message: "élément plugin|éléments plugin"}, + sharedItems: {message: "élément partagé|éléments partagés"}, + empty: {message: ""}, + locale: {message: "fr"}, + }); + expect(french.browserOnly).toEqual(browser === Browser.Chrome ? {message: "Alpha Chrome default"} : undefined); + }); + + test("isolates apps with different default languages over the same shared and plugin sources", async () => { + const alpha = makeLayeredLocale(); + const beta = makeLayeredLocale({app: "beta", lang: Language.French}); + const [alphaJson, betaJson] = await Promise.all([alpha.json(), beta.json()]); + + expect(messages(alphaJson, Language.French).fallbackTitle).toEqual({message: "Alpha Chrome English"}); + expect(messages(betaJson, Language.English).betaOnly).toEqual({message: "Défaut beta app source"}); + expect(messages(betaJson, Language.English).appSourceOnly).toBeUndefined(); + expect(messages(alphaJson, Language.French).betaOnly).toBeUndefined(); + expect((await beta.structure()).betaOnly).toEqual({plural: false, substitutions: []}); + }); + + test("completes selected files even when workspace layer merging is disabled", async () => { + const french = messages(await makeLayeredLocale({mergeLocales: false}).json(), Language.French); + + expect(french.fallbackTitle).toEqual({message: "Alpha Chrome English"}); + expect(french.appSourceOnly).toEqual({message: "Alpha app source default"}); + expect(french.pluginOnly).toEqual({message: "Plugin default"}); + expect(french.sharedOnly).toBeUndefined(); + expect(french.sourceOnly).toBeUndefined(); + expect(french.appOnly).toBeUndefined(); + }); + + test("validates missing plural keys even when json() is called directly", async () => { + await expect(makeLocale("missing-plural").json()).rejects.toThrow( + 'Locale "fr" is missing plural key "cart.items" required by default locale "en"' + ); + }); + + test("rejects a missing configured default instead of guessing another language", async () => { + await expect(makeLocale("single", {lang: Language.Italian}).json()).rejects.toThrow( + 'Default locale "it" not found in available translations' + ); + }); + + test("does not invent locale files when no translations exist", async () => { + const locale = makeLocale("no-locales"); + + expect(await locale.json()).toEqual({}); + expect(await locale.languages()).toEqual(new Set()); + }); + + test("leaves a default-only build unchanged without creating other languages", async () => { + const locale = makeLayeredLocale({ + app: "beta", + lang: Language.French, + mergeLocales: false, + plugins: [], + }); + + expect(await locale.json()).toEqual({ + [getLocaleFilename(Language.French)]: { + betaOnly: {message: "Défaut beta app source"}, + locale: {message: "fr"}, + }, + }); + }); + + test("reloads default completion after clearing the finder for a rebuild", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-locale-rebuild-")); + temporaryDirectories.push(root); + fs.cpSync(path.join(fixtures, "single"), root, {recursive: true}); + const locale = makeLocale("single", {rootDir: root}); + + expect(messages(await locale.json(), Language.French).app_greeting.message).toBe("Hello {{ name }}"); + + fs.copyFileSync(path.join(fixtures, "updated/en.yaml"), path.join(root, "src/locales/en.yaml")); + locale.clear(); + + expect(messages(await locale.json(), Language.French).app_greeting.message).toBe("Welcome {{ name }}"); + }); + + test("dynamic selection uses the generated default rather than a third native language", async () => { + const json = await makeLocale("single").json(); + const german = messages(json, Language.German); + jest.mocked(getI18nMessage).mockImplementation(key => german[key]?.message ?? ""); + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: jest.fn(async () => ({json: async () => messages(json, Language.French)})), + }); + const dynamic = new DynamicLocale<{ + "app.greeting": {plural: false; substitutions: ["name"]}; + "cart.items": {plural: true; substitutions: ["count"]}; + }>(false); + const native = new NativeLocale<{ + "app.greeting": {plural: false; substitutions: ["name"]}; + }>(); + + expect(native.trans("app.greeting", {name: "Ada"})).toBe("Hallo Ada"); + await dynamic.change(Language.French); + + expect(dynamic.lang()).toBe(Language.French); + expect(dynamic.trans("app.greeting", {name: "Ada"})).toBe("Hello Ada"); + expect(dynamic.choice("cart.items", 2, {count: 2})).toBe("2 articles"); + expect(globalThis.fetch).toHaveBeenCalledWith(getLocaleFilename(Language.French)); + }); +}); diff --git a/src/cli/plugins/locale/Locale.ts b/src/cli/plugins/locale/Locale.ts index 082f9dda..ad1b67e8 100644 --- a/src/cli/plugins/locale/Locale.ts +++ b/src/cli/plugins/locale/Locale.ts @@ -5,12 +5,19 @@ import {GenerateJsonPluginData} from "@cli/bundler"; export default class extends LocaleFinder { public async json(): Promise { + await this.validate(); + const data: GenerateJsonPluginData = {}; const builders = await this.builders(); + const defaultBuilder = this.getValidator().getDefaultBuilder(builders); + const defaultMessages = defaultBuilder?.build() ?? {}; for (const builder of builders.values()) { - data[getLocaleFilename(builder.lang())] = builder.build(); + // Builders already include this app's layers and browser overrides. + // Every target plural has been validated; only ordinary gaps remain. + const messages = builder === defaultBuilder ? defaultMessages : builder.build(); + data[getLocaleFilename(builder.lang())] = {...defaultMessages, ...messages}; } return data; diff --git a/src/cli/plugins/locale/declaration/LocaleDeclaration.test.ts b/src/cli/plugins/locale/declaration/LocaleDeclaration.test.ts new file mode 100644 index 00000000..4b9511e2 --- /dev/null +++ b/src/cli/plugins/locale/declaration/LocaleDeclaration.test.ts @@ -0,0 +1,255 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import ts from "typescript"; + +import LocaleDeclaration from "./LocaleDeclaration"; +import {ReadonlyConfig} from "@typing/config"; +import {LocaleStructure} from "@typing/locale"; + +const packageRoot = path.resolve(__dirname, "../../../../.."); + +const normalizeFilename = (filename: string): string => path.normalize(filename).replaceAll("\\", "/").toLowerCase(); + +const typecheck = (source: string, declaration?: string, target: "source" | "package" = "package"): string[] => { + const filename = path.join(packageRoot, "__locale-consumer-test.ts"); + const isTestFile = (file: string): boolean => normalizeFilename(file) === normalizeFilename(filename); + const config = ts.readConfigFile(path.join(packageRoot, "tsconfig.json"), ts.sys.readFile); + const sourceOptions = ts.parseJsonConfigFileContent(config.config, ts.sys, packageRoot).options; + const options: ts.CompilerOptions = { + ...(target === "source" ? sourceOptions : {}), + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ESNext, + strict: true, + skipLibCheck: true, + noEmit: true, + types: [], + paths: + target === "source" + ? sourceOptions.paths + : { + "adnbn/locale": [path.join(packageRoot, "dist/locale/index.d.ts")], + "adnbn/locale/react": [path.join(packageRoot, "dist/locale/adapters/react/index.d.ts")], + }, + }; + 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, version, onError, createNew) => + isTestFile(file) + ? ts.createSourceFile(file, source, version, true) + : getSourceFile(file, version, onError, createNew); + host.readFile = file => (isTestFile(file) ? source : readFile(file)); + host.fileExists = file => isTestFile(file) || fileExists(file); + + const program = ts.createProgram(declaration ? [filename, declaration] : [filename], options, host); + + return ts + .getPreEmitDiagnostics(program) + .map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); +}; + +const namesUsage = ` +import {Language, NativeLocale, DynamicLocale, type LocaleProvider} from "adnbn/locale"; +import {useLocale} from "adnbn/locale/react"; + +const native: Map = new NativeLocale().languageNames(); +const dynamic: Map = new DynamicLocale().languageNames(); +declare const provider: LocaleProvider<{}>; +const providerNames: Map = provider.languageNames(); +const available: Set = new NativeLocale().languages(); +const names: ReadonlyMap = useLocale().langs; +const title: string | undefined = names.get(Language.French); +// @ts-expect-error React exposes a read-only map +useLocale().langs.set(Language.French, "Changed"); +// @ts-expect-error only providers retain the languageNames method +useLocale().languageNames; +// @ts-expect-error keys are Language values, not arbitrary strings +native.get("unsupported"); +`; + +const emptyRegistryUsage = ` +import {t, choice, key, resolve, type LocaleNativeStructure} from "adnbn/locale"; + +const emptyKeys: ReadonlySet = new NativeLocale().keys(); +const emptyDynamicKeys: ReadonlySet = new DynamicLocale(false).keys(); +const plain: string = resolve("Plain title"); +declare const runtimeMarker: string; +resolve(runtimeMarker); +// @ts-expect-error an empty registry has no keys +const missing: keyof LocaleNativeStructure = "app.title"; +// @ts-expect-error no generated non-plural keys +t("app.title"); +// @ts-expect-error no generated plural keys +choice("cart.items", 2); +// @ts-expect-error marker keys are also restricted by the registry +key("app.title"); +// @ts-expect-error native providers share the empty registry +new NativeLocale().trans("app.title"); +// @ts-expect-error the singleton shares the empty registry +NativeLocale.getInstance().trans("app.title"); +// @ts-expect-error dynamic providers share the empty registry +new DynamicLocale(false).choice("cart.items", 2); +// @ts-expect-error React shares the empty registry +useLocale().t("app.title"); +`; + +const customStructureUsage = ` +import type {LocaleContract} from "adnbn/locale/react"; + +interface CustomStructure { + custom: {plural: false; substitutions: ["value"]}; + total: {plural: true; substitutions: []}; +} + +new NativeLocale().trans("custom", {value: "Custom"}); +new DynamicLocale(false).choice("total", 2); +declare const customContext: LocaleContract; +customContext.t("custom", {value: 1}); +// @ts-expect-error explicit structures keep their own key contract +new NativeLocale().trans("app.title"); +// @ts-expect-error explicit structures still require substitutions +new DynamicLocale(false).trans("custom"); +// @ts-expect-error custom React contracts also keep plural keys separate +customContext.t("total"); +`; + +describe("locale declarations", () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-locale-declaration-")); + }); + + afterEach(() => { + fs.rmSync(root, {recursive: true, force: true}); + }); + + const generate = (structure: LocaleStructure): string => { + new LocaleDeclaration({rootDir: root} as ReadonlyConfig).structure(structure).build(); + + return path.join(root, ".adnbn/locale.d.ts"); + }; + + test("generates only the app registry, without duplicating runtime or React APIs", () => { + const declaration = generate({"app.title": {plural: false, substitutions: []}}); + const content = fs.readFileSync(declaration, "utf8"); + + expect(content).toContain('import "adnbn/locale";'); + expect(content).toContain('declare module "adnbn/locale"'); + expect(content).toContain("export interface LocaleNativeStructure"); + expect(content).toContain('"app.title"'); + expect(content).not.toContain("__LOCALE_DICTIONARY__"); + expect(content).not.toContain("GeneratedNativeStructure"); + expect(content).not.toContain("extends LocaleStructure"); + expect(content).not.toContain("[key: string]"); + expect(content).not.toContain("export function"); + expect(content).not.toContain("class "); + expect(content).not.toContain("LocaleContract"); + expect(content).not.toContain("languageNames"); + expect(content).not.toContain("langs"); + expect(content).not.toContain("adnbn/locale/react"); + }); + + describe.each(["source", "package"] as const)("%s types", target => { + test("keep an absent registry strict and allow explicit custom structures", () => { + expect(typecheck(namesUsage + emptyRegistryUsage + customStructureUsage, undefined, target)).toEqual([]); + }); + + test("keep an empty generated registry strict", () => { + const declaration = generate({}); + + expect(typecheck(namesUsage + emptyRegistryUsage, declaration, target)).toEqual([]); + }); + + test("derive every API from the generated registry", () => { + const declaration = generate({ + "app.title": {plural: false, substitutions: []}, + "app.greeting": {plural: false, substitutions: ["name"]}, + "cart.items": {plural: true, substitutions: ["count"]}, + "cart.empty": {plural: true, substitutions: []}, + }); + + expect( + typecheck( + namesUsage + + customStructureUsage + + ` +import {t, choice, key, resolve, type LocaleNativeStructure} from "adnbn/locale"; + +t("app.title"); +t("app.greeting", {name: "Ada"}); +choice("cart.items", 2, {count: 2}); +choice("cart.empty", 0); +key("app.title"); +key("cart.items"); +resolve("@app.title"); +resolve("Plain title"); +const nativeLocale = new NativeLocale(); +nativeLocale.trans("app.greeting", {name: "Ada"}); +nativeLocale.choice("cart.items", 2, {count: 2}); +NativeLocale.getInstance().trans("app.title"); +NativeLocale.getInstance().choice("cart.empty", 0); +const dynamicLocale = new DynamicLocale(false); +dynamicLocale.trans("app.title"); +dynamicLocale.choice("cart.items", 2, {count: 2}); +dynamicLocale.change(Language.French); +const synced: Promise = dynamicLocale.sync(); +const unwatch: () => void = dynamicLocale.watch(lang => {}); +dynamicLocale.unwatch(); +useLocale().t("app.greeting", {name: "Ada"}); +useLocale().choice("cart.items", 2, {count: 2}); +useLocale().change(Language.French); +const allKeys: ReadonlySet<"app.title" | "app.greeting" | "cart.items" | "cart.empty"> = nativeLocale.keys(); +const knownKey: keyof LocaleNativeStructure = "app.title"; +// @ts-expect-error no index signature widens the registry +const arbitraryKey: keyof LocaleNativeStructure = "extra"; +// @ts-expect-error a secondary-only key must not enter the default contract +t("extra"); +// @ts-expect-error substitutions remain mandatory +t("app.greeting"); +// @ts-expect-error substitutions must contain every declared placeholder +t("app.greeting", {}); +// @ts-expect-error unknown substitutions are rejected +t("app.greeting", {name: "Ada", extra: "bad"}); +// @ts-expect-error substitution values must be strings or numbers +t("app.greeting", {name: true}); +// @ts-expect-error keys without placeholders reject substitutions +t("app.title", {}); +// @ts-expect-error plural keys cannot be translated as ordinary keys +t("cart.items"); +// @ts-expect-error ordinary keys cannot be used as plural keys +choice("app.title", 2); +// @ts-expect-error plural substitutions remain mandatory +choice("cart.items", 2); +// @ts-expect-error plural keys without placeholders reject substitutions +choice("cart.empty", 2, {}); +// @ts-expect-error marker keys share the registry +key("extra"); +// @ts-expect-error native providers retain the key contract +nativeLocale.trans("extra"); +// @ts-expect-error native providers retain the substitution contract +nativeLocale.trans("app.greeting"); +// @ts-expect-error the singleton retains the substitution contract +NativeLocale.getInstance().choice("cart.items", 2); +// @ts-expect-error dynamic providers retain the key contract +dynamicLocale.trans("extra"); +// @ts-expect-error dynamic providers retain the substitution contract +dynamicLocale.choice("cart.items", 2); +// @ts-expect-error React retains the generated key contract +useLocale().t("extra"); +// @ts-expect-error React retains the substitution contract +useLocale().t("app.greeting"); +// @ts-expect-error React retains the plural contract +useLocale().choice("app.title", 2); +`, + declaration, + target + ) + ).toEqual([]); + }); + }); +}); diff --git a/src/cli/plugins/locale/declaration/LocaleDeclaration.ts b/src/cli/plugins/locale/declaration/LocaleDeclaration.ts index 20423c1d..eae96113 100644 --- a/src/cli/plugins/locale/declaration/LocaleDeclaration.ts +++ b/src/cli/plugins/locale/declaration/LocaleDeclaration.ts @@ -25,11 +25,11 @@ export default class extends FileBuilder { throw new Error("Locale structure is not set"); } - const type = JSON.stringify(structure, null, 4); + const type = Object.entries(structure) + .map(([key, value]) => `${JSON.stringify(key)}: ${JSON.stringify(value)};`) + .join("\n "); - const name = "GeneratedNativeStructure"; - - return this.readFile().replace(`interface ${name} {}`, `interface ${name} ${type}`); + return this.readFile().replace("__LOCALE_DICTIONARY__", () => type); } public structure(structure: LocaleStructure): this { diff --git a/src/cli/plugins/locale/declaration/locale.d.ts b/src/cli/plugins/locale/declaration/locale.d.ts index acd8ca13..cce32e87 100644 --- a/src/cli/plugins/locale/declaration/locale.d.ts +++ b/src/cli/plugins/locale/declaration/locale.d.ts @@ -1,129 +1,8 @@ -import { - Language, - LocaleDir, - type LocaleNonPluralKeys, - type LocalePluralKeys, - type LocaleProvider, - type LocaleDynamicProvider, - type LocaleSubstitutionArgs, -} from ":package/locale"; +import ":package/locale"; declare module ":package/locale" { // prettier-ignore - export interface GeneratedNativeStructure {} - - /** - * Translates a non-plural locale key. - * - * Substitutions are type-checked from the generated locale structure: - * keys without placeholders do not accept substitutions, while keys with - * placeholders require all declared substitution values. - * - * @example - * ```ts - * t("app.name"); - * t("app.greeting", {name: "Alice"}); - * ``` - */ - export function t>( - key: K, - ...args: LocaleSubstitutionArgs - ): string; - - /** - * Translates a plural locale key using the provided count. - * - * Substitutions are type-checked from the generated locale structure: - * keys without placeholders do not accept substitutions, while keys with - * placeholders require all declared substitution values. - * - * @example - * ```ts - * choice("cart.items", count, {count}); - * ``` - */ - export function choice>( - key: K, - count: number, - ...args: LocaleSubstitutionArgs - ): string; - - /** - * Converts a locale key to a browser message reference. - * - * This is useful for browser-managed extension fields that expect - * `__MSG_name__` references instead of already translated text. - * - * @example - * ```ts - * key("app.name"); // "__MSG_app_name__" - * ``` - */ - export function key(value: keyof GeneratedNativeStructure & string): string; - - /** - * Resolves a string that may contain a locale marker. - * - * When the input contains a locale marker, the marker is extracted and translated. - * Plain strings are returned unchanged. - * - * @example - * ```ts - * resolve("@app.name"); - * resolve("Plain title"); - * ``` - */ - export function resolve(input: string): string; - - export declare class NativeLocale implements LocaleProvider { - lang(): Language; - - languages(): Set; - - keys(): ReadonlySet; - - // non-plural keys - trans>( - key: K, - ...args: LocaleSubstitutionArgs - ): string; - - // plural keys - choice>( - key: K, - count: number, - ...args: LocaleSubstitutionArgs - ): string; - } - - export declare class DynamicLocale extends NativeLocale implements LocaleDynamicProvider { - change(lang: Language): Promise; + export interface LocaleNativeStructure { + __LOCALE_DICTIONARY__ } } - -declare module ":package/locale/react" { - import type {GeneratedNativeStructure} from ":package/locale"; - - export interface LocaleContract { - lang: Language; - - dir: LocaleDir; - - isRtl: boolean; - - t>( - key: K, - ...args: LocaleSubstitutionArgs - ): string; - - choice>( - key: K, - count: number, - ...args: LocaleSubstitutionArgs - ): string; - - change(lang: Language): void; - } - - export function useLocale(): LocaleContract; -} diff --git a/src/cli/plugins/locale/tests/fixtures/completion/layered/plugin/locales/en.yaml b/src/cli/plugins/locale/tests/fixtures/completion/layered/plugin/locales/en.yaml new file mode 100644 index 00000000..82304cf2 --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/layered/plugin/locales/en.yaml @@ -0,0 +1,4 @@ +fallbackTitle: Plugin English +translated: Plugin English translation +pluginOnly: Plugin default +pluginItems: [plugin item, plugin items] diff --git a/src/cli/plugins/locale/tests/fixtures/completion/layered/plugin/locales/fr.yaml b/src/cli/plugins/locale/tests/fixtures/completion/layered/plugin/locales/fr.yaml new file mode 100644 index 00000000..da45155e --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/layered/plugin/locales/fr.yaml @@ -0,0 +1,2 @@ +translated: Traduction plugin +pluginItems: [élément plugin, éléments plugin] diff --git a/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/app-src/locales/en.chrome.yaml b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/app-src/locales/en.chrome.yaml new file mode 100644 index 00000000..69b094bb --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/app-src/locales/en.chrome.yaml @@ -0,0 +1,2 @@ +fallbackTitle: Alpha Chrome English +browserOnly: Alpha Chrome default diff --git a/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/app-src/locales/en.yaml b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/app-src/locales/en.yaml new file mode 100644 index 00000000..1c15dbbc --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/app-src/locales/en.yaml @@ -0,0 +1,4 @@ +fallbackTitle: Alpha app source English +translated: Alpha app source English translation +appSourceOnly: Alpha app source default +empty: Alpha nonempty default diff --git a/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/app-src/locales/fr.chrome.yaml b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/app-src/locales/fr.chrome.yaml new file mode 100644 index 00000000..afd8ba91 --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/app-src/locales/fr.chrome.yaml @@ -0,0 +1 @@ +translated: Traduction alpha Chrome diff --git a/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/app-src/locales/fr.yaml b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/app-src/locales/fr.yaml new file mode 100644 index 00000000..d7621870 --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/app-src/locales/fr.yaml @@ -0,0 +1,2 @@ +translated: Traduction alpha app source +empty: "" diff --git a/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/locales/en.yaml b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/locales/en.yaml new file mode 100644 index 00000000..5f114b31 --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/locales/en.yaml @@ -0,0 +1,3 @@ +fallbackTitle: Alpha app English +translated: Alpha app English translation +appOnly: Alpha app default diff --git a/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/locales/fr.yaml b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/locales/fr.yaml new file mode 100644 index 00000000..44df8680 --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/alpha/locales/fr.yaml @@ -0,0 +1 @@ +translated: Traduction alpha app diff --git a/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/beta/app-src/locales/fr.yaml b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/beta/app-src/locales/fr.yaml new file mode 100644 index 00000000..e72e1071 --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/beta/app-src/locales/fr.yaml @@ -0,0 +1 @@ +betaOnly: Défaut beta app source diff --git a/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/beta/locales/fr.yaml b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/beta/locales/fr.yaml new file mode 100644 index 00000000..3d4b9d24 --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/apps/beta/locales/fr.yaml @@ -0,0 +1 @@ +betaOnly: Défaut beta app diff --git a/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/locales/en.yaml b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/locales/en.yaml new file mode 100644 index 00000000..d0765dc7 --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/locales/en.yaml @@ -0,0 +1,3 @@ +fallbackTitle: Source English +translated: Source English translation +sourceOnly: Source default diff --git a/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/locales/fr.yaml b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/locales/fr.yaml new file mode 100644 index 00000000..ca287ddd --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/locales/fr.yaml @@ -0,0 +1 @@ +translated: Traduction source diff --git a/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/shared/locales/en.yaml b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/shared/locales/en.yaml new file mode 100644 index 00000000..0231151d --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/shared/locales/en.yaml @@ -0,0 +1,4 @@ +fallbackTitle: Shared English +translated: Shared English translation +sharedOnly: Shared default +sharedItems: [shared item, shared items] diff --git a/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/shared/locales/fr.yaml b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/shared/locales/fr.yaml new file mode 100644 index 00000000..82c1aec6 --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/layered/project/src/shared/locales/fr.yaml @@ -0,0 +1,2 @@ +translated: Traduction shared +sharedItems: [élément partagé, éléments partagés] diff --git a/src/cli/plugins/locale/tests/fixtures/completion/missing-plural/src/locales/en.yaml b/src/cli/plugins/locale/tests/fixtures/completion/missing-plural/src/locales/en.yaml new file mode 100644 index 00000000..ee36bb4e --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/missing-plural/src/locales/en.yaml @@ -0,0 +1,3 @@ +title: Cart +cart: + items: [item, items] diff --git a/src/cli/plugins/locale/tests/fixtures/completion/missing-plural/src/locales/fr.yaml b/src/cli/plugins/locale/tests/fixtures/completion/missing-plural/src/locales/fr.yaml new file mode 100644 index 00000000..8d7db3ec --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/missing-plural/src/locales/fr.yaml @@ -0,0 +1 @@ +title: Panier diff --git a/src/cli/plugins/locale/tests/fixtures/completion/single/src/locales/de.yaml b/src/cli/plugins/locale/tests/fixtures/completion/single/src/locales/de.yaml new file mode 100644 index 00000000..b313ade3 --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/single/src/locales/de.yaml @@ -0,0 +1,7 @@ +app: + title: Deutscher Titel + greeting: Hallo {{name}} +cart: + items: + - "{{count}} Artikel" + - "{{count}} Artikel" diff --git a/src/cli/plugins/locale/tests/fixtures/completion/single/src/locales/en.yaml b/src/cli/plugins/locale/tests/fixtures/completion/single/src/locales/en.yaml new file mode 100644 index 00000000..566825a7 --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/single/src/locales/en.yaml @@ -0,0 +1,9 @@ +app: + title: English title + greeting: Hello {{ name }} +empty: Default value +zero: 0 +cart: + items: + - "{{count}} item" + - "{{count}} items" diff --git a/src/cli/plugins/locale/tests/fixtures/completion/single/src/locales/fr.yaml b/src/cli/plugins/locale/tests/fixtures/completion/single/src/locales/fr.yaml new file mode 100644 index 00000000..716567ee --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/single/src/locales/fr.yaml @@ -0,0 +1,8 @@ +app: + title: Titre français +empty: "" +extra: Seulement en français +cart: + items: + - "{{count}} article" + - "{{count}} articles" diff --git a/src/cli/plugins/locale/tests/fixtures/completion/updated/en.yaml b/src/cli/plugins/locale/tests/fixtures/completion/updated/en.yaml new file mode 100644 index 00000000..ec511c24 --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/updated/en.yaml @@ -0,0 +1,9 @@ +app: + title: Updated English title + greeting: Welcome {{ name }} +empty: Updated default value +zero: 0 +cart: + items: + - "{{count}} item" + - "{{count}} items" diff --git a/src/cli/tests/fixtures/exit-code/invalid-config/adnbn.config.ts b/src/cli/tests/fixtures/exit-code/invalid-config/adnbn.config.ts new file mode 100644 index 00000000..b004f3d9 --- /dev/null +++ b/src/cli/tests/fixtures/exit-code/invalid-config/adnbn.config.ts @@ -0,0 +1,3 @@ +export default { + lang: "unsupported", +}; diff --git a/src/cli/tests/fixtures/exit-code/missing-plural/adnbn.config.ts b/src/cli/tests/fixtures/exit-code/missing-plural/adnbn.config.ts new file mode 100644 index 00000000..626d6c0d --- /dev/null +++ b/src/cli/tests/fixtures/exit-code/missing-plural/adnbn.config.ts @@ -0,0 +1,3 @@ +export default { + version: "1.0.0", +}; diff --git a/src/cli/tests/fixtures/exit-code/missing-plural/src/locales/en.yaml b/src/cli/tests/fixtures/exit-code/missing-plural/src/locales/en.yaml new file mode 100644 index 00000000..ee36bb4e --- /dev/null +++ b/src/cli/tests/fixtures/exit-code/missing-plural/src/locales/en.yaml @@ -0,0 +1,3 @@ +title: Cart +cart: + items: [item, items] diff --git a/src/cli/tests/fixtures/exit-code/missing-plural/src/locales/fr.yaml b/src/cli/tests/fixtures/exit-code/missing-plural/src/locales/fr.yaml new file mode 100644 index 00000000..8d7db3ec --- /dev/null +++ b/src/cli/tests/fixtures/exit-code/missing-plural/src/locales/fr.yaml @@ -0,0 +1 @@ +title: Panier diff --git a/src/cli/tests/fixtures/exit-code/success/adnbn.config.ts b/src/cli/tests/fixtures/exit-code/success/adnbn.config.ts new file mode 100644 index 00000000..626d6c0d --- /dev/null +++ b/src/cli/tests/fixtures/exit-code/success/adnbn.config.ts @@ -0,0 +1,3 @@ +export default { + version: "1.0.0", +}; diff --git a/src/cli/tests/fixtures/exit-code/success/src/locales/en.yaml b/src/cli/tests/fixtures/exit-code/success/src/locales/en.yaml new file mode 100644 index 00000000..ee36bb4e --- /dev/null +++ b/src/cli/tests/fixtures/exit-code/success/src/locales/en.yaml @@ -0,0 +1,3 @@ +title: Cart +cart: + items: [item, items] diff --git a/src/cli/tests/fixtures/exit-code/success/src/locales/fr.yaml b/src/cli/tests/fixtures/exit-code/success/src/locales/fr.yaml new file mode 100644 index 00000000..a9ee059d --- /dev/null +++ b/src/cli/tests/fixtures/exit-code/success/src/locales/fr.yaml @@ -0,0 +1,3 @@ +title: Panier +cart: + items: [article, articles] diff --git a/src/locale/adapters/react/LocaleProvider.tsx b/src/locale/adapters/react/LocaleProvider.tsx index 3f15a85c..0d668235 100644 --- a/src/locale/adapters/react/LocaleProvider.tsx +++ b/src/locale/adapters/react/LocaleProvider.tsx @@ -15,6 +15,7 @@ export interface LocaleProviderProps { const LocaleProvider = ({children, storage, container = "html"}: PropsWithChildren) => { const locale = useMemo(() => new DynamicLocale(storage), []); + const langs = useMemo(() => locale.languageNames(), [locale]); const [lang, setLang] = useState(locale.lang()); @@ -65,6 +66,7 @@ const LocaleProvider = ({children, storage, container = "html"}: PropsWithChildr choice, change, lang, + langs, dir: getLocaleDir(lang), isRtl: isLocaleRtl(lang), }} diff --git a/src/locale/adapters/react/context.ts b/src/locale/adapters/react/context.ts index d84b8e7a..243a2cc9 100644 --- a/src/locale/adapters/react/context.ts +++ b/src/locale/adapters/react/context.ts @@ -7,6 +7,8 @@ import {LocaleNativeStructure} from "@locale/providers"; export interface LocaleContract { lang: Language; + langs: ReadonlyMap; + dir: LocaleDir; isRtl: boolean; @@ -20,12 +22,13 @@ export interface LocaleContract { export const DefaultLocale: LocaleContract = { lang: Language.English, + langs: new Map(), isRtl: false, dir: LocaleDir.LeftToRight, - t(key: string): string { - return key as string; + t(key, ..._args): string { + return key; }, - choice(key: string): string { + choice(key, _count, ..._args): string { return key; }, change(_lang: Language) {}, diff --git a/src/locale/adapters/react/localeProvider.test.ts b/src/locale/adapters/react/localeProvider.test.ts new file mode 100644 index 00000000..796546d7 --- /dev/null +++ b/src/locale/adapters/react/localeProvider.test.ts @@ -0,0 +1,115 @@ +jest.mock("@addon-core/browser", () => ({ + getI18nMessage: jest.fn(() => "en"), +})); + +import {createElement, PropsWithChildren} from "react"; +import {act, cleanup, renderHook, waitFor} from "@testing-library/react"; +import {getI18nMessage} from "@addon-core/browser"; +import LocaleProvider from "./LocaleProvider"; +import {useLocale, type LocaleContract} from "./context"; +import {Language} from "@typing/locale"; + +describe("React locale provider", () => { + const languagesDescriptor = Object.getOwnPropertyDescriptor(globalThis, "__ADNBN_DEFINED_LOCALES__"); + const keysDescriptor = Object.getOwnPropertyDescriptor(globalThis, "__ADNBN_LOCALE_KEYS__"); + const fetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, "fetch"); + + beforeEach(() => { + jest.mocked(getI18nMessage).mockReset().mockReturnValue("en"); + Object.defineProperty(globalThis, "__ADNBN_DEFINED_LOCALES__", { + configurable: true, + value: [Language.English, Language.French], + }); + Object.defineProperty(globalThis, "__ADNBN_LOCALE_KEYS__", { + configurable: true, + value: ["demo.empty"], + }); + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: jest.fn(async () => ({json: async () => ({locale: {message: "fr"}})})), + }); + }); + + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + + for (const [key, descriptor] of [ + ["__ADNBN_DEFINED_LOCALES__", languagesDescriptor], + ["__ADNBN_LOCALE_KEYS__", keysDescriptor], + ["fetch", fetchDescriptor], + ] as const) { + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + } else { + Reflect.deleteProperty(globalThis, key); + } + } + }); + + test("returns an empty map outside a provider", () => { + const {result} = renderHook(() => useLocale()); + + expect(result.current.langs).toEqual(new Map()); + expect(result.current).not.toHaveProperty("languageNames"); + }); + + test("returns keys outside a provider, ignoring substitutions and plural counts", () => { + const {result} = renderHook(() => useLocale()); + const locale = result.current as LocaleContract<{ + "app.title": {plural: false; substitutions: []}; + "app.greeting": {plural: false; substitutions: ["name"]}; + "cart.items": {plural: true; substitutions: ["count"]}; + }>; + + expect(locale.t("app.title")).toBe("app.title"); + expect(locale.t("app.greeting", {name: "Ada"})).toBe("app.greeting"); + expect(locale.choice("cart.items", 2, {count: 2})).toBe("cart.items"); + }); + + test("preserves an empty native translation on the first render without warnings or fetching", async () => { + jest.mocked(getI18nMessage).mockImplementation(key => (key === "locale" ? "ru" : "")); + Object.defineProperty(globalThis, "__ADNBN_DEFINED_LOCALES__", { + value: [Language.English, Language.Russian], + }); + const warn = jest.spyOn(console, "warn").mockImplementation(); + const wrapper = ({children}: PropsWithChildren) => + createElement(LocaleProvider, {container: false, storage: "native-empty-test"}, children); + const {result} = renderHook( + () => { + const locale = useLocale() as LocaleContract<{ + "demo.empty": {plural: false; substitutions: []}; + }>; + + return {lang: locale.lang, message: locale.t("demo.empty")}; + }, + {wrapper} + ); + + expect(result.current).toEqual({lang: Language.Russian, message: ""}); + await act(async () => {}); + expect(result.current).toEqual({lang: Language.Russian, message: ""}); + expect(warn).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + test("keeps the available language map stable while changing the selected language", async () => { + const wrapper = ({children}: PropsWithChildren) => + createElement(LocaleProvider, {container: false, storage: "locale-test"}, children); + const {result, rerender} = renderHook(() => useLocale(), {wrapper}); + + await act(async () => {}); + const langs = result.current.langs; + expect([...langs]).toEqual([ + [Language.English, "English"], + [Language.French, "Français"], + ]); + + act(() => result.current.change(Language.French)); + await waitFor(() => expect(result.current.lang).toBe(Language.French)); + rerender(); + + expect(result.current.langs).toBe(langs); + expect([...result.current.langs.keys()]).toEqual([Language.English, Language.French]); + }); +}); diff --git a/src/locale/helpers.test.ts b/src/locale/helpers.test.ts new file mode 100644 index 00000000..fd301c09 --- /dev/null +++ b/src/locale/helpers.test.ts @@ -0,0 +1,56 @@ +jest.mock("@addon-core/browser", () => ({ + getI18nMessage: jest.fn((key: string) => { + const messages: Record = { + locale: "en", + app_title: "App title", + app_greeting: "Hello {{name}}", + }; + + return messages[key] ?? ""; + }), +})); + +import {getI18nMessage} from "@addon-core/browser"; +import {resolve} from "./helpers"; + +describe("locale resolve", () => { + const keysDescriptor = Object.getOwnPropertyDescriptor(globalThis, "__ADNBN_LOCALE_KEYS__"); + + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(globalThis, "__ADNBN_LOCALE_KEYS__", { + configurable: true, + value: ["locale", "app.title", "app.greeting"], + }); + }); + + afterEach(() => { + if (keysDescriptor) { + Object.defineProperty(globalThis, "__ADNBN_LOCALE_KEYS__", keysDescriptor); + } else { + Reflect.deleteProperty(globalThis, "__ADNBN_LOCALE_KEYS__"); + } + jest.restoreAllMocks(); + }); + + test.each(["Plain title", "", "@"])('leaves "%s" unchanged without reading native messages', input => { + expect(resolve(input)).toBe(input); + expect(getI18nMessage).not.toHaveBeenCalled(); + }); + + test("resolves runtime markers through the native provider", () => { + expect(resolve("@app.title")).toBe("App title"); + expect(getI18nMessage).toHaveBeenCalledWith("app_title"); + }); + + test("keeps placeholders when resolving without substitutions", () => { + expect(resolve("@app.greeting")).toBe("Hello {{name}}"); + }); + + test("keeps the native missing-key warning and fallback", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(); + + expect(resolve("@missing")).toBe("missing"); + expect(warn).toHaveBeenCalledWith('Locale key "missing" not found in "en" language.'); + }); +}); diff --git a/src/locale/helpers.ts b/src/locale/helpers.ts index a198f15f..793d997d 100644 --- a/src/locale/helpers.ts +++ b/src/locale/helpers.ts @@ -96,7 +96,8 @@ export const resolve = (input: string): string => { const localeKey = extractLocaleKey(input); if (localeKey) { - return t(localeKey as LocaleNonPluralKeys); + // Markers are runtime strings without substitutions; keep t() strict for typed callers. + return (t as (key: string) => string)(localeKey); } return input; diff --git a/src/locale/providers/AbstractLocale.ts b/src/locale/providers/AbstractLocale.ts index f21987a4..fb4bebf7 100644 --- a/src/locale/providers/AbstractLocale.ts +++ b/src/locale/providers/AbstractLocale.ts @@ -1,5 +1,6 @@ import { Language, + LanguageNames, LocaleNonPluralKeys, LocalePluralKeys, LocaleProvider, @@ -16,6 +17,11 @@ export default abstract class implements Loc public abstract languages(): Set; + /** Returns native names for this provider's languages, preserving their order. */ + public languageNames(): Map { + return new Map([...this.languages()].map(language => [language, LanguageNames[language]])); + } + protected abstract value(key: keyof S & string): string | undefined; public trans>(key: K, ...args: LocaleSubstitutionArgs): string { diff --git a/src/locale/providers/CustomLocale.test.ts b/src/locale/providers/CustomLocale.test.ts index cc9cb147..5d112c9d 100644 --- a/src/locale/providers/CustomLocale.test.ts +++ b/src/locale/providers/CustomLocale.test.ts @@ -41,6 +41,14 @@ describe("CustomLocale", () => { expect(locale.lang()).toBe(Language.English); }); + test("languageNames() - follows the provider's languages", () => { + locale.setLang(Language.French); + expect(locale.languageNames()).toEqual(new Map([[Language.French, "Français"]])); + + locale.setLang(Language.Ukrainian); + expect(locale.languageNames()).toEqual(new Map([[Language.Ukrainian, "Українська"]])); + }); + describe("trans()", () => { test("returned the correct message if key exists and is non-empty", () => { expect(locale.trans("title" as never)).toBe("Adnbn"); diff --git a/src/locale/providers/DynamicLocale.test.ts b/src/locale/providers/DynamicLocale.test.ts index aa533890..32d5494d 100644 --- a/src/locale/providers/DynamicLocale.test.ts +++ b/src/locale/providers/DynamicLocale.test.ts @@ -5,6 +5,7 @@ jest.mock("@addon-core/browser", () => ({ import DynamicLocale from "./DynamicLocale"; import CustomLocale, {CustomLocaleData} from "./CustomLocale"; +import {getI18nMessage} from "@addon-core/browser"; import {Language} from "@typing/locale"; interface Structure { @@ -20,6 +21,86 @@ class TestDynamicLocale extends DynamicLocale { } describe("DynamicLocale", () => { + const keysDescriptor = Object.getOwnPropertyDescriptor(globalThis, "__ADNBN_LOCALE_KEYS__"); + + beforeEach(() => { + Object.defineProperty(globalThis, "__ADNBN_LOCALE_KEYS__", { + configurable: true, + value: ["empty"], + }); + jest.mocked(getI18nMessage).mockReset().mockReturnValue("en"); + }); + + afterEach(() => { + if (keysDescriptor) { + Object.defineProperty(globalThis, "__ADNBN_LOCALE_KEYS__", keysDescriptor); + } else { + Reflect.deleteProperty(globalThis, "__ADNBN_LOCALE_KEYS__"); + } + jest.restoreAllMocks(); + }); + + describe("languageNames()", () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "__ADNBN_DEFINED_LOCALES__"); + + beforeEach(() => { + Object.defineProperty(globalThis, "__ADNBN_DEFINED_LOCALES__", { + configurable: true, + value: [Language.French, Language.EnglishGreatBritain, Language.Ukrainian], + }); + }); + + afterEach(() => { + if (descriptor) { + Object.defineProperty(globalThis, "__ADNBN_DEFINED_LOCALES__", descriptor); + } else { + Reflect.deleteProperty(globalThis, "__ADNBN_DEFINED_LOCALES__"); + } + }); + + test("exposes only build languages in their original order", () => { + const locale = new DynamicLocale(false); + + expect([...locale.languageNames()]).toEqual([ + [Language.French, "Français"], + [Language.EnglishGreatBritain, "English (United Kingdom)"], + [Language.Ukrainian, "Українська"], + ]); + expect([...locale.languageNames().keys()]).toEqual([...locale.languages()]); + expect(locale.languageNames().has(Language.German)).toBe(false); + }); + + test("returns independent maps without changing the language names", () => { + const locale = new DynamicLocale(false); + const changed = locale.languageNames(); + const original = locale.languageNames(); + + expect(changed).not.toBe(original); + changed.set(Language.French, "Changed"); + changed.delete(Language.Ukrainian); + + expect(original.get(Language.French)).toBe("Français"); + expect(original.get(Language.Ukrainian)).toBe("Українська"); + expect(locale.languageNames()).toEqual(original); + }); + + test("returns an empty map for a build without locales", () => { + Object.defineProperty(globalThis, "__ADNBN_DEFINED_LOCALES__", {value: []}); + + expect(new DynamicLocale(false).languageNames()).toEqual(new Map()); + }); + }); + + test("preserves an empty native message before loading a dynamic catalog", () => { + jest.mocked(getI18nMessage).mockImplementation(key => (key === "locale" ? "ru" : "")); + const warn = jest.spyOn(console, "warn").mockImplementation(); + const locale = new DynamicLocale(false); + + expect(locale.lang()).toBe(Language.Russian); + expect(locale.trans("empty")).toBe(""); + expect(warn).not.toHaveBeenCalled(); + }); + test("does not fall back to native locale when dynamic value is empty", () => { const locale = new TestDynamicLocale(false).setLocale(Language.French, { empty: "", diff --git a/src/locale/providers/NativeLocale.test.ts b/src/locale/providers/NativeLocale.test.ts new file mode 100644 index 00000000..b3dd6dea --- /dev/null +++ b/src/locale/providers/NativeLocale.test.ts @@ -0,0 +1,83 @@ +jest.mock("@addon-core/browser", () => ({ + getI18nMessage: jest.fn(), +})); + +import {getI18nMessage} from "@addon-core/browser"; +import NativeLocale from "./NativeLocale"; + +interface Structure { + "demo.empty": {plural: false; substitutions: []}; + "demo.title": {plural: false; substitutions: []}; +} + +describe("NativeLocale", () => { + const keysDescriptor = Object.getOwnPropertyDescriptor(globalThis, "__ADNBN_LOCALE_KEYS__"); + let locale: NativeLocale; + let warn: jest.SpyInstance; + let error: jest.SpyInstance; + + beforeEach(() => { + Object.defineProperty(globalThis, "__ADNBN_LOCALE_KEYS__", { + configurable: true, + value: ["demo.empty", "demo.title"], + }); + jest.mocked(getI18nMessage) + .mockReset() + .mockImplementation(key => (key === "locale" ? "ru" : "")); + warn = jest.spyOn(console, "warn").mockImplementation(); + error = jest.spyOn(console, "error").mockImplementation(); + locale = new NativeLocale(); + }); + + afterEach(() => { + if (keysDescriptor) { + Object.defineProperty(globalThis, "__ADNBN_LOCALE_KEYS__", keysDescriptor); + } else { + Reflect.deleteProperty(globalThis, "__ADNBN_LOCALE_KEYS__"); + } + jest.restoreAllMocks(); + }); + + test("preserves an empty message for a known build key without warning", () => { + expect(locale.trans("demo.empty")).toBe(""); + expect(getI18nMessage).toHaveBeenLastCalledWith("demo_empty"); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + test("warns and returns the key when an unknown message is empty", () => { + expect(locale.get("demo.missing" as never)).toBe("demo.missing"); + expect(warn).toHaveBeenCalledWith('Locale key "demo.missing" not found in "ru" language.'); + expect(error).not.toHaveBeenCalled(); + }); + + test.each(["Translated title", "0"])("preserves the nonempty message %j without consulting build keys", value => { + jest.mocked(getI18nMessage).mockReturnValue(value); + Reflect.deleteProperty(globalThis, "__ADNBN_LOCALE_KEYS__"); + + expect(locale.trans("demo.title")).toBe(value); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + test("preserves nonempty messages outside the default build contract", () => { + jest.mocked(getI18nMessage).mockReturnValue("Extra translation"); + + expect(locale.get("demo.extra" as never)).toBe("Extra translation"); + expect(warn).not.toHaveBeenCalled(); + }); + + test("does not treat an undefined API result as a valid empty translation", () => { + jest.mocked(getI18nMessage).mockReturnValue(undefined); + + expect(locale.trans("demo.empty")).toBe("demo.empty"); + expect(warn).toHaveBeenCalledWith('Locale key "demo.empty" not found in "ru" language.'); + }); + + test("keeps missing-key diagnostics when build keys are unavailable", () => { + Reflect.deleteProperty(globalThis, "__ADNBN_LOCALE_KEYS__"); + + expect(locale.trans("demo.empty")).toBe("demo.empty"); + expect(warn).toHaveBeenCalledWith('Locale key "demo.empty" not found in "ru" language.'); + }); +}); diff --git a/src/locale/providers/NativeLocale.ts b/src/locale/providers/NativeLocale.ts index 7f05c408..67be2b5d 100644 --- a/src/locale/providers/NativeLocale.ts +++ b/src/locale/providers/NativeLocale.ts @@ -4,9 +4,10 @@ import AbstractLocale from "./AbstractLocale"; import {convertLocaleKey, resolveLanguage} from "@locale/utils"; -import {Language, LocaleCustomKeyForLanguage, LocaleProvider, LocaleStructure} from "@typing/locale"; +import {Language, LocaleCustomKeyForLanguage, LocaleProvider} from "@typing/locale"; -export interface LocaleNativeStructure extends LocaleStructure {} +/** Augmented by the generated .adnbn/locale.d.ts for the current app. */ +export interface LocaleNativeStructure {} export default class NativeLocale extends AbstractLocale { private static instance?: LocaleProvider; @@ -75,7 +76,8 @@ export default class NativeLocale exte protected value(key: Extract): string | undefined { const value = getI18nMessage(convertLocaleKey(key)); - if (!value || value.length === 0) { + // Native i18n returns an empty string for missing messages too; build keys distinguish valid empty translations. + if (value === "" && !this.keys().has(key)) { return undefined; } diff --git a/src/types/locale.test.ts b/src/types/locale.test.ts index 9d57c0b5..f108756e 100644 --- a/src/types/locale.test.ts +++ b/src/types/locale.test.ts @@ -1,6 +1,22 @@ 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(); }; diff --git a/src/types/locale.ts b/src/types/locale.ts index b1e47572..e703058a 100644 --- a/src/types/locale.ts +++ b/src/types/locale.ts @@ -75,6 +75,65 @@ export const LocaleKeyMarker = "@"; export const LanguageCodes: ReadonlySet = new Set(Object.values(Language)); +/** Native language names, independent of the selected UI language. */ +export const LanguageNames: Readonly> = Object.freeze({ + [Language.Arabic]: "العربية", + [Language.Amharic]: "አማርኛ", + [Language.Bulgarian]: "Български", + [Language.Bengali]: "বাংলা", + [Language.Catalan]: "Català", + [Language.Czech]: "Čeština", + [Language.Danish]: "Dansk", + [Language.German]: "Deutsch", + [Language.Greek]: "Ελληνικά", + [Language.English]: "English", + [Language.EnglishAustralia]: "English (Australia)", + [Language.EnglishGreatBritain]: "English (United Kingdom)", + [Language.EnglishUSA]: "English (United States)", + [Language.Spanish]: "Español", + [Language.SpanishLatinAmericaAndCaribbean]: "Español (Latinoamérica y el Caribe)", + [Language.Estonian]: "Eesti", + [Language.Persian]: "فارسی", + [Language.Finnish]: "Suomi", + [Language.Filipino]: "Filipino", + [Language.French]: "Français", + [Language.Gujarati]: "ગુજરાતી", + [Language.Hebrew]: "עברית", + [Language.Hindi]: "हिन्दी", + [Language.Croatian]: "Hrvatski", + [Language.Hungarian]: "Magyar", + [Language.Indonesian]: "Bahasa Indonesia", + [Language.Italian]: "Italiano", + [Language.Japanese]: "日本語", + [Language.Kannada]: "ಕನ್ನಡ", + [Language.Korean]: "한국어", + [Language.Lithuanian]: "Lietuvių", + [Language.Latvian]: "Latviešu", + [Language.Malayalam]: "മലയാളം", + [Language.Marathi]: "मराठी", + [Language.Malay]: "Bahasa Melayu", + [Language.Dutch]: "Nederlands", + [Language.Norwegian]: "Norsk", + [Language.Polish]: "Polski", + [Language.PortugueseBrazil]: "Português (Brasil)", + [Language.PortuguesePortugal]: "Português (Portugal)", + [Language.Romanian]: "Română", + [Language.Russian]: "Русский", + [Language.Slovak]: "Slovenčina", + [Language.Slovenian]: "Slovenščina", + [Language.Serbian]: "Српски", + [Language.Swedish]: "Svenska", + [Language.Swahili]: "Kiswahili", + [Language.Tamil]: "தமிழ்", + [Language.Telugu]: "తెలుగు", + [Language.Thai]: "ไทย", + [Language.Turkish]: "Türkçe", + [Language.Ukrainian]: "Українська", + [Language.Vietnamese]: "Tiếng Việt", + [Language.ChineseChina]: "中文(简体)", + [Language.ChineseTaiwan]: "中文(繁體)", +}); + export const LocaleFileExtensions: ReadonlySet = new Set(["yaml", "yml", "json"]); export type LocaleValue = string | number | Array; @@ -165,6 +224,8 @@ export interface LocaleProvider { languages(): Set; + languageNames(): Map; + keys(): ReadonlySet; // non-plural keys