From 9473e5e765829d64cc748dbaee3f6edc239af497 Mon Sep 17 00:00:00 2001 From: Addon Bone Date: Mon, 18 Aug 2025 17:21:50 +0300 Subject: [PATCH 01/38] Improve code readability and consistency: - Add `holds` method to `entrypoint.ts` interface for file existence checks. - Update `.toSorted` usage in `AbstractFinder` for array sorting. - Apply consistent formatting with `prettier-ignore` in various files. - Minor refactor of `AbstractEntrypointFinder` regex patterns for clarity. --- .../finder/AbstractEntrypointFinder.ts | 11 ++++++++--- src/cli/entrypoint/finder/AbstractFinder.ts | 2 +- src/cli/plugins/content/ContentManager.ts | 17 ++++++++++++----- src/cli/plugins/content/index.ts | 5 ++++- src/types/entrypoint.ts | 6 ++++++ 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts b/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts index dbc87cab..15f933dd 100644 --- a/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts +++ b/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts @@ -17,9 +17,14 @@ export default abstract class extends AbstractOptio protected constructor(config: ReadonlyConfig) { super(config); - this.fileExtensionsPattern = [...EntrypointFileExtensions].map(ext => ext.replace(".", "\\.")).join("|"); - - this.possibleIndexFiles = new Set([...EntrypointFileExtensions].map(ext => `index.${ext}`)); + // prettier-ignore + this.fileExtensionsPattern = [...EntrypointFileExtensions] + .map(ext => ext.replace(".", "\\.")) + .join("|"); + + // prettier-ignore + this.possibleIndexFiles = new Set([...EntrypointFileExtensions] + .map(ext => `index.${ext}`)); } public canMerge(): boolean { diff --git a/src/cli/entrypoint/finder/AbstractFinder.ts b/src/cli/entrypoint/finder/AbstractFinder.ts index 3488027e..e779dd57 100644 --- a/src/cli/entrypoint/finder/AbstractFinder.ts +++ b/src/cli/entrypoint/finder/AbstractFinder.ts @@ -36,7 +36,7 @@ export default abstract class implements EntrypointFinder { return this._files; } - const files = Array.from(await this.getFiles()).sort((a, b) => { + const files = Array.from(await this.getFiles()).toSorted((a, b) => { const priorityA = this.priority(a); const priorityB = this.priority(b); diff --git a/src/cli/plugins/content/ContentManager.ts b/src/cli/plugins/content/ContentManager.ts index 48ae8068..3d659391 100644 --- a/src/cli/plugins/content/ContentManager.ts +++ b/src/cli/plugins/content/ContentManager.ts @@ -26,7 +26,10 @@ export default class { } protected async getGroup(): Promise> { - const content = await Promise.all(Array.from(this.providers, provider => provider.driver().items())); + // prettier-ignore + const content = await Promise.all( + Array.from(this.providers, provider => provider.driver().items()) + ); const group: ContentGroupItems = new Map(); @@ -59,9 +62,12 @@ export default class { const manifest: ManifestContentScripts = new Set(); for (const [entry, items] of await this.group()) { - const options = Array.from(items, ({options}) => options).reduce((acc, opt) => { - return {...acc, ...opt}; - }, {} as ContentScriptEntrypointOptions); + // prettier-ignore + const options = Array + .from(items, ({options}) => options) + .reduce((acc, opt) => { + return {...acc, ...opt}; + }, {} as ContentScriptEntrypointOptions); manifest.add({entry, ...getContentScriptConfigFromOptions(options)}); } @@ -95,7 +101,8 @@ export default class { for (const provider of this.providers) { try { return provider.virtual(file); - } catch {} + } catch { + } } throw new Error(`Virtual file "${file.file}" not found.`); diff --git a/src/cli/plugins/content/index.ts b/src/cli/plugins/content/index.ts index 47ceb9b7..856005ee 100644 --- a/src/cli/plugins/content/index.ts +++ b/src/cli/plugins/content/index.ts @@ -23,7 +23,10 @@ export default definePlugin(() => { content = new Content(config); relay = new Relay(config); - manager = new ContentManager(config).provider(content).provider(relay); + // prettier-ignore + manager = new ContentManager(config) + .provider(content) + .provider(relay); relayDeclaration = new RelayDeclaration(config); }, diff --git a/src/types/entrypoint.ts b/src/types/entrypoint.ts index 64adfb35..f5597306 100644 --- a/src/types/entrypoint.ts +++ b/src/types/entrypoint.ts @@ -79,6 +79,12 @@ export interface EntrypointFinder { clear(): this; + /** + * Determines if the specified file exists within the current set of files. + * + * @param {EntrypointFile} file - The file to be checked for existence in the set of files. + * @return {boolean} Returns true if the file exists in the set, otherwise false. + */ holds(file: EntrypointFile): boolean; } From 4dd021ac44d2dd288a66545ba6c4feaeff597e55 Mon Sep 17 00:00:00 2001 From: Addon Stack Date: Wed, 10 Sep 2025 21:10:46 +0300 Subject: [PATCH 02/38] refactor(relay): enhance permission handling and improve type definitions - Refactored permission handling logic for `RelayPermission` to improve clarity and maintainability. - Updated type definitions for `declarative` field to support `ContentScriptDeclarative` enum. - Improved code formatting and consistent conditional handling across relay components. - Added return types to private methods for better type inference. --- .../bundler/plugins/EntrypointMetaPlugin.ts | 102 ++++++++++++++++++ src/cli/bundler/plugins/index.ts | 2 + src/cli/plugins/output.ts | 11 +- 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 src/cli/bundler/plugins/EntrypointMetaPlugin.ts diff --git a/src/cli/bundler/plugins/EntrypointMetaPlugin.ts b/src/cli/bundler/plugins/EntrypointMetaPlugin.ts new file mode 100644 index 00000000..3bb3546b --- /dev/null +++ b/src/cli/bundler/plugins/EntrypointMetaPlugin.ts @@ -0,0 +1,102 @@ +import type {Compiler, RspackPluginInstance} from "@rspack/core"; +import {sources} from "@rspack/core"; + +export interface EntryPointMetaPluginOptions { + + ident?: string; + + statsOptions?: { + entrypoints?: boolean; + chunks?: boolean; + assets?: boolean; + }; +} + +export default class implements RspackPluginInstance { + private readonly ident: string; + private readonly statsOptions: Required; + + constructor(opts: EntryPointMetaPluginOptions = {}) { + this.ident = opts.ident ?? "__ENTRYPOINT_META__"; + this.statsOptions = { + entrypoints: true, + chunks: true, + assets: true, + ...(opts.statsOptions ?? {}), + }; + } + + apply(compiler: Compiler) { + const PLUGIN = "EntrypointMetaPlugin"; + + + compiler.hooks.thisCompilation.tap(PLUGIN, (compilation: any) => { + const summarizeStage = (compiler as any).rspack?.Compilation?.PROCESS_ASSETS_STAGE_SUMMARIZE ?? 10000; + + compilation.hooks.processAssets.tap( + {name: PLUGIN, stage: summarizeStage}, + () => { + const stats = compilation.getStats().toJson(this.statsOptions); + const byEntry: Record + = {}; + + for (const [name, ep] of Object.entries(stats.entrypoints ?? {})) { + const files = (ep.assets ?? []) + .map((a: any) => (typeof a === "string" ? a : a?.name)) + .filter(Boolean); + + const js = files.filter((f) => typeof f === "string" && f.endsWith(".js")); + const css = files.filter((f) => typeof f === "string" && f.endsWith(".css")); + const others = files.filter((f) => typeof f === "string" && !f.endsWith(".js") && !f.endsWith(".css")); + + byEntry[name] = { + name, + files: files as string[], + js: js as string[], + css: css as string[], + assets: others as string[] + }; + } + + const fullLiteral = JSON.stringify(byEntry); + + const META_RE = /(["'])__ADNBN_EM__META__\1/g; + const NAME_RE = /(["'])__ADNBN_EM__NAME__\1/g; + + + for (const [entryName, meta] of Object.entries(byEntry)) { + const nameLiteral = JSON.stringify(entryName); // строковый литерал с кавычками + for (const assetName of meta.js) { + const asset = compilation.getAsset(assetName); + if (!asset) continue; + const src = asset.source.source().toString(); + let replaced = src; + let changed = false; + + if (META_RE.test(replaced)) { + replaced = replaced.replace(META_RE, `(${fullLiteral})`); + changed = true; + } + + if (NAME_RE.test(replaced)) { + replaced = replaced.replace(NAME_RE, `(${nameLiteral})`); + changed = true; + } + if (!changed) continue; + compilation.updateAsset( + assetName, + () => new sources.RawSource(replaced) + ); + } + } + } + ); + }); + } +} \ No newline at end of file diff --git a/src/cli/bundler/plugins/index.ts b/src/cli/bundler/plugins/index.ts index 20072af0..9ef34bef 100644 --- a/src/cli/bundler/plugins/index.ts +++ b/src/cli/bundler/plugins/index.ts @@ -13,6 +13,8 @@ export { type GenerateJsonPluginUpdate, } from "./GenerateJsonPlugin"; +export {default as EntrypointMetaPlugin} from "./EntrypointMetaPlugin"; + export {default as ManifestPlugin} from "./ManifestPlugin"; export {default as ReplacePlugin} from "./ReplacePlugin"; diff --git a/src/cli/plugins/output.ts b/src/cli/plugins/output.ts index 5d2d0f08..f7b93594 100644 --- a/src/cli/plugins/output.ts +++ b/src/cli/plugins/output.ts @@ -1,9 +1,9 @@ import _ from "lodash"; -import {Configuration as RspackConfig} from "@rspack/core"; +import {Configuration as RspackConfig, DefinePlugin} from "@rspack/core"; import {definePlugin} from "@main/plugin"; -import {appFilenameResolver} from "@cli/bundler"; +import {appFilenameResolver, EntrypointMetaPlugin} from "@cli/bundler"; import {getOutputPath, getResolvePath} from "@cli/resolvers/path"; import {Command} from "@typing/app"; @@ -29,6 +29,13 @@ export default definePlugin(() => { uniqueName: kebabApp, clean: config.command === Command.Build, }, + plugins: [ + new DefinePlugin({ + __ENTRYPOINT_META__: "(0, \"__ADNBN_EM__META__\")", + __ENTRYPOINT_NAME__: "(0, \"__ADNBN_EM__NAME__\")", + }), + new EntrypointMetaPlugin() + ], } satisfies RspackConfig; }, }; From 77f8b2bd99804641165b72b126dac4883eacdb69 Mon Sep 17 00:00:00 2001 From: Addon Stack Date: Wed, 10 Sep 2025 22:59:43 +0300 Subject: [PATCH 03/38] docs: improve formatting and clarity in contribution and policy documents --- CODE_OF_CONDUCT.md | 23 +++++++++++++---- CONTRIBUTING.md | 61 +++++++++++++++++++++++++++++----------------- SECURITY.md | 8 ++++++ 3 files changed, 64 insertions(+), 28 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 0604c0f1..5d6e566b 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -3,12 +3,15 @@ This project (Addon Bone by Addon Stack) adopts the Contributor Covenant, version 2.1, to help us maintain a friendly, safe, and inclusive community. ## Our Pledge + We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards + Examples of behavior that contributes to a positive environment for our community include: + - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback @@ -16,6 +19,7 @@ Examples of behavior that contributes to a positive environment for our communit - Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: + - The use of sexualized language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment @@ -23,14 +27,17 @@ Examples of unacceptable behavior include: - Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities + Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope + This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement + Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at: - Email: addonbonedev@gmail.com @@ -39,25 +46,31 @@ Instances of abusive, harassing, or otherwise unacceptable behavior may be repor All reports will be reviewed and investigated promptly and fairly. We will respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines + Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: -1) Correction +1. Correction + - Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. - Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. -2) Warning +2. Warning + - Community Impact: A violation through a single incident or series of actions. - Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. -3) Temporary Ban +3. Temporary Ban + - Community Impact: A serious violation of community standards, including sustained inappropriate behavior. - Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. -4) Permanent Ban +4. Permanent Ban + - Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. - Consequence: A permanent ban from any sort of public interaction within the community. ## Attribution + This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at: https://www.contributor-covenant.org/version/2/1/code_of_conduct/ @@ -68,4 +81,4 @@ For answers to common questions about this code of conduct, see the FAQ at: https://www.contributor-covenant.org/faq Translations are available at: -https://www.contributor-covenant.org/translations \ No newline at end of file +https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2cc0adad..9e2ee405 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,9 +3,11 @@ Thank you for your interest in contributing to Addon Bone! This guide explains the rules and processes that help us work consistently. ## Code of Conduct + By participating, you agree to follow our Code of Conduct (see `CODE_OF_CONDUCT.md`). To report violations, email addonbonedev@gmail.com or use Issues: https://github.com/addon-stack/addon-bone/issues ## Mandatory commit format — Conventional Commits + We follow Conventional Commits 1.0.0: https://www.conventionalcommits.org/en/v1.0.0/ Basic syntax: @@ -18,6 +20,7 @@ Basic syntax: `[Closes #123]` Examples: + - `feat(relay): add proxy support for multi-tenant routing` - `fix(storage): prevent data race in local adapter` - `docs(readme): clarify installation steps` @@ -26,73 +29,85 @@ Examples: - `chore(release): bump version to 0.3.0` Allowed types: + - `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert` Recommended scopes for this repository: + - `cli`, `entrypoint`, `manifest`, `plugins` - `relay`, `service`, `storage`, `message`, `locale`, `offscreen`, `transport` - `docs`, `tests`, `build`, `deps`, `tooling` Rules: + - Subject line up to ~72 characters, no trailing period. - English commit messages are preferred for consistency. - Mark breaking changes: add `!` after the scope or include a `BREAKING CHANGE` section. -- Link issues in the footer: `Closes`/`Refs` `#`. +- Link issues in the footer: `Closes`/`Refs` `#`. ## Branching — Git Flow + The project follows Git Flow. Main branches: + - `main` — stable releases - `develop` — integration branch Temporary branches: + - `feature/` — new features (branch from `develop` → merge into `develop`) - `bugfix/` — fixes that don’t require urgent release (from `develop` → to `develop`) - `release/` — release prep (from `develop` → into `main` and back into `develop`) - `hotfix/` — urgent production fixes (from `main` → into `main` and back into `develop`) PR rules: + - For features and regular fixes, open PRs into `develop`. - For hotfixes, open PRs into `main`; after release, back-merge/PR into `develop`. - Use PR titles in Conventional Commits style (scope optional but encouraged). - One PR = one logical change. Keep diffs small. Releases and versioning: + - We use SemVer. Commit types help determine version bumps (`feat` → minor, `fix` → patch, `BREAKING CHANGE` → major). - Package version is updated by maintainers within release/hotfix branches. ## How to contribute -1) Fork the repository and create a branch from `develop` (or `main` for `hotfix`): - - `feature/my-cool-feature` - - `bugfix/fix-storage-race` - - `hotfix/0.2.10` -2) Install dependencies: `npm install` -3) Make changes and ensure commits follow Conventional Commits. -4) Before opening a PR, run local checks: - - Formatting: `npm run format` - - Tests: `npm test` - - By area (if needed): - - `npm run test:relay` - - `npm run test:service` - - `npm run test:storage` - - `npm run test:message` - - `npm run test:locale` - - `npm run test:manifest` - - `npm run test:entrypoint` - - `npm run test:plugins` -5) Open a PR to the appropriate branch (usually `develop`) and include: - - motivation and solution; - - alternatives and trade-offs (if any); - - related issues (e.g., `Closes #123`). + +1. Fork the repository and create a branch from `develop` (or `main` for `hotfix`): + - `feature/my-cool-feature` + - `bugfix/fix-storage-race` + - `hotfix/0.2.10` +2. Install dependencies: `npm install` +3. Make changes and ensure commits follow Conventional Commits. +4. Before opening a PR, run local checks: + - Formatting: `npm run format` + - Tests: `npm test` + - By area (if needed): + - `npm run test:relay` + - `npm run test:service` + - `npm run test:storage` + - `npm run test:message` + - `npm run test:locale` + - `npm run test:manifest` + - `npm run test:entrypoint` + - `npm run test:plugins` +5. Open a PR to the appropriate branch (usually `develop`) and include: + - motivation and solution; + - alternatives and trade-offs (if any); + - related issues (e.g., `Closes #123`). ## Code style and formatting + - Use Prettier: `npm run format` - Follow existing patterns in the codebase. For large refactors, use separate PRs. ## Contribution license + The project is MIT-licensed (see `LICENSE.md`). By contributing, you agree your contributions are licensed under MIT. ## Questions and support + - Issue tracker: https://github.com/addon-stack/addon-bone/issues - General questions can be raised there as a Discussion/Issue. diff --git a/SECURITY.md b/SECURITY.md index c7826a81..3180e590 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,15 +1,18 @@ # Security Policy ## Supported Versions + We aim to maintain the latest release line of this project. If you use an older version, please consider upgrading to receive security fixes. ## Reporting a Vulnerability + Please report security issues privately and avoid opening public issues with exploitable details. - Preferred contact: addonbonedev@gmail.com - For non-sensitive/general bugs, use the issue tracker: https://github.com/addon-stack/addon-bone/issues When reporting, please include (if possible): + - Affected version(s) and package name (adnbn) and how you installed it - Environment details (OS, Node.js version, browser/runtime, relevant configs) - Steps to reproduce and a minimal proof of concept (PoC) @@ -18,18 +21,23 @@ When reporting, please include (if possible): We will acknowledge your report within 72 hours and keep you informed as we triage and address the issue. ## Coordinated Disclosure + We follow coordinated disclosure practices: + - We collaborate with you to verify the issue and determine the fix. - A fix will be released as soon as feasible, depending on severity and complexity. - After a fix is available, we will publish release notes describing the impact and mitigation. - Please refrain from public disclosure until a fix has been released or we agree on a timeline together. ## Credit + We are happy to acknowledge reporters in release notes (unless you prefer to remain anonymous). If you would like a specific name or handle to be used, let us know. ## CVEs and Bounties + - CVE assignment may be considered on a case-by-case basis. - We do not operate a bug bounty program at this time. ## Scope + This policy covers the code and packages maintained in this repository. Issues in third-party dependencies should be reported upstream to their maintainers when appropriate. From d4886cb2569207cdb842639c21ac9b4111c2d1f4 Mon Sep 17 00:00:00 2001 From: Addon Stack Date: Wed, 10 Sep 2025 23:00:22 +0300 Subject: [PATCH 04/38] refactor(cli): improve EntrypointMetaPlugin for better asset handling and modularity --- .../bundler/plugins/EntrypointMetaPlugin.ts | 149 +++++++++++------- src/cli/plugins/output.ts | 10 +- 2 files changed, 90 insertions(+), 69 deletions(-) diff --git a/src/cli/bundler/plugins/EntrypointMetaPlugin.ts b/src/cli/bundler/plugins/EntrypointMetaPlugin.ts index 3bb3546b..87de52f6 100644 --- a/src/cli/bundler/plugins/EntrypointMetaPlugin.ts +++ b/src/cli/bundler/plugins/EntrypointMetaPlugin.ts @@ -1,8 +1,10 @@ -import type {Compiler, RspackPluginInstance} from "@rspack/core"; -import {sources} from "@rspack/core"; +import type {Compilation, Compiler, RspackPluginInstance} from "@rspack/core"; +import {DefinePlugin, sources} from "@rspack/core"; +import {customAlphabet} from "nanoid"; -export interface EntryPointMetaPluginOptions { +const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +export interface EntryPointMetaPluginOptions { ident?: string; statsOptions?: { @@ -12,9 +14,15 @@ export interface EntryPointMetaPluginOptions { }; } +const generateId = customAlphabet("abcdefghijklmnopqrstuvwxyz1234567890", 24); + export default class implements RspackPluginInstance { private readonly ident: string; private readonly statsOptions: Required; + private readonly metaSentinel: string; + private readonly nameSentinel: string; + private readonly metaRegex: RegExp; + private readonly nameRegex: RegExp; constructor(opts: EntryPointMetaPluginOptions = {}) { this.ident = opts.ident ?? "__ENTRYPOINT_META__"; @@ -24,79 +32,98 @@ export default class implements RspackPluginInstance { assets: true, ...(opts.statsOptions ?? {}), }; + + const suffix = generateId(24); + this.metaSentinel = `__ADNBN_EM__META__${suffix}__`; + this.nameSentinel = `__ADNBN_EM__NAME__${suffix}__`; + this.metaRegex = new RegExp(`(["'])${escapeRegex(this.metaSentinel)}\\1`, "g"); + this.nameRegex = new RegExp(`(["'])${escapeRegex(this.nameSentinel)}\\1`, "g"); } apply(compiler: Compiler) { const PLUGIN = "EntrypointMetaPlugin"; + new DefinePlugin({ + __ENTRYPOINT_META__: `(0, "${this.metaSentinel}")`, + __ENTRYPOINT_NAME__: `(0, "${this.nameSentinel}")`, + }).apply(compiler); - compiler.hooks.thisCompilation.tap(PLUGIN, (compilation: any) => { - const summarizeStage = (compiler as any).rspack?.Compilation?.PROCESS_ASSETS_STAGE_SUMMARIZE ?? 10000; - - compilation.hooks.processAssets.tap( - {name: PLUGIN, stage: summarizeStage}, - () => { - const stats = compilation.getStats().toJson(this.statsOptions); - const byEntry: Record - = {}; - - for (const [name, ep] of Object.entries(stats.entrypoints ?? {})) { - const files = (ep.assets ?? []) - .map((a: any) => (typeof a === "string" ? a : a?.name)) - .filter(Boolean); - - const js = files.filter((f) => typeof f === "string" && f.endsWith(".js")); - const css = files.filter((f) => typeof f === "string" && f.endsWith(".css")); - const others = files.filter((f) => typeof f === "string" && !f.endsWith(".js") && !f.endsWith(".css")); - - byEntry[name] = { - name, - files: files as string[], - js: js as string[], - css: css as string[], - assets: others as string[] - }; - } + compiler.hooks.thisCompilation.tap(PLUGIN, (compilation: Compilation) => { + const summarizeStage = compiler.rspack.Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE; - const fullLiteral = JSON.stringify(byEntry); + compilation.hooks.processAssets.tap({name: PLUGIN, stage: summarizeStage}, () => { + const META_RE = this.metaRegex; + const NAME_RE = this.nameRegex; - const META_RE = /(["'])__ADNBN_EM__META__\1/g; - const NAME_RE = /(["'])__ADNBN_EM__NAME__\1/g; + const byEntry: Record = {}; + const chunkGraph = compilation.chunkGraph; - for (const [entryName, meta] of Object.entries(byEntry)) { - const nameLiteral = JSON.stringify(entryName); // строковый литерал с кавычками - for (const assetName of meta.js) { - const asset = compilation.getAsset(assetName); - if (!asset) continue; - const src = asset.source.source().toString(); - let replaced = src; - let changed = false; + for (const [entryName, ep] of compilation.entrypoints) { + const fileSet = new Set(); + const chunks = ep.chunks ?? []; - if (META_RE.test(replaced)) { - replaced = replaced.replace(META_RE, `(${fullLiteral})`); - changed = true; - } + for (const chunk of chunks) { + for (const f of chunk.files) { + fileSet.add(String(f)); + } - if (NAME_RE.test(replaced)) { - replaced = replaced.replace(NAME_RE, `(${nameLiteral})`); - changed = true; + for (const f of chunk.auxiliaryFiles) { + fileSet.add(String(f)); + } + } + + for (const chunk of chunks) { + const mods = chunkGraph.getChunkModulesIterable(chunk); + + for (const mod of mods) { + for (const a of Object.keys(mod.buildInfo.assets)) { + if (compilation.getAsset(a)) { + fileSet.add(a); + } } - if (!changed) continue; - compilation.updateAsset( - assetName, - () => new sources.RawSource(replaced) - ); + } + } + + const files = Array.from(fileSet); + const js = files.filter(f => f.endsWith(".js")); + const css = files.filter(f => f.endsWith(".css")); + const others = files.filter(f => !f.endsWith(".js") && !f.endsWith(".css")); + + byEntry[entryName] = {js, css, assets: others}; + } + + const fullLiteral = JSON.stringify(byEntry); + + for (const [entryName, meta] of Object.entries(byEntry)) { + const nameLiteral = JSON.stringify(entryName); + + for (const assetName of meta.js) { + const asset = compilation.getAsset(assetName); + + if (!asset) { + continue; + } + + let replaced = asset.source.source().toString(); + let changed = false; + + if (META_RE.test(replaced)) { + replaced = replaced.replace(META_RE, `(${fullLiteral})`); + changed = true; + } + + if (NAME_RE.test(replaced)) { + replaced = replaced.replace(NAME_RE, `(${nameLiteral})`); + changed = true; + } + + if (changed) { + compilation.updateAsset(assetName, () => new sources.RawSource(replaced)); } } } - ); + }); }); } -} \ No newline at end of file +} diff --git a/src/cli/plugins/output.ts b/src/cli/plugins/output.ts index f7b93594..15ab8983 100644 --- a/src/cli/plugins/output.ts +++ b/src/cli/plugins/output.ts @@ -1,5 +1,5 @@ import _ from "lodash"; -import {Configuration as RspackConfig, DefinePlugin} from "@rspack/core"; +import {Configuration as RspackConfig} from "@rspack/core"; import {definePlugin} from "@main/plugin"; @@ -29,13 +29,7 @@ export default definePlugin(() => { uniqueName: kebabApp, clean: config.command === Command.Build, }, - plugins: [ - new DefinePlugin({ - __ENTRYPOINT_META__: "(0, \"__ADNBN_EM__META__\")", - __ENTRYPOINT_NAME__: "(0, \"__ADNBN_EM__NAME__\")", - }), - new EntrypointMetaPlugin() - ], + plugins: [new EntrypointMetaPlugin()], } satisfies RspackConfig; }, }; From c839497569d62fffe38cd2f553be049313c13194 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:23:48 +0300 Subject: [PATCH 05/38] ci(release): prepare protected main automation --- .github/scripts/releaseAutomation.cjs | 72 +++++++ .../scripts/validateMainPullRequestSource.mjs | 26 +++ .github/workflows/ci.yml | 1 + .github/workflows/main-pr.yml | 25 +++ .github/workflows/release.yml | 71 ++++--- .release-it.cjs | 2 +- tests/mainSourcePolicy.test.ts | 105 ++++++++++ tests/releaseAutomation.test.ts | 197 ++++++++++++++++++ 8 files changed, 474 insertions(+), 25 deletions(-) create mode 100644 .github/scripts/releaseAutomation.cjs create mode 100644 .github/scripts/validateMainPullRequestSource.mjs create mode 100644 .github/workflows/main-pr.yml create mode 100644 tests/mainSourcePolicy.test.ts create mode 100644 tests/releaseAutomation.test.ts diff --git a/.github/scripts/releaseAutomation.cjs b/.github/scripts/releaseAutomation.cjs new file mode 100644 index 00000000..d3f1d0ad --- /dev/null +++ b/.github/scripts/releaseAutomation.cjs @@ -0,0 +1,72 @@ +const {spawnSync} = require("node:child_process"); +const path = require("node:path"); + +const root = path.resolve(__dirname, "../.."); + +const shouldRelease = env => { + if (env.GITHUB_REF !== "refs/heads/main" || !["push", "workflow_dispatch"].includes(env.GITHUB_EVENT_NAME)) { + return false; + } + + const actor = env.GITHUB_ACTOR; + const appSlug = env.RELEASE_APP_SLUG; + if (!actor || !appSlug) { + throw new Error("Release actor and App slug are required."); + } + + // The authenticated actor survives reruns; commit authors and messages can be spoofed. + return actor.toLowerCase() !== `${appSlug}[bot]`.toLowerCase(); +}; + +const getReleaseArguments = env => { + // The policy job runs before npm ci, so load this dependency only for release arguments. + const {validRange} = require("semver"); + const tag = env.RELEASE_NPM_TAG || "latest"; + // release-it later passes ['--tag', tag] to npm, where a leading '--' could become another option. + if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(tag) || validRange(tag)) { + throw new Error("Invalid npm dist-tag."); + } + + return [ + "--ci", + ...(env.RELEASE_PREID ? [`--preRelease=${env.RELEASE_PREID}`] : []), + `--npm.tag=${tag}`, + ...(env.RELEASE_VERSION ? [`--increment=${env.RELEASE_VERSION}`] : []), + ]; +}; + +const runRelease = (env = process.env, execute = spawnSync) => { + if (!shouldRelease(env)) { + throw new Error("This event is not allowed to release."); + } + if (env.RELEASE_TOKEN_APP_SLUG !== env.RELEASE_APP_SLUG) { + throw new Error("The release token App does not match RELEASE_APP_SLUG."); + } + + const result = execute( + process.execPath, + [path.join(root, "node_modules/release-it/bin/release-it.js"), ...getReleaseArguments(env)], + {cwd: root, env, shell: false, stdio: "inherit"} + ); + if (result.error) { + throw result.error; + } + return result.status ?? 1; +}; + +if (require.main === module) { + try { + if (process.argv[2] === "policy") { + process.stdout.write(`should_release=${shouldRelease(process.env)}\n`); + } else if (process.argv[2] === "run") { + process.exitCode = runRelease(); + } else { + throw new Error("Expected policy or run."); + } + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} + +module.exports = {shouldRelease, getReleaseArguments, runRelease}; diff --git a/.github/scripts/validateMainPullRequestSource.mjs b/.github/scripts/validateMainPullRequestSource.mjs new file mode 100644 index 00000000..0be45599 --- /dev/null +++ b/.github/scripts/validateMainPullRequestSource.mjs @@ -0,0 +1,26 @@ +import {readFileSync} from "node:fs"; + +try { + const event = JSON.parse(readFileSync(0, "utf8")); + const pullRequest = event?.pull_request; + const headRepositoryId = pullRequest?.head?.repo?.id; + const baseRepositoryId = pullRequest?.base?.repo?.id; + const allowed = + pullRequest?.base?.ref === "main" && + pullRequest?.head?.ref === "develop" && + Number.isSafeInteger(headRepositoryId) && + headRepositoryId > 0 && + Number.isSafeInteger(baseRepositoryId) && + baseRepositoryId > 0 && + headRepositoryId === baseRepositoryId; + + if (allowed) { + console.log("Source policy passed: develop -> main in the same repository."); + } else { + console.error("Only pull requests from this repository's develop branch may target main."); + process.exitCode = 1; + } +} catch { + console.error("Unable to read valid pull request event JSON."); + process.exitCode = 1; +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index daa58bb0..f32a31cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: - 'feature/**' pull_request: branches: + - main - develop - 'feature/**' workflow_call: diff --git a/.github/workflows/main-pr.yml b/.github/workflows/main-pr.yml new file mode 100644 index 00000000..f0ae4a37 --- /dev/null +++ b/.github/workflows/main-pr.yml @@ -0,0 +1,25 @@ +name: Main PR + +on: + pull_request_target: + branches: + - main + types: [opened, reopened, synchronize, edited] + +permissions: + contents: read + +jobs: + source-policy: + name: Main PR source (develop only) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout trusted policy + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + + - name: Validate pull request source + run: node .github/scripts/validateMainPullRequestSource.mjs < "$GITHUB_EVENT_PATH" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index faf12aba..48772d72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,12 +21,34 @@ on: type: string permissions: - contents: write - id-token: write + contents: read jobs: + policy: + name: Release policy + if: >- + github.ref == 'refs/heads/main' && + github.actor != format('{0}[bot]', vars.RELEASE_APP_SLUG) + runs-on: ubuntu-latest + outputs: + should_release: ${{ steps.policy.outputs.should_release }} + steps: + - name: Checkout trusted workflow revision + uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + + - name: Check release event + id: policy + env: + RELEASE_APP_SLUG: ${{ vars.RELEASE_APP_SLUG }} + run: node .github/scripts/releaseAutomation.cjs policy >> "$GITHUB_OUTPUT" + ci: name: CI + needs: policy + if: needs.policy.outputs.should_release == 'true' uses: ./.github/workflows/ci.yml with: full: true @@ -34,13 +56,28 @@ jobs: release: name: Release & Publish runs-on: ubuntu-latest - needs: ci + needs: [policy, ci] + if: needs.policy.outputs.should_release == 'true' + environment: release + permissions: + contents: write + id-token: write steps: + - name: Create release App token + id: release-app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + # Omitting owner/repositories scopes the token to this repository only. + permission-contents: write + - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 fetch-tags: true + token: ${{ steps.release-app-token.outputs.token }} - name: Use Node.js uses: actions/setup-node@v4 @@ -65,26 +102,13 @@ jobs: env: DEBUG: release-it:*,@release-it/* HUSKY: 0 - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION_ARG="" - if [ -n "${{ inputs.version }}" ]; then - VERSION_ARG="${{ inputs.version }}" - fi - - PREID_ARG="" - if [ -n "${{ inputs.preid }}" ]; then - PREID_ARG="--preRelease=${{ inputs.preid }}" - fi - - if [ -n "${{ inputs.npm_tag }}" ]; then - NPM_TAG="${{ inputs.npm_tag }}" - else - NPM_TAG="latest" - fi - NPM_TAG_ARG="--npm.tag=${NPM_TAG}" - - npx release-it --ci $PREID_ARG $NPM_TAG_ARG $VERSION_ARG + GITHUB_TOKEN: ${{ steps.release-app-token.outputs.token }} + RELEASE_APP_SLUG: ${{ vars.RELEASE_APP_SLUG }} + RELEASE_TOKEN_APP_SLUG: ${{ steps.release-app-token.outputs.app-slug }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_PREID: ${{ inputs.preid }} + RELEASE_NPM_TAG: ${{ inputs.npm_tag }} + run: node .github/scripts/releaseAutomation.cjs run - name: Sync main → develop uses: devmasx/merge-branch@v1.4.0 @@ -95,4 +119,3 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} env: HUSKY: 0 - diff --git a/.release-it.cjs b/.release-it.cjs index e684d10a..c4f167a8 100644 --- a/.release-it.cjs +++ b/.release-it.cjs @@ -180,7 +180,7 @@ const createReleaseConfig = () => { git: { requireCleanWorkingDir: true, requireUpstream: false, - requireBranch: false, + requireBranch: "main", commit: true, commitMessage: "chore(release): v${version}", tag: true, diff --git a/tests/mainSourcePolicy.test.ts b/tests/mainSourcePolicy.test.ts new file mode 100644 index 00000000..dcbdfb72 --- /dev/null +++ b/tests/mainSourcePolicy.test.ts @@ -0,0 +1,105 @@ +import {spawnSync} from "node:child_process"; +import {readFileSync} from "node:fs"; +import path from "node:path"; +import {load} from "js-yaml"; + +const root = path.resolve(__dirname, ".."); +const script = path.join(root, ".github/scripts/validateMainPullRequestSource.mjs"); +const runPolicy = (input: string) => + spawnSync(process.execPath, [script], { + cwd: root, + encoding: "utf8", + input, + timeout: 10_000, + }); + +const event = (head: unknown = {ref: "develop", repo: {id: 123}}, base: unknown = {ref: "main", repo: {id: 123}}) => ({ + pull_request: {head, base}, +}); + +describe("main pull request source policy", () => { + test("allows develop to main in the same repository", () => { + const result = runPolicy(JSON.stringify(event())); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Source policy passed: develop -> main in the same repository."); + }); + + test.each([ + ["another source branch", event({ref: "feature/fix", repo: {id: 123}})], + ["a differently cased source branch", event({ref: "Develop", repo: {id: 123}})], + ["a fork's develop branch", event({ref: "develop", repo: {id: 456}})], + ["a different target branch", event(undefined, {ref: "develop", repo: {id: 123}})], + ["missing head repository", event({ref: "develop"})], + ["missing base repository", event(undefined, {ref: "main"})], + ["both repositories missing", event({ref: "develop"}, {ref: "main"})], + ["a deleted head repository", event({ref: "develop", repo: null})], + ["string repository IDs", event({ref: "develop", repo: {id: "123"}}, {ref: "main", repo: {id: "123"}})], + ["invalid repository IDs", event({ref: "develop", repo: {id: 0}}, {ref: "main", repo: {id: 0}})], + ["missing pull request", {}], + ["null event", null], + ["false event", false], + ])("rejects %s", (_label, payload) => { + const result = runPolicy(JSON.stringify(payload)); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Only pull requests from this repository's develop branch may target main."); + }); + + test("fails closed for invalid JSON", () => { + const result = runPolicy("{"); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Unable to read valid pull request event JSON."); + }); +}); + +type Workflow = { + on: Record; + permissions?: Record; + jobs: Record< + string, + { + name?: string; + if?: string; + steps: Array<{uses?: string; run?: string; with?: Record}>; + } + >; +}; + +const workflow = (name: string) => load(readFileSync(path.join(root, ".github/workflows", name), "utf8")) as Workflow; + +describe("main source policy workflow", () => { + const policy = workflow("main-pr.yml"); + const job = policy.jobs["source-policy"]; + + test("covers main PR creation, updates, reopening, and retargeting using a trusted event", () => { + expect(policy.on).toEqual({ + pull_request_target: { + branches: ["main"], + types: ["opened", "reopened", "synchronize", "edited"], + }, + }); + expect(job.if).toBeUndefined(); + }); + + test("executes only the policy from the trusted workflow revision with read-only permissions", () => { + expect(policy.permissions).toEqual({contents: "read"}); + expect(job.steps).toEqual([ + expect.objectContaining({ + uses: "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683", + with: {ref: "${{ github.workflow_sha }}", "persist-credentials": false}, + }), + expect.objectContaining({ + run: 'node .github/scripts/validateMainPullRequestSource.mjs < "$GITHUB_EVENT_PATH"', + }), + ]); + }); + + test("runs CI before merging to main without changing development push targets", () => { + const ci = workflow("ci.yml"); + + expect(ci.on.pull_request.branches).toEqual(["main", "develop", "feature/**"]); + expect(ci.on.push.branches).toEqual(["develop", "feature/**"]); + }); +}); diff --git a/tests/releaseAutomation.test.ts b/tests/releaseAutomation.test.ts new file mode 100644 index 00000000..4816d54d --- /dev/null +++ b/tests/releaseAutomation.test.ts @@ -0,0 +1,197 @@ +import {spawnSync} from "node:child_process"; +import path from "node:path"; + +type Environment = Record; +type Execute = ( + command: string, + args: string[], + options: {cwd: string; env: Environment; shell: false; stdio: "inherit"} +) => {status: number | null; error?: Error}; + +const {shouldRelease, getReleaseArguments, runRelease} = require("../.github/scripts/releaseAutomation.cjs") as { + shouldRelease: (env: Environment) => boolean; + getReleaseArguments: (env: Environment) => string[]; + runRelease: (env: Environment, execute: Execute) => number; +}; + +const root = path.resolve(__dirname, ".."); +const script = path.join(root, ".github/scripts/releaseAutomation.cjs"); +const environment = (overrides: Environment = {}): Environment => ({ + GITHUB_EVENT_NAME: "push", + GITHUB_REF: "refs/heads/main", + GITHUB_ACTOR: "maintainer", + RELEASE_APP_SLUG: "addon-bone-release", + RELEASE_TOKEN_APP_SLUG: "addon-bone-release", + ...overrides, +}); + +describe("release automation event policy", () => { + test.each(["push", "workflow_dispatch"])("allows a human %s on main", eventName => { + expect(shouldRelease(environment({GITHUB_EVENT_NAME: eventName}))).toBe(true); + }); + + test.each([ + ["a development push", {GITHUB_REF: "refs/heads/develop"}], + [ + "a manual run from another branch", + {GITHUB_EVENT_NAME: "workflow_dispatch", GITHUB_REF: "refs/heads/develop"}, + ], + ["a tag named main", {GITHUB_REF: "refs/tags/main"}], + ["a different event", {GITHUB_EVENT_NAME: "pull_request"}], + ["the release App's push", {GITHUB_ACTOR: "addon-bone-release[bot]"}], + [ + "a human rerun of the release App's push", + {GITHUB_ACTOR: "addon-bone-release[bot]", GITHUB_TRIGGERING_ACTOR: "maintainer"}, + ], + ])("skips %s", (_label, overrides) => { + expect(shouldRelease(environment(overrides))).toBe(false); + }); + + test("does not trust a release-looking commit message to skip a human push", () => { + expect(shouldRelease(environment({GITHUB_HEAD_COMMIT_MESSAGE: "chore(release): v9.9.9"}))).toBe(true); + }); + + test.each(["GITHUB_ACTOR", "RELEASE_APP_SLUG"])("fails closed when %s is missing", key => { + expect(() => shouldRelease(environment({[key]: ""}))).toThrow("Release actor and App slug are required."); + }); + + test("emits the policy job output without invoking release-it", () => { + const result = spawnSync(process.execPath, [script, "policy"], { + cwd: root, + env: {...process.env, ...environment()}, + encoding: "utf8", + timeout: 10_000, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("should_release=true\n"); + }); + + test("does not release when the command is omitted", () => { + const result = spawnSync(process.execPath, [script], { + cwd: root, + env: {...process.env, ...environment()}, + encoding: "utf8", + timeout: 10_000, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Expected policy or run."); + }); +}); + +describe("release automation invocation", () => { + test("keeps automatic bumping and the latest npm tag by default", () => { + expect(getReleaseArguments(environment())).toEqual(["--ci", "--npm.tag=latest"]); + }); + + test("passes explicit manual release options", () => { + expect( + getReleaseArguments( + environment({RELEASE_VERSION: "1.2.3-beta.1", RELEASE_PREID: "beta", RELEASE_NPM_TAG: "beta"}) + ) + ).toEqual(["--ci", "--preRelease=beta", "--npm.tag=beta", "--increment=1.2.3-beta.1"]); + }); + + test.each(["latest", "beta", "next", "canary-1", "rc.2", "nightly_build"])("accepts the npm dist-tag %s", tag => { + expect(getReleaseArguments(environment({RELEASE_NPM_TAG: tag}))).toEqual(["--ci", `--npm.tag=${tag}`]); + }); + + test.each([ + "--script-shell=/tmp/release-probe-never-executed", + "next --no-git.push", + "next\n--no-npm.publish", + "$(printf injected)", + "next; printf injected", + "`printf injected`", + "1.2.3", + "v1.4", + "x", + ])("rejects the unsafe or version-like npm tag %s before starting release-it", tag => { + const execute = jest.fn(() => ({status: 0})); + + expect(() => runRelease(environment({RELEASE_NPM_TAG: tag}), execute)).toThrow("Invalid npm dist-tag."); + expect(execute).not.toHaveBeenCalled(); + }); + + test("the installed release-it parser keeps version and prerelease strings inside argument values", () => { + const env = environment({ + RELEASE_VERSION: "--config=/tmp/other.cjs", + RELEASE_PREID: 'rc; $(printf injected) "quoted"', + RELEASE_NPM_TAG: "beta", + }); + + const result = spawnSync( + process.execPath, + [ + "--input-type=module", + "-e", + ` + import {readFileSync} from "node:fs"; + import {parseCliArguments} from "./node_modules/release-it/lib/args.js"; + process.stdout.write(JSON.stringify(parseCliArguments(JSON.parse(readFileSync(0, "utf8"))))); + `, + ], + {cwd: root, encoding: "utf8", input: JSON.stringify(getReleaseArguments(env)), timeout: 10_000} + ); + + expect(result.status).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed).toMatchObject({ + ci: true, + preRelease: env.RELEASE_PREID, + npm: {tag: env.RELEASE_NPM_TAG}, + increment: env.RELEASE_VERSION, + }); + expect(parsed.config).toBeUndefined(); + expect(parsed.git).toBeUndefined(); + expect(parsed.npm.publish).toBeUndefined(); + }); + + test("runs the installed release-it without a shell and preserves its exit status", () => { + const env = environment({GITHUB_TOKEN: "test-token", RELEASE_VERSION: "1.2.3"}); + const execute = jest.fn(() => ({status: 7})); + + expect(runRelease(env, execute)).toBe(7); + expect(execute).toHaveBeenCalledWith( + process.execPath, + [ + path.join(root, "node_modules/release-it/bin/release-it.js"), + "--ci", + "--npm.tag=latest", + "--increment=1.2.3", + ], + {cwd: root, env, shell: false, stdio: "inherit"} + ); + }); + + test.each([ + ["a non-main run", {GITHUB_REF: "refs/heads/develop"}, "This event is not allowed to release."], + ["the release App's event", {GITHUB_ACTOR: "addon-bone-release[bot]"}, "This event is not allowed to release."], + [ + "a different token App", + {RELEASE_TOKEN_APP_SLUG: "another-app"}, + "The release token App does not match RELEASE_APP_SLUG.", + ], + [ + "a missing token App identity", + {RELEASE_TOKEN_APP_SLUG: undefined}, + "The release token App does not match RELEASE_APP_SLUG.", + ], + ])("does not start release-it for %s", (_label, overrides, error) => { + const execute = jest.fn(() => ({status: 0})); + + expect(() => runRelease(environment(overrides), execute)).toThrow(error); + expect(execute).not.toHaveBeenCalled(); + }); + + test("fails if the release process cannot start", () => { + const error = new Error("Unable to start release-it"); + + expect(() => runRelease(environment(), () => ({status: null, error}))).toThrow(error); + }); + + test("fails if the release process is terminated without an exit code", () => { + expect(runRelease(environment(), () => ({status: null}))).toBe(1); + }); +}); From d4730b2f5e7f031ab0fc9602619a3bd3aef76e4a Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:54:19 +0300 Subject: [PATCH 06/38] feat(entrypoint): expose asset maps and support content chunk loading Add per-entry asset metadata and a background-only full asset map. Respect output filename templates and isolate content chunk graphs by world. Normalize MV2 content scripts to ISOLATED with a build warning. Add Chrome and Firefox integration coverage and reorganize fixtures. Document project naming conventions, including class test filenames. --- .github/workflows/ci.yml | 13 +- .gitignore | 2 +- .prettierignore | 4 +- AGENTS.md | 37 +- package.json | 7 +- .../plugins/BuildAssetsMapPlugin.test.ts | 849 ++++++++++++++++++ .../bundler/plugins/BuildAssetsMapPlugin.ts | 814 +++++++++++++++++ .../bundler/plugins/ChunkLoaderPlugin.test.ts | 108 +++ src/cli/bundler/plugins/ChunkLoaderPlugin.ts | 85 ++ .../bundler/plugins/EntrypointPlugin.test.ts | 121 +++ src/cli/bundler/plugins/EntrypointPlugin.ts | 73 +- .../bundler/plugins/ManifestPlugin.test.ts | 69 ++ src/cli/bundler/plugins/ManifestPlugin.ts | 91 +- src/cli/bundler/plugins/index.ts | 6 + .../tests/fixtures/build-assets/alpha.css | 8 + .../fixtures/build-assets/alpha.lazy.css | 3 + .../tests/fixtures/build-assets/alpha.lazy.js | 3 + .../fixtures/build-assets/alpha.plan-a.js | 22 + .../build-assets/background-async.plan-a.js | 4 + .../build-assets/background-shared.plan-a.js | 6 + .../build-assets/background.plan-a.js | 15 + .../fixtures/build-assets/beta.changed.css | 3 + .../tests/fixtures/build-assets/beta.css | 3 + .../tests/fixtures/build-assets/beta.lazy.css | 3 + .../tests/fixtures/build-assets/beta.lazy.js | 3 + .../fixtures/build-assets/beta.plan-a.js | 20 + .../tests/fixtures/build-assets/font.woff2 | 1 + .../fixtures/build-assets/lazy-image.svg | 3 + .../tests/fixtures/build-assets/payload.js | 1 + .../fixtures/build-assets/shared.changed.js | 1 + .../tests/fixtures/build-assets/shared.js | 1 + .../tests/fixtures/chunk-loader/entry.js | 1 + .../tests/fixtures/chunk-loader/lazy.js | 1 + .../tests/fixtures/chunk-loader/unselected.js | 1 + .../fixtures/chunk-loader/unselected.lazy.js | 1 + src/cli/bundler/utils/output.test.ts | 26 + src/cli/bundler/utils/output.ts | 197 +++- src/cli/entrypoint/file/injectors/core.ts | 11 +- .../entrypoint/parser/ContentParser.test.ts | 30 + src/cli/entrypoint/parser/ContentParser.ts | 3 +- .../content/options/world/enum.content.ts | 5 + .../content/options/world/string.content.ts | 5 + src/cli/plugins/background/index.ts | 1 + .../plugins/content/ContentManager.test.ts | 122 ++- src/cli/plugins/content/ContentManager.ts | 74 +- src/cli/plugins/content/ContentName.ts | 18 +- src/cli/plugins/content/index.ts | 107 ++- src/cli/plugins/content/utils.ts | 11 +- src/cli/plugins/output.ts | 27 +- .../content/core/resolvers/watch.test.ts | 24 + src/entry/content/core/resolvers/watch.ts | 2 +- src/main/entrypoint.test.ts | 73 ++ src/main/entrypoint.ts | 37 + src/main/index.ts | 1 + src/types/content.ts | 15 +- src/types/entrypoint.ts | 16 + tests/integration/README.md | 94 ++ ...rypoint-assets.firefox.integration.test.ts | 181 ++++ .../entrypoint-assets.integration.test.ts | 236 +++++ .../content/entrypoint-assets/adnbn.config.ts | 12 + .../content/entrypoint-assets/package.json | 9 + .../content/entrypoint-assets/site/child.html | 10 + .../entrypoint-assets/site/frames.html | 11 + .../content/entrypoint-assets/site/top.html | 10 + .../entrypoint-assets/src/background.ts | 21 + .../src/isolated-secondary.content.ts | 12 + .../entrypoint-assets/src/isolated.content.ts | 19 + .../src/main-secondary.content.ts | 12 + .../entrypoint-assets/src/main.content.ts | 19 + .../src/probe/async/index.ts | 9 + .../src/probe/async/probe.svg | 3 + .../src/probe/async/styles.module.css | 4 + .../entrypoint-assets/src/probe/index.ts | 61 ++ .../src/probe/styles.module.css | 3 + .../entrypoint-assets}/tsconfig.json | 0 tests/integration/browser/content/utils.ts | 106 +++ .../browser/offscreen-service/package.json | 6 - .../service.integration.test.ts} | 34 +- .../service}/adnbn.config.ts | 0 .../browser/offscreen/service/package.json | 9 + .../service/src/background.ts} | 0 .../service/src/offscreen.ts} | 0 .../service/src/service.ts} | 0 .../service}/tsconfig.json | 0 tests/integration/browser/options/.gitignore | 1 - tests/integration/browser/options/README.md | 33 +- .../browser/options/embedded/package.json | 5 - .../{ => options}/options.integration.test.ts | 79 +- .../browser/options/react/package.json | 11 +- .../src/{options.tsx => options/index.tsx} | 0 .../react/src/{ => options}/styles.css | 0 .../browser/options/vanilla/package.json | 8 +- .../src/{options.ts => options/index.ts} | 0 .../vanilla/src/{ => options}/styles.css | 0 tests/integration/browser/utils/BidiClient.ts | 143 +++ tests/integration/browser/utils/browser.ts | 88 ++ tests/integration/browser/utils/chrome.ts | 122 +-- tests/integration/browser/utils/firefox.ts | 22 + tests/integration/browser/utils/site.ts | 52 ++ .../options/embedded/adnbn.config.ts | 0 .../build/options/embedded/package.json | 9 + .../options/embedded/src/options.ts | 0 .../build/options/embedded/tsconfig.json | 3 + .../build/options/options.integration.test.ts | 29 + tests/integration/prepare.ts | 12 + tests/integration/typecheck.ts | 17 + tests/integration/utils/fixture.ts | 117 +++ tests/integration/utils/process.ts | 34 + tsconfig.tests.json | 1 + 109 files changed, 4428 insertions(+), 427 deletions(-) create mode 100644 src/cli/bundler/plugins/BuildAssetsMapPlugin.test.ts create mode 100644 src/cli/bundler/plugins/BuildAssetsMapPlugin.ts create mode 100644 src/cli/bundler/plugins/ChunkLoaderPlugin.test.ts create mode 100644 src/cli/bundler/plugins/ChunkLoaderPlugin.ts create mode 100644 src/cli/bundler/plugins/EntrypointPlugin.test.ts create mode 100644 src/cli/bundler/plugins/ManifestPlugin.test.ts create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.css create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.lazy.css create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.lazy.js create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.plan-a.js create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/background-async.plan-a.js create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/background-shared.plan-a.js create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/background.plan-a.js create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/beta.changed.css create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/beta.css create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/beta.lazy.css create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/beta.lazy.js create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/beta.plan-a.js create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/font.woff2 create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/lazy-image.svg create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/payload.js create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/shared.changed.js create mode 100644 src/cli/bundler/plugins/tests/fixtures/build-assets/shared.js create mode 100644 src/cli/bundler/plugins/tests/fixtures/chunk-loader/entry.js create mode 100644 src/cli/bundler/plugins/tests/fixtures/chunk-loader/lazy.js create mode 100644 src/cli/bundler/plugins/tests/fixtures/chunk-loader/unselected.js create mode 100644 src/cli/bundler/plugins/tests/fixtures/chunk-loader/unselected.lazy.js create mode 100644 src/cli/bundler/utils/output.test.ts create mode 100644 src/cli/entrypoint/parser/ContentParser.test.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/world/enum.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/world/string.content.ts create mode 100644 src/entry/content/core/resolvers/watch.test.ts create mode 100644 src/main/entrypoint.test.ts create mode 100644 src/main/entrypoint.ts create mode 100644 tests/integration/README.md create mode 100644 tests/integration/browser/content/entrypoint-assets.firefox.integration.test.ts create mode 100644 tests/integration/browser/content/entrypoint-assets.integration.test.ts create mode 100644 tests/integration/browser/content/entrypoint-assets/adnbn.config.ts create mode 100644 tests/integration/browser/content/entrypoint-assets/package.json create mode 100644 tests/integration/browser/content/entrypoint-assets/site/child.html create mode 100644 tests/integration/browser/content/entrypoint-assets/site/frames.html create mode 100644 tests/integration/browser/content/entrypoint-assets/site/top.html create mode 100644 tests/integration/browser/content/entrypoint-assets/src/background.ts create mode 100644 tests/integration/browser/content/entrypoint-assets/src/isolated-secondary.content.ts create mode 100644 tests/integration/browser/content/entrypoint-assets/src/isolated.content.ts create mode 100644 tests/integration/browser/content/entrypoint-assets/src/main-secondary.content.ts create mode 100644 tests/integration/browser/content/entrypoint-assets/src/main.content.ts create mode 100644 tests/integration/browser/content/entrypoint-assets/src/probe/async/index.ts create mode 100644 tests/integration/browser/content/entrypoint-assets/src/probe/async/probe.svg create mode 100644 tests/integration/browser/content/entrypoint-assets/src/probe/async/styles.module.css create mode 100644 tests/integration/browser/content/entrypoint-assets/src/probe/index.ts create mode 100644 tests/integration/browser/content/entrypoint-assets/src/probe/styles.module.css rename tests/integration/browser/{offscreen-service => content/entrypoint-assets}/tsconfig.json (100%) create mode 100644 tests/integration/browser/content/utils.ts delete mode 100644 tests/integration/browser/offscreen-service/package.json rename tests/integration/browser/{offscreen-service.integration.test.ts => offscreen/service.integration.test.ts} (81%) rename tests/integration/browser/{offscreen-service => offscreen/service}/adnbn.config.ts (100%) create mode 100644 tests/integration/browser/offscreen/service/package.json rename tests/integration/browser/{offscreen-service/src/background/index.ts => offscreen/service/src/background.ts} (100%) rename tests/integration/browser/{offscreen-service/src/offscreen/index.ts => offscreen/service/src/offscreen.ts} (100%) rename tests/integration/browser/{offscreen-service/src/services/offscreen-test.service.ts => offscreen/service/src/service.ts} (100%) rename tests/integration/browser/{options/embedded => offscreen/service}/tsconfig.json (100%) delete mode 100644 tests/integration/browser/options/.gitignore delete mode 100644 tests/integration/browser/options/embedded/package.json rename tests/integration/browser/{ => options}/options.integration.test.ts (69%) rename tests/integration/browser/options/react/src/{options.tsx => options/index.tsx} (100%) rename tests/integration/browser/options/react/src/{ => options}/styles.css (100%) rename tests/integration/browser/options/vanilla/src/{options.ts => options/index.ts} (100%) rename tests/integration/browser/options/vanilla/src/{ => options}/styles.css (100%) create mode 100644 tests/integration/browser/utils/BidiClient.ts create mode 100644 tests/integration/browser/utils/browser.ts create mode 100644 tests/integration/browser/utils/firefox.ts create mode 100644 tests/integration/browser/utils/site.ts rename tests/integration/{browser => build}/options/embedded/adnbn.config.ts (100%) create mode 100644 tests/integration/build/options/embedded/package.json rename tests/integration/{browser => build}/options/embedded/src/options.ts (100%) create mode 100644 tests/integration/build/options/embedded/tsconfig.json create mode 100644 tests/integration/build/options/options.integration.test.ts create mode 100644 tests/integration/prepare.ts create mode 100644 tests/integration/typecheck.ts create mode 100644 tests/integration/utils/fixture.ts create mode 100644 tests/integration/utils/process.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f32a31cc..329f0609 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,16 +65,27 @@ jobs: - name: Install dependencies run: npm ci + - name: Set up Firefox + if: matrix.os == 'ubuntu-latest' + id: firefox + uses: browser-actions/setup-firefox@v1 + with: + firefox-version: latest + - name: Typecheck run: npm run typecheck + - name: Typecheck integration fixtures + run: npm run typecheck:integration + - name: Test if: matrix.os == 'ubuntu-latest' run: npm run test:ci env: ADNBN_CHROME_BIN: ${{ steps.chrome.outputs.chrome-path }} + ADNBN_FIREFOX_BIN: ${{ steps.firefox.outputs.firefox-path }} - # The CDP extension-loading scenario is verified on Chrome's Linux runner. + # Chrome CDP and Firefox BiDi extension scenarios run on the Linux runner. # The rest of the test suite remains part of the full OS x Node matrix. - name: Test (non-browser) if: matrix.os != 'ubuntu-latest' diff --git a/.gitignore b/.gitignore index fa7fb772..3f0b9e58 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,6 @@ TODOs.md stats.html .tool-versions .cache -/tests/integration/browser/offscreen-service/.adnbn/ +/tests/integration/**/.adnbn/ *-stats.txt .npmrc diff --git a/.prettierignore b/.prettierignore index a6dde738..fb92500f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,7 +1,7 @@ node_modules/ .github/workflows/ dist/ -build/ +/build/ addon/ src/cli/entrypoint/file/tests/fixtures/ -CHANGELOG.md \ No newline at end of file +CHANGELOG.md diff --git a/AGENTS.md b/AGENTS.md index 9d9e84c3..9895869d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,38 @@ # Repository conventions -## TypeScript file naming +## Symbol naming -- Use PascalCase filenames only when the file's primary export is a class, for example `LocaleFinder.ts`. -- Use camelCase filenames for modules that export helper functions, utilities, constants, enums, or types without a primary class, for example `utils/filePrecedence.ts`. +- Apply these conventions throughout the project: shared types, runtime APIs, entrypoints, CLI, bundler plugins, and tests. +- Before introducing or renaming a symbol, inspect related declarations and reuse their domain vocabulary and capitalization, for example `Entrypoint`, not `EntryPoint`. +- Keep third-party contracts and platform API names unchanged; these rules apply to symbols owned by the project. +- Use PascalCase for classes, interfaces, type aliases, enums, and enum members. Use camelCase for functions, methods, parameters, and local variables. +- Name related exported types using **domain + concept + role or variant**. Keep the shared family prefix intact and append the distinguishing part: `ContentScriptContainerOptions`, `ContentScriptContainerFactory`, `ViewRenderHandler`, `EntrypointAssetsMapEntry`. +- Give each family a clear base name for its main contract. Name related shapes consistently, for example `EntrypointAssets`, `EntrypointAssetsFiles`, `EntrypointAssetsMap`, and `EntrypointAssetsMapEntry`. +- Suffixes must describe the actual role or shape: `Map` for a keyed collection, `MapEntry` for its value, `Options` for configuration, and `Handler` for a callable handler. Do not use the same name for different data shapes. +- Name classes after their responsibility and role, such as `LocaleFinder` or `ContentParser`. Avoid names tied only to a temporary implementation detail or former file location. +- Keep private helpers and local variables concise when their surrounding scope already supplies the domain. Do not mechanically repeat a long public prefix everywhere. + +## Public API naming + +- Use **verb + domain concept** for exported functions. Prefer explicit names over ambiguous shortcuts, and use the same terminology as the associated types. +- For getters returning a named domain contract, align the function and result names: `getX(): X`. Preserve required wrappers such as `Promise`. A getter returning a collection should also express that collection in its name. +- For example, use `getEntrypointAssets(): EntrypointAssets` and `getEntrypointAssetsMap(): EntrypointAssetsMap`. Avoid an unrelated pair such as `getBuildAssets(): EntrypointAssetsMap`. +- Add qualifiers such as `Current`, `Runtime`, or `Build` only when they identify a meaningful distinction. Do not add them merely to resolve a naming collision; first clarify the base contract and its related shapes. +- Keep access restrictions and behavior explicit in the API documentation and errors. A naming cleanup must not change the returned data, execution context, or loading behavior. + +## Renaming existing symbols + +- Update declarations, imports, re-exports, callers, tests, fixtures, and examples together, including the root `addon` playground and relevant documentation. +- Treat exported renames as public API changes. Do not silently preserve old aliases or remove compatibility that has been explicitly required. +- Keep renames focused. Do not change behavior or sweep unrelated existing names into the same change. Check for stale references and run validation appropriate to the affected code. + +## File and directory naming + +- Use PascalCase for a file whose primary export is a class or React component, matching that export's name: `LocaleFinder.ts`, `BuildAssetsMapPlugin.ts`, and `DiagnosticPanel.tsx`. Prefer a named class/component export so the relationship is explicit. +- Name tests for a specific class after that class, preserving PascalCase and adding the test suffix: `BuildAssetsMapPlugin.test.ts`, `ChunkLoaderPlugin.test.ts`, and `ContentManager.test.ts`. +- Use lowercase kebab-case for other multiword filenames, including helper modules, types, enums, configuration modules, and tests that are not dedicated to a class: `file-precedence.ts`, `entrypoint-assets.ts`, and `entrypoint-assets.integration.test.ts`. +- Use lowercase kebab-case for multiword directory names, including directories containing classes or React components: `entrypoint-assets` and `diagnostic-panel`. +- Preserve tool-required conventional names such as `AGENTS.md` and `README.md`, and recognized suffixes such as `.content.ts`, `.service.ts`, `.module.css`, and `.config.ts`. This rule does not change TypeScript symbol casing. +- Apply the rule to new and renamed paths. Do not mass-rename unrelated existing files as part of another task. +- Keep an entrypoint or component with its own styles and resources in one directory. A standalone entrypoint without related files can remain a single file. - Keep finder-specific helper modules in `src/cli/entrypoint/finder/utils`. diff --git a/package.json b/package.json index acb1ad56..908e196a 100644 --- a/package.json +++ b/package.json @@ -109,13 +109,18 @@ "format": "prettier --write .", "typecheck": "tsc -p tsconfig.json --noEmit && npm run typecheck:tests", "typecheck:tests": "tsc -p tsconfig.tests.json --noEmit", + "prepare:integration": "npm run build && tsx tests/integration/prepare.ts", + "typecheck:integration": "npm run prepare:integration && tsx tests/integration/typecheck.ts", + "test:integration": "npm run test -- --testPathPatterns=tests/integration --runInBand", + "test:integration:build": "npm run test -- --testPathPatterns=tests/integration/build --runInBand", "check:node-version": "node ./scripts/check-node-version.js", "test": "npm run build && jest", "test:ci": "npm run test -- --ci --passWithNoTests --coverage", "test:ci:non-browser": "npm run test -- --ci --passWithNoTests --coverage --testPathIgnorePatterns=tests/integration/browser", "test:watch": "jest --watch --testPathIgnorePatterns=tests/integration/browser", "test:offscreen": "jest --testPathPatterns=Offscreen", - "test:chrome": "npm run test -- --testPathPatterns=tests/integration/browser --runInBand", + "test:chrome": "npm run test -- --testPathPatterns=tests/integration/browser --testPathIgnorePatterns=firefox.integration.test --runInBand", + "test:firefox": "npm run test -- --testPathPatterns=firefox.integration.test --runInBand", "test:message": "jest --testPathPatterns=src/message", "test:service": "jest --testPathPatterns=Service", "test:relay": "jest --testPathPatterns=Relay", diff --git a/src/cli/bundler/plugins/BuildAssetsMapPlugin.test.ts b/src/cli/bundler/plugins/BuildAssetsMapPlugin.test.ts new file mode 100644 index 00000000..cf661bf0 --- /dev/null +++ b/src/cli/bundler/plugins/BuildAssetsMapPlugin.test.ts @@ -0,0 +1,849 @@ +/** @jest-environment node */ + +import crypto from "crypto"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import vm from "vm"; + +import { + Compilation, + Compiler, + CssExtractRspackPlugin, + HtmlRspackPlugin, + NormalModuleReplacementPlugin, + RuntimeModule, + Stats, + rspack, + sources, +} from "@rspack/core"; +import type {Filename} from "@rspack/core"; + +import {appFilenameResolver, getCompilationBuildAssets} from "@cli/bundler/utils/output"; + +import type {EntrypointAssetsMap, EntrypointAssetsMapEntry, EntrypointAssets} from "@typing/entrypoint"; + +import BuildAssetsMapPlugin from "./BuildAssetsMapPlugin"; + +const AppName = "Build Assets Fixture"; +const AppToken = "build-assets-fixture"; +const UnrelatedFile = "public/unrelated.txt"; +const FullMapPlaceholder = "__ADNBN_BUILD_ASSETS_FULL_MAP_PLACEHOLDER__"; +const ChunkLoadingGlobal = "buildAssetsFixtureChunks"; + +const fixtures = path.resolve(__dirname, "tests", "fixtures", "build-assets"); +const projectRoot = path.resolve(__dirname, "../../../.."); + +interface BuildResult { + readonly assets: EntrypointAssetsMap; + readonly hashes: Readonly>; + readonly sources: Readonly>; +} + +interface FakeLoadEvent { + readonly target: FakeElement; + readonly type: "error" | "load"; +} + +interface FakeElement { + href?: string; + onerror?: (event: FakeLoadEvent) => void; + onload?: (event: FakeLoadEvent) => void; + parentNode?: FakeHead; + rel?: string; + src?: string; + readonly tagName: string; + getAttribute(name: string): string | null; + setAttribute(name: string, value: string): void; +} + +interface FakeHead { + appendChild(element: FakeElement): FakeElement; + removeChild(element: FakeElement): FakeElement; +} + +interface RuntimeResult { + readonly requested: {css: string[]; js: string[]}; + readonly sandbox: Record; +} + +type GetterTrace = {readonly ok: true; readonly value: T} | {readonly error: string; readonly ok: false}; + +interface BuildAssetsGetterTrace { + readonly current: GetterTrace; + readonly full: GetterTrace; +} + +class EmitUnrelatedAssetPlugin { + public apply(compiler: Compiler): void { + compiler.hooks.thisCompilation.tap("EmitUnrelatedAssetPlugin", compilation => { + compilation.hooks.processAssets.tap( + { + name: "EmitUnrelatedAssetPlugin", + stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + }, + () => compilation.emitAsset(UnrelatedFile, new sources.RawSource("not reachable from an entrypoint")) + ); + }); + } +} + +class BetaRuntimeModule extends RuntimeModule { + public constructor(private readonly marker: string) { + super("beta fixture marker", RuntimeModule.STAGE_NORMAL); + } + + public generate(): string { + return `globalThis.__betaRuntimeMarker = ${JSON.stringify(this.marker)};`; + } +} + +class AddBetaRuntimeModulePlugin { + public constructor(private readonly marker: string) {} + + public apply(compiler: Compiler): void { + compiler.hooks.thisCompilation.tap("AddBetaRuntimeModulePlugin", compilation => { + compilation.hooks.additionalTreeRuntimeRequirements.tap("AddBetaRuntimeModulePlugin", chunk => { + if (chunk.name === "beta") { + compilation.addRuntimeModule(chunk, new BetaRuntimeModule(this.marker)); + } + }); + }); + } +} + +class RenameBetaCssPlugin { + public apply(compiler: Compiler): void { + compiler.hooks.thisCompilation.tap("RenameBetaCssPlugin", compilation => { + compilation.hooks.processAssets.tap( + { + name: "RenameBetaCssPlugin", + stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + }, + () => { + const css = compilation.entrypoints + .get("beta") + ?.getFiles() + .find(file => file.endsWith(".css")); + + if (css) { + compilation.renameAsset(css, "custom/css/beta-renamed.css"); + } + } + ); + }); + } +} + +class RemoveBetaCssPlugin { + public apply(compiler: Compiler): void { + compiler.hooks.thisCompilation.tap("RemoveBetaCssPlugin", compilation => { + compilation.hooks.processAssets.tap( + { + name: "RemoveBetaCssPlugin", + stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE, + }, + () => { + const css = compilation.entrypoints + .get("beta") + ?.getFiles() + .find(file => file.endsWith(".css")); + + if (css) { + compilation.deleteAsset(css); + } + } + ); + }); + } +} + +class RenameBetaCssAfterProcessAssetsPlugin { + public apply(compiler: Compiler): void { + compiler.hooks.thisCompilation.tap("RenameBetaCssAfterProcessAssetsPlugin", compilation => { + compilation.hooks.afterProcessAssets.tap("RenameBetaCssAfterProcessAssetsPlugin", () => { + const css = compilation.entrypoints + .get("beta") + ?.getFiles() + .find(file => file.endsWith(".css")); + + if (css) { + compilation.renameAsset(css, "custom/css/beta-after-process.css"); + } + }); + }); + } +} + +class RenameBetaCssAtEmitPlugin { + public apply(compiler: Compiler): void { + compiler.hooks.emit.tap("RenameBetaCssAtEmitPlugin", compilation => { + const css = compilation.entrypoints + .get("beta") + ?.getFiles() + .find(file => file.endsWith(".css")); + + if (css) { + compilation.renameAsset(css, "custom/css/beta-at-emit.css"); + } + }); + } +} + +const closeCompiler = (compiler: Compiler): Promise => { + return new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); +}; + +const runCompiler = (compiler: Compiler): Promise => { + return new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) { + reject(error); + } else if (!stats) { + reject(new Error("Rspack did not return build stats")); + } else if (stats.hasErrors()) { + reject(new Error(stats.toString({all: false, errors: true, errorDetails: true}))); + } else { + resolve(stats); + } + }); + }); +}; + +const compile = async ( + commonChunks: boolean, + options: { + assetInfoFilename?: boolean; + assertEmptyOutputOnFailure?: boolean; + background?: "async" | "shared"; + betaDirectory?: string; + betaRuntimeMarker?: string; + betaCssChange?: boolean; + buildHashSalt?: string; + cssDir?: string; + cssFullHashCallback?: boolean; + fullHashCallback?: boolean; + htmlChunks?: readonly string[]; + includeBackground?: boolean; + jsHash?: "chunkhash" | "contenthash" | "fullhash"; + removeBetaCss?: boolean; + renameBetaCss?: boolean; + renameBetaCssAfterProcessAssets?: boolean; + renameBetaCssAtEmit?: boolean; + resolvedHashFilename?: boolean; + separateRuntime?: boolean; + sharedChange?: boolean; + } = {} +): Promise => { + const outputPath = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-build-assets-")); + const jsHash = options.jsHash ?? "chunkhash"; + const jsTemplate = `[name].[app].[${jsHash}:8].js`; + const jsFilenameConfig: Filename = options.resolvedHashFilename + ? pathData => `[name].[app].${pathData.hash?.slice(0, 8) ?? "missing-hash"}.js` + : options.assetInfoFilename + ? (pathData, assetInfo) => { + const initial = ["alpha", "background", "beta"].includes(String(pathData.chunk?.name)); + const directory = initial ? `${String(assetInfo?.assetType ?? "missing-asset-info")}/` : ""; + + return `${directory}${jsTemplate}`; + } + : options.betaDirectory + ? pathData => { + const directory = pathData.chunk?.name === "beta" ? `${options.betaDirectory}/` : ""; + + return `${directory}${jsTemplate}`; + } + : jsHash === "fullhash" && !options.fullHashCallback + ? jsTemplate + : () => jsTemplate; + const jsFilename = appFilenameResolver(AppName, jsFilenameConfig, "custom/js"); + const cssTemplate = options.cssFullHashCallback + ? "[name].[app].[fullhash:8].css" + : "[name].[app].[contenthash:8].css"; + const cssFilename = appFilenameResolver(AppName, () => cssTemplate, options.cssDir ?? "custom/css"); + const assetFilename = appFilenameResolver(AppName, () => "[name].[app].[contenthash:8][ext]", "custom/assets"); + const background = + options.background === "async" + ? "./background-async.plan-a.js" + : options.background === "shared" + ? "./background-shared.plan-a.js" + : "./background.plan-a.js"; + const compiler = rspack({ + context: fixtures, + mode: "production", + target: ["web", "es2020"], + devtool: "source-map", + entry: { + ...(options.includeBackground === false ? {} : {background}), + alpha: "./alpha.plan-a.js", + beta: "./beta.plan-a.js", + }, + output: { + path: outputPath, + clean: true, + publicPath: "", + globalObject: "globalThis", + uniqueName: AppToken, + chunkLoadingGlobal: ChunkLoadingGlobal, + filename: jsFilename, + chunkFilename: jsFilename, + assetModuleFilename: assetFilename, + }, + resolve: { + alias: { + adnbn$: path.resolve(projectRoot, "dist", "index.js"), + }, + extensions: [".js"], + }, + resolveLoader: { + modules: [path.resolve(projectRoot, "node_modules"), "node_modules"], + }, + module: { + rules: [ + { + resourceQuery: /resource/, + type: "asset/resource", + }, + { + test: /\.(?:svg|woff2)$/i, + type: "asset/resource", + }, + { + test: /\.css$/i, + sideEffects: true, + type: "javascript/auto", + use: [ + CssExtractRspackPlugin.loader, + { + loader: "css-loader", + options: {esModule: true, modules: false}, + }, + ], + }, + ], + }, + optimization: { + chunkIds: "deterministic", + moduleIds: "deterministic", + minimize: true, + realContentHash: true, + runtimeChunk: options.separateRuntime ? "multiple" : false, + splitChunks: commonChunks + ? { + chunks: "all", + minSize: 0, + cacheGroups: { + default: false, + defaultVendors: false, + fixtureShared: { + test: /[\\/]shared(?:\.changed)?\.js$/, + name: "common", + minChunks: 2, + enforce: true, + reuseExistingChunk: true, + }, + }, + } + : false, + }, + plugins: [ + new CssExtractRspackPlugin({filename: cssFilename, chunkFilename: cssFilename}), + ...(options.betaCssChange + ? [ + new NormalModuleReplacementPlugin(/beta\.css$/, resource => { + resource.request = path.resolve(fixtures, "beta.changed.css"); + }), + ] + : []), + ...(options.sharedChange + ? [ + new NormalModuleReplacementPlugin(/shared\.js$/, resource => { + resource.request = path.resolve(fixtures, "shared.changed.js"); + }), + ] + : []), + ...(options.htmlChunks + ? [new HtmlRspackPlugin({chunks: [...options.htmlChunks], filename: "fixture.html", hash: true})] + : []), + ...(options.renameBetaCss ? [new RenameBetaCssPlugin()] : []), + ...(options.removeBetaCss ? [new RemoveBetaCssPlugin()] : []), + ...(options.betaRuntimeMarker ? [new AddBetaRuntimeModulePlugin(options.betaRuntimeMarker)] : []), + new BuildAssetsMapPlugin({ + buildHashSalt: options.buildHashSalt, + cssChunkFilename: cssFilename, + cssFilename, + fullMapEntrypoint: "background", + }), + ...(options.renameBetaCssAfterProcessAssets ? [new RenameBetaCssAfterProcessAssetsPlugin()] : []), + ...(options.renameBetaCssAtEmit ? [new RenameBetaCssAtEmitPlugin()] : []), + new EmitUnrelatedAssetPlugin(), + ], + }); + + try { + const stats = await runCompiler(compiler); + const assets = getCompilationBuildAssets(stats.compilation); + + if (!assets) { + throw new Error("BuildAssetsMapPlugin did not expose the finalized map"); + } + + const emitted = stats.compilation.getAssets(); + const outputSources = Object.fromEntries(emitted.map(asset => [asset.name, asset.source.source().toString()])); + const hashes = Object.fromEntries( + emitted + .filter(asset => !asset.name.endsWith(".map")) + .map(asset => [asset.name, crypto.createHash("sha256").update(asset.source.buffer()).digest("hex")]) + ); + + return {assets, hashes, sources: outputSources}; + } catch (error) { + if ( + options.assertEmptyOutputOnFailure && + fs.existsSync(outputPath) && + fs.readdirSync(outputPath, {recursive: true}).length > 0 + ) { + throw new Error("Rspack wrote output before build-assets validation failed", {cause: error}); + } + + throw error; + } finally { + await closeCompiler(compiler); + fs.rmSync(outputPath, {force: true, recursive: true}); + } +}; + +const flattenEntrypoint = (entrypoint: EntrypointAssetsMapEntry): readonly string[] => { + return [ + ...entrypoint.initial.js, + ...entrypoint.initial.css, + ...entrypoint.async.js, + ...entrypoint.async.css, + ...entrypoint.assets, + ]; +}; + +const runtimeAssets = (entrypoint: EntrypointAssetsMapEntry): EntrypointAssets => { + return {initial: entrypoint.initial, async: entrypoint.async}; +}; + +const findNamedFile = (files: readonly string[], name: string, extension: "css" | "js"): string => { + const expression = new RegExp(`^custom/${extension}/${name}\\.${AppToken}\\.[a-f0-9]{8}\\.${extension}$`); + const file = files.find(candidate => expression.test(candidate)); + + if (!file) { + throw new Error(`Could not find ${name}.${extension} in ${JSON.stringify(files)}`); + } + + return file; +}; + +const normalizeUrl = (url: string): string => { + return url + .replace(/[?#].*$/, "") + .replace(/^[a-z]+:\/\/[^/]+\//i, "") + .replace(/^\/+/, ""); +}; + +const executeEntrypoint = (build: BuildResult, name: keyof EntrypointAssetsMap): RuntimeResult => { + const requested = {css: [] as string[], js: [] as string[]}; + const scripts: FakeElement[] = []; + const links: FakeElement[] = []; + let context: vm.Context; + + const remove = (collection: FakeElement[], element: FakeElement): void => { + const index = collection.indexOf(element); + + if (index >= 0) { + collection.splice(index, 1); + } + }; + let document: { + createElement(tagName: string): FakeElement; + getElementsByTagName(tagName: string): readonly (FakeElement | FakeHead)[]; + head: FakeHead; + }; + const head: FakeHead = { + appendChild: element => { + element.parentNode = head; + + if (element.tagName === "SCRIPT") { + const file = normalizeUrl(element.src ?? element.getAttribute("src") ?? ""); + const source = build.sources[file]; + + requested.js.push(file); + scripts.push(element); + + if (source) { + new vm.Script(source, {filename: file}).runInContext(context); + element.onload?.({target: element, type: "load"}); + } else { + element.onerror?.({target: element, type: "error"}); + } + } else if (element.tagName === "LINK") { + const file = normalizeUrl(element.href ?? element.getAttribute("href") ?? ""); + + requested.css.push(file); + links.push(element); + + if (build.sources[file]) { + element.onload?.({target: element, type: "load"}); + } else { + element.onerror?.({target: element, type: "error"}); + } + } + + return element; + }, + removeChild: element => { + remove(scripts, element); + remove(links, element); + + return element; + }, + }; + const createElement = (tagName: string): FakeElement => { + const attributes: Record = {}; + const element = { + tagName: tagName.toUpperCase(), + getAttribute: (attribute: string) => { + const value = (element as FakeElement & Record)[attribute]; + + return typeof value === "string" ? value : (attributes[attribute] ?? null); + }, + setAttribute: (attribute: string, value: string) => { + attributes[attribute] = value; + (element as FakeElement & Record)[attribute] = value; + }, + } as FakeElement; + + return element; + }; + + document = { + createElement, + head, + getElementsByTagName: tagName => { + if (tagName.toLowerCase() === "script") return scripts; + if (tagName.toLowerCase() === "link") return links; + if (tagName.toLowerCase() === "head") return [head]; + + return []; + }, + }; + + const sandbox: Record = {clearTimeout, console, document, setTimeout}; + + sandbox.globalThis = sandbox; + sandbox.self = sandbox; + context = vm.createContext(sandbox); + + for (const file of build.assets[name].initial.js) { + new vm.Script(build.sources[file], {filename: file}).runInContext(context); + } + + return {requested, sandbox}; +}; + +const expectCompleteMap = (build: BuildResult, commonChunks: boolean): void => { + const {assets, sources: emittedSources} = build; + + expect(Object.keys(assets)).toEqual(["alpha", "background", "beta"]); + + const background = findNamedFile(assets.background.initial.js, "background", "js"); + const alpha = findNamedFile(assets.alpha.initial.js, "alpha", "js"); + const beta = findNamedFile(assets.beta.initial.js, "beta", "js"); + + expect(assets.background.initial.js).toEqual([background]); + expect(assets.background.async.js).toEqual([]); + expect(assets.alpha.initial.js).toContain(alpha); + expect(assets.beta.initial.js).toContain(beta); + expect(assets.alpha.initial.js).not.toContain(background); + expect(assets.beta.initial.js).not.toContain(background); + + if (commonChunks) { + const common = findNamedFile(assets.alpha.initial.js, "common", "js"); + + expect(assets.beta.initial.js).toContain(common); + } else { + expect([...assets.alpha.initial.js, ...assets.beta.initial.js]).not.toEqual( + expect.arrayContaining([expect.stringContaining("/common.")]) + ); + } + + expect(assets.alpha.initial.css).toEqual([ + expect.stringMatching(/^custom\/css\/alpha\.build-assets-fixture\.[a-f0-9]{8}\.css$/), + ]); + expect(assets.beta.initial.css).toEqual([ + expect.stringMatching(/^custom\/css\/beta\.build-assets-fixture\.[a-f0-9]{8}\.css$/), + ]); + expect(assets.alpha.async.js).toEqual([ + expect.stringMatching(/^custom\/js\/alpha-lazy\.build-assets-fixture\.[a-f0-9]{8}\.js$/), + ]); + expect(assets.beta.async.js).toEqual([ + expect.stringMatching(/^custom\/js\/beta-lazy\.build-assets-fixture\.[a-f0-9]{8}\.js$/), + ]); + expect(assets.alpha.async.css).toEqual([ + expect.stringMatching(/^custom\/css\/alpha-lazy\.build-assets-fixture\.[a-f0-9]{8}\.css$/), + ]); + expect(assets.beta.async.css).toEqual([ + expect.stringMatching(/^custom\/css\/beta-lazy\.build-assets-fixture\.[a-f0-9]{8}\.css$/), + ]); + expect(assets.alpha.assets).toEqual([ + expect.stringMatching(/^custom\/assets\/font\.build-assets-fixture\.[a-f0-9]{8}\.woff2$/), + expect.stringMatching(/^custom\/assets\/lazy-image\.build-assets-fixture\.[a-f0-9]{8}\.svg$/), + expect.stringMatching(/^custom\/assets\/payload\.build-assets-fixture\.[a-f0-9]{8}\.js$/), + ]); + expect(assets.background.assets).toEqual([]); + expect(assets.beta.assets).toEqual([]); + + const codeFiles = Object.values(assets).flatMap(entrypoint => [...entrypoint.initial.js, ...entrypoint.async.js]); + const payload = assets.alpha.assets.find(file => file.endsWith(".js")); + + expect(payload).toBeDefined(); + expect(codeFiles).not.toContain(payload); + + for (const entrypoint of Object.values(assets)) { + const initial = new Set([...entrypoint.initial.js, ...entrypoint.initial.css]); + const async = [...entrypoint.async.js, ...entrypoint.async.css]; + + expect(async.filter(file => initial.has(file))).toEqual([]); + expect(flattenEntrypoint(entrypoint).filter(file => file.endsWith(".map"))).toEqual([]); + } + + const mappedFiles = new Set(Object.values(assets).flatMap(flattenEntrypoint)); + const emittedFiles = Object.keys(emittedSources).filter(file => !file.endsWith(".map")); + + expect(emittedFiles.toSorted()).toEqual([...mappedFiles, UnrelatedFile].toSorted()); + expect(emittedFiles.filter(file => file.endsWith(".json"))).toEqual([]); + expect(mappedFiles.has(UnrelatedFile)).toBe(false); + const emittedCode = Object.entries(emittedSources) + .filter(([file]) => !file.endsWith(".map")) + .map(([, source]) => source); + + expect(emittedCode.every(source => !source.includes(FullMapPlaceholder))).toBe(true); + expect(emittedCode.every(source => !source.includes("scheduleEntrypoint"))).toBe(true); + expect(emittedCode.every(source => !source.includes("__adnbnScheduleEntrypoint"))).toBe(true); +}; + +const expectNoChangedBytesUnderStableNames = (before: BuildResult, after: BuildResult): void => { + for (const file of Object.keys(before.hashes)) { + if (Object.hasOwn(after.hashes, file)) { + expect(after.hashes[file]).toBe(before.hashes[file]); + } + } +}; + +jest.setTimeout(120_000); + +describe("BuildAssetsMapPlugin Plan A", () => { + test.each(["chunkhash", "contenthash", "fullhash"] as const)( + "exposes isolated runtime maps with %s filenames", + async jsHash => { + const build = await compile(true, {jsHash}); + + expectCompleteMap(build, true); + + const background = executeEntrypoint(build, "background").sandbox; + const alpha = executeEntrypoint(build, "alpha").sandbox; + const beta = executeEntrypoint(build, "beta").sandbox; + + const backgroundTrace = background.__buildAssetsGetterTrace as BuildAssetsGetterTrace; + const alphaTrace = alpha.__buildAssetsGetterTrace as BuildAssetsGetterTrace; + const betaTrace = beta.__buildAssetsGetterTrace as BuildAssetsGetterTrace; + + expect(background.__backgroundBuildAssets).toEqual(build.assets); + expect(alpha.__alphaEntrypointAssets).toEqual(runtimeAssets(build.assets.alpha)); + expect(beta.__betaEntrypointAssets).toEqual(runtimeAssets(build.assets.beta)); + expect(backgroundTrace).toEqual({ + current: {error: "Current entrypoint assets are unavailable in this runtime", ok: false}, + full: {ok: true, value: build.assets}, + }); + expect(alphaTrace).toEqual({ + current: {ok: true, value: runtimeAssets(build.assets.alpha)}, + full: {error: "getEntrypointAssetsMap() is available only in the background entrypoint", ok: false}, + }); + expect(betaTrace).toEqual({ + current: {ok: true, value: runtimeAssets(build.assets.beta)}, + full: {error: "getEntrypointAssetsMap() is available only in the background entrypoint", ok: false}, + }); + expect(alpha.__buildAssetsPayloadExecuted).toBeUndefined(); + } + ); + + test("preserves lazy JS and CSS loading inside a local runtime", async () => { + const build = await compile(true); + const {requested, sandbox} = executeEntrypoint(build, "alpha"); + const load = sandbox.loadAlphaFixture; + + expect(typeof load).toBe("function"); + + const module = await (load as () => Promise<{alphaLazy: boolean}>)(); + + expect(module.alphaLazy).toBe(true); + expect(requested.js).toEqual(build.assets.alpha.async.js); + expect(requested.css).toEqual(build.assets.alpha.async.css); + }); + + test("does not append background files to generated HTML", async () => { + const build = await compile(false, {htmlChunks: ["alpha", "beta"]}); + const html = build.sources["fixture.html"]; + const scripts = Array.from(html.matchAll(/]+src="([^"]+)"/g), match => match[1]); + const background = findNamedFile(build.assets.background.initial.js, "background", "js"); + + expect(scripts.map(normalizeUrl)).not.toContain(background); + expect(scripts.every(script => !script.includes("__adnbn_build_assets__"))).toBe(true); + }); + + test("rejects async chunks in the background entrypoint", async () => { + await expect(compile(false, {background: "async"})).rejects.toThrow( + 'Full build-assets entrypoint "background" must be a single self-contained chunk; async chunks are not supported' + ); + }); + + test("rejects a shared initial chunk extracted from background", async () => { + await expect(compile(true, {background: "shared"})).rejects.toThrow( + 'Full build-assets entrypoint "background" must be a single self-contained chunk; async chunks are not supported' + ); + }); + + test("rejects separate runtime chunks after user optimization is applied", async () => { + await expect(compile(false, {separateRuntime: true})).rejects.toThrow( + 'Build assets require a self-contained runtime for entrypoint "background"; disable optimization.runtimeChunk' + ); + }); + + test("rejects the Rspack fullhash callback limitation before emitting invalid JavaScript", async () => { + await expect(compile(false, {fullHashCallback: true, jsHash: "fullhash"})).rejects.toThrow( + "Rspack cannot generate async chunk URLs when a filename callback returns [fullhash]; configure jsFilename as a string template instead" + ); + }); + + test("rejects the Rspack CSS fullhash callback limitation before emitting invalid JavaScript", async () => { + await expect(compile(false, {cssFullHashCallback: true})).rejects.toThrow( + "Rspack cannot generate async CSS chunk URLs when a filename callback returns [fullhash]; configure cssFilename as a string template instead" + ); + }); + + test("uses Rspack PathData and AssetInfo when resolving a local callback map", async () => { + const build = await compile(false, {assetInfoFilename: true, jsHash: "contenthash"}); + const alpha = executeEntrypoint(build, "alpha").sandbox; + + expect(build.assets.alpha.initial.js[0]).toMatch(/^custom\/js\/javascript\/alpha\./); + expect(alpha.__alphaEntrypointAssets).toEqual(runtimeAssets(build.assets.alpha)); + }); + + test("hashes callback results that depend on opaque closure values", async () => { + const [before, after] = await Promise.all([ + compile(false, {betaDirectory: "beta-a", buildHashSalt: "opaque-callback"}), + compile(false, {betaDirectory: "beta-b", buildHashSalt: "opaque-callback"}), + ]); + const backgroundBefore = findNamedFile(before.assets.background.initial.js, "background", "js"); + const backgroundAfter = findNamedFile(after.assets.background.initial.js, "background", "js"); + + expect(after.assets.beta.initial.js).not.toEqual(before.assets.beta.initial.js); + expect(backgroundAfter).not.toBe(backgroundBefore); + expectNoChangedBytesUnderStableNames(before, after); + }); + + test("invalidates the background map when a foreign runtime module changes another entrypoint", async () => { + const [before, after] = await Promise.all([ + compile(false, {betaRuntimeMarker: "before", buildHashSalt: "foreign-runtime"}), + compile(false, {betaRuntimeMarker: "after", buildHashSalt: "foreign-runtime"}), + ]); + const backgroundBefore = findNamedFile(before.assets.background.initial.js, "background", "js"); + const backgroundAfter = findNamedFile(after.assets.background.initial.js, "background", "js"); + const betaBefore = findNamedFile(before.assets.beta.initial.js, "beta", "js"); + const betaAfter = findNamedFile(after.assets.beta.initial.js, "beta", "js"); + + expect(betaAfter).not.toBe(betaBefore); + expect(after.hashes[betaAfter]).not.toBe(before.hashes[betaBefore]); + expect(backgroundAfter).not.toBe(backgroundBefore); + expectNoChangedBytesUnderStableNames(before, after); + expect(executeEntrypoint(after, "background").sandbox.__backgroundBuildAssets).toEqual(after.assets); + }); + + test("rejects callbacks that read unresolved hash data instead of returning placeholders", async () => { + await expect(compile(false, {resolvedHashFilename: true})).rejects.toThrow( + "Build assets filename callbacks cannot read PathData hash values; return [contenthash], [chunkhash], or [fullhash] placeholders instead" + ); + }); + + test("keeps custom hashed filenames and bytes stable across identical builds", async () => { + const [first, second] = await Promise.all([compile(false), compile(false)]); + + expect(second.assets).toEqual(first.assets); + expect(second.hashes).toEqual(first.hashes); + }); + + test.each(["chunkhash", "contenthash"] as const)( + "invalidates %s maps when a foreign CSS dependency changes", + async jsHash => { + const [before, after] = await Promise.all([ + compile(false, {jsHash}), + compile(false, {betaCssChange: true, jsHash}), + ]); + const backgroundBefore = findNamedFile(before.assets.background.initial.js, "background", "js"); + const backgroundAfter = findNamedFile(after.assets.background.initial.js, "background", "js"); + const betaBefore = findNamedFile(before.assets.beta.initial.js, "beta", "js"); + const betaAfter = findNamedFile(after.assets.beta.initial.js, "beta", "js"); + + expect(backgroundAfter).not.toBe(backgroundBefore); + expect(betaAfter).not.toBe(betaBefore); + expect(after.assets.beta.initial.css).not.toEqual(before.assets.beta.initial.css); + expectNoChangedBytesUnderStableNames(before, after); + expect(executeEntrypoint(after, "background").sandbox.__backgroundBuildAssets).toEqual(after.assets); + expect(executeEntrypoint(after, "beta").sandbox.__betaEntrypointAssets).toEqual( + runtimeAssets(after.assets.beta) + ); + } + ); + + test("invalidates every consumer when a shared chunk changes", async () => { + const [before, after] = await Promise.all([compile(true), compile(true, {sharedChange: true})]); + + for (const name of ["alpha", "background", "beta"] as const) { + expect(findNamedFile(after.assets[name].initial.js, name, "js")).not.toBe( + findNamedFile(before.assets[name].initial.js, name, "js") + ); + } + + expectNoChangedBytesUnderStableNames(before, after); + }); + + test("keeps configured CSS directories inside callback-generated names", async () => { + const [css, styles] = await Promise.all([ + compile(false, {buildHashSalt: "css-directory", cssDir: "custom/css"}), + compile(false, {buildHashSalt: "styles-directory", cssDir: "custom/styles"}), + ]); + + expect(css.assets.beta.initial.css[0]).toMatch(/^custom\/css\//); + expect(styles.assets.beta.initial.css[0]).toMatch(/^custom\/styles\//); + expect(findNamedFile(styles.assets.background.initial.js, "background", "js")).not.toBe( + findNamedFile(css.assets.background.initial.js, "background", "js") + ); + }); + + test.each([ + {options: {renameBetaCss: true}, scenario: "renamed during processAssets"}, + {options: {removeBetaCss: true}, scenario: "removed during processAssets"}, + {options: {renameBetaCssAfterProcessAssets: true}, scenario: "renamed after processAssets"}, + { + options: {assertEmptyOutputOnFailure: true, renameBetaCssAtEmit: true}, + scenario: "renamed at emit", + }, + ])("rejects an asset $scenario", async ({options}) => { + await expect(compile(false, options)).rejects.toThrow( + "Build assets changed after the runtime map was embedded" + ); + }); + + test("rejects a late asset rename when the build has no background entrypoint", async () => { + await expect( + compile(false, { + includeBackground: false, + renameBetaCssAfterProcessAssets: true, + }) + ).rejects.toThrow('Build assets changed after the runtime map for entrypoint "beta" was embedded'); + }); +}); diff --git a/src/cli/bundler/plugins/BuildAssetsMapPlugin.ts b/src/cli/bundler/plugins/BuildAssetsMapPlugin.ts new file mode 100644 index 00000000..a09c3138 --- /dev/null +++ b/src/cli/bundler/plugins/BuildAssetsMapPlugin.ts @@ -0,0 +1,814 @@ +import { + AssetInfo, + Chunk, + Compilation, + Compiler, + Filename, + PathData, + RuntimeGlobals, + RuntimeModule, + sources, +} from "@rspack/core"; +import stringify from "json-stringify-deterministic"; +import ts from "typescript"; + +import {collectBuildAssets, setCompilationBuildAssets} from "@cli/bundler/utils/output"; +import {toPosix} from "@cli/utils/path"; + +import type {EntrypointAssetsMap, EntrypointAssets} from "@typing/entrypoint"; + +const PluginName = "BuildAssetsMapPlugin"; +// Replaced with the full map once Rspack has resolved the output filenames. +const FullMapPlaceholder = "__ADNBN_BUILD_ASSETS_FULL_MAP_PLACEHOLDER__"; +// These JSON envelope keys let final validation locate each map after minification. +const FullMapEnvelopeKey = "__adnbnBuildAssetsFullMap__"; +const CurrentMapEnvelopeKey = "__adnbnBuildAssetsCurrentMap__"; +const MapProperty = "__adnbnBuildAssets"; +const CurrentMapProperty = "__adnbnCurrentEntrypointAssets"; +const CssContentHashType = "css/mini-extract"; +const JavaScriptContentHashType = "javascript"; +const UnsupportedCallbackHashError = + "Build assets filename callbacks cannot read PathData hash values; return [contenthash], [chunkhash], or [fullhash] placeholders instead"; + +// Hash-based output names are fixed when processAssets starts, so the asset graph is frozen here. +const EmbedStage = Number.NEGATIVE_INFINITY; +const FinalValidationStage = Infinity; + +interface FullMapRuntime { + readonly name: string; + readonly runtime: Chunk; +} + +export interface BuildAssetsMapPluginOptions { + readonly buildHashSalt?: string; + readonly cssChunkFilename: Filename; + readonly cssFilename: Filename; + readonly fullMapEntrypoint: string; +} + +type FilenameTemplateCache = Map>>; +type RuntimeModuleFingerprints = Map>; + +const filenameTemplateCaches = new WeakMap(); + +// Final hashes do not exist while chunkHash is calculated. Returning placeholders keeps the callback result +// hashable before Rspack resolves those placeholders into the emitted filename. +const rejectResolvedHashAccess = (value: T): T => { + return new Proxy(value, { + get(target, property, receiver) { + if (property === "hash" || property === "contentHash") { + throw new Error(UnsupportedCallbackHashError); + } + + return Reflect.get(target, property, receiver); + }, + }); +}; + +const getFilenameCallbackContext = ( + compilation: Compilation, + chunk: Chunk, + contentHashType: string +): {assetInfo: AssetInfo; pathData: PathData} => { + const id = chunk.id == null ? undefined : String(chunk.id); + const pathData: PathData = { + chunk: rejectResolvedHashAccess({ + id, + name: chunk.name ?? id, + hash: chunk.hash ?? undefined, + }), + contentHash: chunk.contentHash[contentHashType], + hash: compilation.fullHash ?? undefined, + }; + const assetInfo: AssetInfo = { + fullhash: [], + chunkhash: [], + contenthash: [], + related: {}, + assetType: contentHashType === JavaScriptContentHashType ? "javascript" : "extract-css", + }; + + if (contentHashType === JavaScriptContentHashType) { + pathData.runtime = undefined; + assetInfo.javascriptModule = Boolean(compilation.outputOptions.module); + } + + return {assetInfo, pathData: rejectResolvedHashAccess(pathData)}; +}; + +const getFilenamePathData = (compilation: Compilation, chunk: Chunk, contentHashType: string): PathData => { + return { + chunk, + contentHash: chunk.contentHash[contentHashType], + contentHashType, + hash: compilation.fullHash ?? undefined, + }; +}; + +const resolveFilenameTemplate = ( + compilation: Compilation, + chunk: Chunk, + filename: Filename, + contentHashType: string +): string => { + if (typeof filename !== "function") { + return filename; + } + + let compilationCache = filenameTemplateCaches.get(compilation); + + if (!compilationCache) { + compilationCache = new Map(); + filenameTemplateCaches.set(compilation, compilationCache); + } + + let filenameCache = compilationCache.get(filename); + + if (!filenameCache) { + filenameCache = new Map(); + compilationCache.set(filename, filenameCache); + } + + let chunkCache = filenameCache.get(chunk); + + if (!chunkCache) { + chunkCache = new Map(); + filenameCache.set(chunk, chunkCache); + } + + const cached = chunkCache.get(contentHashType); + + if (cached !== undefined) { + return cached; + } + + const {assetInfo, pathData} = getFilenameCallbackContext(compilation, chunk, contentHashType); + const template = filename(pathData, assetInfo); + + chunkCache.set(contentHashType, template); + + return template; +}; + +const resolveChunkFilename = ( + compilation: Compilation, + chunk: Chunk, + filename: Filename, + contentHashType: string +): string => { + const data = getFilenamePathData(compilation, chunk, contentHashType); + const template = resolveFilenameTemplate(compilation, chunk, filename, contentHashType); + + return toPosix(compilation.getPath(template, data)); +}; + +const filenameRequiresFullHash = ( + compilation: Compilation, + chunk: Chunk, + filename: Filename, + contentHashType: string +): boolean => { + const template = resolveFilenameTemplate(compilation, chunk, filename, contentHashType); + const callbackUsesHash = typeof filename === "function" && /\b(?:fullHash|hash)\b/.test(filename.toString()); + + return callbackUsesHash || /\[(?:fullhash|hash)(?::\d+)?\]/i.test(template); +}; + +const validateAsyncChunkFilenames = (compilation: Compilation, cssChunkFilename: Filename): void => { + const asyncChunks = new Set( + Array.from(compilation.entrypoints.values()).flatMap(entrypoint => { + return Array.from(entrypoint.getEntrypointChunk().getAllAsyncChunks()); + }) + ); + const validate = ( + filename: Filename, + chunks: Iterable, + contentHashType: string, + assetType: "CSS" | "JavaScript", + configName: "cssFilename" | "jsFilename" + ): void => { + if (typeof filename !== "function") { + return; + } + + const returnsFullHash = Array.from(chunks).some(chunk => { + const template = resolveFilenameTemplate(compilation, chunk, filename, contentHashType); + + return /\[(?:fullhash|hash)(?::\d+)?\]/i.test(template); + }); + + if (returnsFullHash) { + const target = assetType === "JavaScript" ? "async chunk URLs" : "async CSS chunk URLs"; + + throw new Error( + `Rspack cannot generate ${target} when a filename callback returns [fullhash]; configure ${configName} as a string template instead` + ); + } + }; + + if (compilation.outputOptions.chunkFilename) { + validate( + compilation.outputOptions.chunkFilename, + asyncChunks, + JavaScriptContentHashType, + "JavaScript", + "jsFilename" + ); + } + validate( + cssChunkFilename, + Array.from(asyncChunks).filter(chunk => chunkHasCss(compilation, chunk)), + CssContentHashType, + "CSS", + "cssFilename" + ); +}; + +const chunkHasCss = (compilation: Compilation, chunk: Chunk): boolean => { + return Array.from(compilation.chunkGraph.getChunkModulesIterable(chunk)).some(module => { + return module.type === CssContentHashType; + }); +}; + +const getEntrypointRuntimeAssets = ( + compilation: Compilation, + name: string, + cssFilename: Filename, + cssChunkFilename: Filename +): EntrypointAssets => { + const entrypoint = compilation.entrypoints.get(name); + + if (!entrypoint) { + throw new Error(`Build assets entrypoint "${name}" is unavailable`); + } + + const entryChunk = entrypoint.getEntrypointChunk(); + const initialChunks = Array.from(entrypoint.chunks); + const asyncChunks = Array.from(entryChunk.getAllAsyncChunks()); + const jsFilename = compilation.outputOptions.filename; + const jsChunkFilename = compilation.outputOptions.chunkFilename; + + if (!jsFilename || !jsChunkFilename) { + throw new Error("Build assets require output.filename and output.chunkFilename"); + } + + return { + initial: { + js: initialChunks.map(chunk => { + return resolveChunkFilename( + compilation, + chunk, + chunk === entryChunk ? jsFilename : jsChunkFilename, + JavaScriptContentHashType + ); + }), + css: initialChunks + .filter(chunk => chunkHasCss(compilation, chunk)) + .map(chunk => { + return resolveChunkFilename( + compilation, + chunk, + chunk === entryChunk ? cssFilename : cssChunkFilename, + CssContentHashType + ); + }), + }, + async: { + js: asyncChunks.map(chunk => { + return resolveChunkFilename(compilation, chunk, jsChunkFilename, JavaScriptContentHashType); + }), + css: asyncChunks + .filter(chunk => chunkHasCss(compilation, chunk)) + .map(chunk => { + return resolveChunkFilename(compilation, chunk, cssChunkFilename, CssContentHashType); + }), + }, + }; +}; + +const entrypointRuntimeUsesFullHash = ( + compilation: Compilation, + name: string, + cssFilename: Filename, + cssChunkFilename: Filename +): boolean => { + const entrypoint = compilation.entrypoints.get(name); + + if (!entrypoint) { + return false; + } + + const entryChunk = entrypoint.getEntrypointChunk(); + const initialChunks = Array.from(entrypoint.chunks); + const asyncChunks = Array.from(entryChunk.getAllAsyncChunks()); + const jsFilename = compilation.outputOptions.filename; + const jsChunkFilename = compilation.outputOptions.chunkFilename; + + if (!jsFilename || !jsChunkFilename) { + return false; + } + + return ( + initialChunks.some(chunk => { + const js = filenameRequiresFullHash( + compilation, + chunk, + chunk === entryChunk ? jsFilename : jsChunkFilename, + JavaScriptContentHashType + ); + const css = + chunkHasCss(compilation, chunk) && + filenameRequiresFullHash( + compilation, + chunk, + chunk === entryChunk ? cssFilename : cssChunkFilename, + CssContentHashType + ); + + return js || css; + }) || + asyncChunks.some(chunk => { + const js = filenameRequiresFullHash(compilation, chunk, jsChunkFilename, JavaScriptContentHashType); + const css = + chunkHasCss(compilation, chunk) && + filenameRequiresFullHash(compilation, chunk, cssChunkFilename, CssContentHashType); + + return js || css; + }) + ); +}; + +const getChunkKey = (chunk: Chunk): string => { + return `${String(chunk.name ?? "")}:${String(chunk.id ?? "")}`; +}; + +const getFullMapRuntime = (compilation: Compilation, name: string): FullMapRuntime | undefined => { + const entrypoint = compilation.entrypoints.get(name); + + if (!entrypoint) { + return; + } + + return {name, runtime: entrypoint.getRuntimeChunk()}; +}; + +const getFullMapRuntimeFiles = (fullMap: FullMapRuntime, assets: EntrypointAssetsMap): readonly string[] => { + const runtimeFiles = new Set(Array.from(fullMap.runtime.files, toPosix)); + + return assets[fullMap.name].initial.js.filter(file => runtimeFiles.has(file)); +}; + +const validateEntrypointRuntimes = (compilation: Compilation): void => { + for (const [name, entrypoint] of compilation.entrypoints) { + if (entrypoint.getRuntimeChunk() !== entrypoint.getEntrypointChunk()) { + throw new Error( + `Build assets require a self-contained runtime for entrypoint "${name}"; disable optimization.runtimeChunk` + ); + } + } +}; + +const validateFullMapEntrypoint = (compilation: Compilation, name: string): void => { + const entrypoint = compilation.entrypoints.get(name); + + if (!entrypoint) { + return; + } + + const entryChunk = entrypoint.getEntrypointChunk(); + const initialChunks = Array.from(entrypoint.chunks); + const asyncChunks = Array.from(entryChunk.getAllAsyncChunks()); + + if (initialChunks.length !== 1 || initialChunks[0] !== entryChunk || asyncChunks.length > 0) { + throw new Error( + `Full build-assets entrypoint "${name}" must be a single self-contained chunk; async chunks are not supported` + ); + } +}; + +const validateFullMapAssets = (assets: EntrypointAssetsMap, name: string): void => { + const entrypoint = assets[name]; + + if (!entrypoint) { + return; + } + + if (entrypoint.initial.js.length !== 1 || entrypoint.async.js.length > 0) { + throw new Error( + `Full build-assets entrypoint "${name}" must emit exactly one initial JavaScript file and no async JavaScript files` + ); + } +}; + +const serializeFilename = (filename: unknown): string => { + return typeof filename === "function" ? filename.toString() : JSON.stringify(filename); +}; + +const collectRuntimeModuleFingerprints = (compilation: Compilation): RuntimeModuleFingerprints => { + const fingerprints: RuntimeModuleFingerprints = new Map(); + + compilation.hooks.runtimeModule.tap(PluginName, (runtimeModule, chunk) => { + if (runtimeModule.identifier().startsWith("webpack/runtime/build assets ")) { + return; + } + + let chunkFingerprints = fingerprints.get(chunk); + + if (!chunkFingerprints) { + chunkFingerprints = new Map(); + fingerprints.set(chunk, chunkFingerprints); + } + + chunkFingerprints.set(`${runtimeModule.stage}:${runtimeModule.identifier()}`, runtimeModule.generate()); + }); + + return fingerprints; +}; + +const updateBuildAssetsChunkHashes = ( + compilation: Compilation, + options: Required, + runtimeModuleFingerprints: RuntimeModuleFingerprints +): void => { + compilation.hooks.chunkHash.tap(PluginName, (chunk, hash) => { + const runtimeEntry = Array.from(compilation.entrypoints).find(([, entrypoint]) => { + return entrypoint.getRuntimeChunk() === chunk; + }); + + if (!runtimeEntry) { + return; + } + + const [runtimeName] = runtimeEntry; + const fullMap = runtimeName === options.fullMapEntrypoint; + const entries = fullMap + ? Array.from(compilation.entrypoints).toSorted(([left], [right]) => left.localeCompare(right)) + : [runtimeEntry]; + + hash.update(`${PluginName}:runtime-map:${runtimeName}`, "utf8"); + hash.update(options.buildHashSalt, "utf8"); + hash.update(serializeFilename(compilation.outputOptions.filename), "utf8"); + hash.update(serializeFilename(compilation.outputOptions.chunkFilename), "utf8"); + hash.update(serializeFilename(options.cssFilename), "utf8"); + hash.update(serializeFilename(options.cssChunkFilename), "utf8"); + + if (fullMap) { + hash.update(serializeFilename(compilation.outputOptions.assetModuleFilename), "utf8"); + } + + for (const [entryName, entrypoint] of entries) { + hash.update(entryName, "utf8"); + + const entryChunk = entrypoint.getEntrypointChunk(); + const initialChunks = new Set(entrypoint.chunks); + const chunks = Array.from(entryChunk.getAllReferencedChunks()).toSorted((left, right) => + getChunkKey(left).localeCompare(getChunkKey(right)) + ); + + for (const referencedChunk of chunks) { + const category = initialChunks.has(referencedChunk) ? "initial" : "async"; + + hash.update(`${category}:${getChunkKey(referencedChunk)}`, "utf8"); + + const jsFilename = + referencedChunk === entryChunk + ? compilation.outputOptions.filename + : compilation.outputOptions.chunkFilename; + + if (jsFilename) { + const template = resolveFilenameTemplate( + compilation, + referencedChunk, + jsFilename, + JavaScriptContentHashType + ); + + hash.update(`javascript-template:${template}`, "utf8"); + } + + if (chunkHasCss(compilation, referencedChunk)) { + const cssFilename = referencedChunk === entryChunk ? options.cssFilename : options.cssChunkFilename; + const template = resolveFilenameTemplate( + compilation, + referencedChunk, + cssFilename, + CssContentHashType + ); + + hash.update(`css-template:${template}`, "utf8"); + } + + const moduleHashes = Array.from( + compilation.chunkGraph.getChunkModulesIterable(referencedChunk), + module => { + return compilation.chunkGraph.getModuleHash(module, new Set(referencedChunk.runtime)) ?? ""; + } + ).toSorted(); + + moduleHashes.forEach(moduleHash => hash.update(moduleHash, "utf8")); + + const runtimeFingerprints = runtimeModuleFingerprints.get(referencedChunk); + + if (runtimeFingerprints) { + Array.from(runtimeFingerprints) + .toSorted(([left], [right]) => left.localeCompare(right)) + .forEach(([identifier, source]) => { + hash.update(`runtime-module:${identifier}`, "utf8"); + hash.update(source, "utf8"); + }); + } + } + } + }); +}; + +class BuildAssetsRuntimeModule extends RuntimeModule { + public constructor( + private readonly fullMap: boolean, + private readonly runtimeName: string, + private readonly cssFilename: Filename, + private readonly cssChunkFilename: Filename, + fullHash: boolean + ) { + super(`build assets ${fullMap ? "full" : "current"} ${runtimeName}`, RuntimeModule.STAGE_TRIGGER); + + if (!fullMap) { + this.dependentHash = true; + this.fullHash = fullHash; + } + } + + public generate(): string { + const runtime = RuntimeGlobals.require; + + if (this.fullMap) { + return `${runtime}.${MapProperty} = JSON.parse(${JSON.stringify(FullMapPlaceholder)}).${FullMapEnvelopeKey};`; + } + + const compilation = this.compilation; + + if (!compilation) { + throw new Error("Build assets runtime is not attached to a compilation"); + } + + const current = getEntrypointRuntimeAssets( + compilation, + this.runtimeName, + this.cssFilename, + this.cssChunkFilename + ); + + const envelope = JSON.stringify({[CurrentMapEnvelopeKey]: current}); + + return `${runtime}.${CurrentMapProperty} = JSON.parse(${JSON.stringify(envelope)}).${CurrentMapEnvelopeKey};`; + } +} + +const replaceFullMapPlaceholder = ( + compilation: Compilation, + runtimeFiles: readonly string[], + assets: EntrypointAssetsMap +): void => { + const placeholder = JSON.stringify(FullMapPlaceholder); + const replacementValue = JSON.stringify(JSON.stringify({[FullMapEnvelopeKey]: assets})); + let replacements = 0; + + for (const file of runtimeFiles) { + const asset = compilation.getAsset(file); + + if (!asset) { + continue; + } + + const code = asset.source.source().toString(); + const start = code.indexOf(placeholder); + + if (start < 0) { + continue; + } + + if (code.indexOf(placeholder, start + placeholder.length) >= 0) { + throw new Error(`Build assets placeholder occurs more than once in "${file}"`); + } + + const source = new sources.ReplaceSource(asset.source); + + source.replace(start, start + placeholder.length - 1, replacementValue); + compilation.updateAsset(file, source); + replacements += 1; + } + + if (replacements !== 1) { + throw new Error(`Expected one full build-assets runtime, found ${replacements}`); + } +}; + +const extractEmbeddedEnvelope = (file: string, source: string, key: string): T => { + const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.JS); + const values: T[] = []; + + const visit = (node: ts.Node): void => { + if (ts.isStringLiteralLike(node) && node.text.includes(key)) { + try { + const envelope = JSON.parse(node.text) as Record; + + if (Object.prototype.hasOwnProperty.call(envelope, key)) { + values.push(envelope[key]); + } + } catch {} + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + + if (values.length !== 1) { + throw new Error(`Expected one embedded build assets value in "${file}", found ${values.length}`); + } + + return values[0]; +}; + +const extractEmbeddedMap = (file: string, source: string): EntrypointAssetsMap => { + return extractEmbeddedEnvelope(file, source, FullMapEnvelopeKey); +}; + +const validateEmbeddedMap = (compilation: Compilation, fullMap: FullMapRuntime, assets: EntrypointAssetsMap): void => { + const runtimeFiles = getFullMapRuntimeFiles(fullMap, assets); + const runtime = runtimeFiles + .map(file => ({file, source: compilation.getAsset(file)?.source.source().toString() ?? ""})) + .find(({source}) => source.length > 0); + + if (!runtime) { + throw new Error( + `Full build-assets runtime for entrypoint "${fullMap.name}" is unavailable after asset processing` + ); + } + + const embedded = extractEmbeddedMap(runtime.file, runtime.source); + + if (stringify(embedded) !== stringify(assets)) { + throw new Error( + "Build assets changed after the runtime map was embedded; finalize entrypoint assets before processAssets begins" + ); + } +}; + +const validateCurrentEntrypointMaps = ( + compilation: Compilation, + assets: EntrypointAssetsMap, + fullMapEntrypoint: string +): void => { + for (const [name, entrypointAssets] of Object.entries(assets)) { + if (name === fullMapEntrypoint) { + continue; + } + + const entrypoint = compilation.entrypoints.get(name); + + if (!entrypoint) { + throw new Error(`Build assets entrypoint "${name}" is unavailable`); + } + + const runtimeFiles = new Set(Array.from(entrypoint.getRuntimeChunk().files, toPosix)); + const runtime = entrypointAssets.initial.js + .filter(file => runtimeFiles.has(file)) + .map(file => ({file, source: compilation.getAsset(file)?.source.source().toString() ?? ""})) + .find(({source}) => source.length > 0); + + if (!runtime) { + throw new Error(`Runtime map for entrypoint "${name}" is unavailable after asset processing`); + } + + const runtimeAssets = extractEmbeddedEnvelope( + runtime.file, + runtime.source, + CurrentMapEnvelopeKey + ); + const finalizedAssets: EntrypointAssets = { + initial: entrypointAssets.initial, + async: entrypointAssets.async, + }; + + if (stringify(runtimeAssets) !== stringify(finalizedAssets)) { + throw new Error(`Build assets changed after the runtime map for entrypoint "${name}" was embedded`); + } + } +}; + +const finalizeBuildAssets = (compilation: Compilation, options: Required): void => { + const assets = collectBuildAssets(compilation); + const fullMap = getFullMapRuntime(compilation, options.fullMapEntrypoint); + + validateFullMapAssets(assets, options.fullMapEntrypoint); + + if (fullMap) { + validateEmbeddedMap(compilation, fullMap, assets); + } + + validateCurrentEntrypointMaps(compilation, assets, options.fullMapEntrypoint); + + setCompilationBuildAssets(compilation, assets); +}; + +export default class BuildAssetsMapPlugin { + private readonly options: Required; + + public constructor(options: BuildAssetsMapPluginOptions) { + this.options = { + ...options, + buildHashSalt: options.buildHashSalt ?? "", + }; + } + + public apply(compiler: Compiler): void { + compiler.hooks.thisCompilation.tap({name: PluginName, stage: EmbedStage}, compilation => { + const injectedRuntimes = new Set(); + let filenamesValidated = false; + let runtimesValidated = false; + const runtimeModuleFingerprints = collectRuntimeModuleFingerprints(compilation); + + updateBuildAssetsChunkHashes(compilation, this.options, runtimeModuleFingerprints); + + compilation.hooks.additionalTreeRuntimeRequirements.tap(PluginName, chunk => { + if (!runtimesValidated) { + validateEntrypointRuntimes(compilation); + validateFullMapEntrypoint(compilation, this.options.fullMapEntrypoint); + runtimesValidated = true; + } + + if (!filenamesValidated) { + validateAsyncChunkFilenames(compilation, this.options.cssChunkFilename); + filenamesValidated = true; + } + + const runtimeEntry = Array.from(compilation.entrypoints).find(([, entrypoint]) => { + return entrypoint.getRuntimeChunk() === chunk; + }); + + if (!runtimeEntry || injectedRuntimes.has(chunk)) { + return; + } + + const [runtimeName] = runtimeEntry; + + injectedRuntimes.add(chunk); + compilation.addRuntimeModule( + chunk, + new BuildAssetsRuntimeModule( + runtimeName === this.options.fullMapEntrypoint, + runtimeName, + this.options.cssFilename, + this.options.cssChunkFilename, + entrypointRuntimeUsesFullHash( + compilation, + runtimeName, + this.options.cssFilename, + this.options.cssChunkFilename + ) + ) + ); + }); + + compilation.hooks.processAssets.tap( + { + name: PluginName, + stage: EmbedStage, + }, + () => { + const assets = collectBuildAssets(compilation); + const fullMap = getFullMapRuntime(compilation, this.options.fullMapEntrypoint); + + validateFullMapAssets(assets, this.options.fullMapEntrypoint); + setCompilationBuildAssets(compilation, assets); + + if (fullMap) { + replaceFullMapPlaceholder(compilation, getFullMapRuntimeFiles(fullMap, assets), assets); + } + } + ); + + compilation.hooks.processAssets.tap( + { + name: `${PluginName}:finalize`, + stage: Compilation.PROCESS_ASSETS_STAGE_ANALYSE, + }, + () => finalizeBuildAssets(compilation, this.options) + ); + }); + + // Register after every afterPlugins callback, then run after ordinary emit taps but before files are written. + compiler.hooks.afterPlugins.intercept({ + name: `${PluginName}:registerFinalValidation`, + done: () => { + compiler.hooks.emit.tap( + {name: `${PluginName}:finalValidation`, stage: FinalValidationStage}, + compilation => { + finalizeBuildAssets(compilation, this.options); + } + ); + }, + }); + } +} diff --git a/src/cli/bundler/plugins/ChunkLoaderPlugin.test.ts b/src/cli/bundler/plugins/ChunkLoaderPlugin.test.ts new file mode 100644 index 00000000..b572e9b1 --- /dev/null +++ b/src/cli/bundler/plugins/ChunkLoaderPlugin.test.ts @@ -0,0 +1,108 @@ +/** @jest-environment node */ + +import fs from "fs"; +import os from "os"; +import path from "path"; + +import {type Compiler, rspack, type Stats} from "@rspack/core"; + +import ChunkLoaderPlugin from "./ChunkLoaderPlugin"; + +const fixtures = path.resolve(__dirname, "tests", "fixtures", "chunk-loader"); + +const closeCompiler = (compiler: Compiler): Promise => { + return new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); +}; + +const runCompiler = (compiler: Compiler): Promise => { + return new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) { + reject(error); + } else if (!stats) { + reject(new Error("Rspack did not return build stats")); + } else if (stats.hasErrors()) { + reject(new Error(stats.toString({all: false, errors: true, errorDetails: true}))); + } else { + resolve(stats); + } + }); + }); +}; + +test("adds the extension chunk loader only to selected entrypoints with physical async chunks", async () => { + const outputPath = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-extension-chunks-")); + const compiler = rspack({ + context: fixtures, + mode: "development", + target: ["web", "es2020"], + devtool: false, + entry: { + selected: { + import: "./entry.js", + asyncChunks: true, + layer: "selected", + publicPath: "", + }, + inlined: { + import: "./entry.js", + asyncChunks: false, + layer: "inlined", + publicPath: "", + }, + unselected: "./unselected.js", + }, + output: { + path: outputPath, + clean: true, + filename: "custom/js/[name].[chunkhash:8].js", + chunkFilename: "custom/js/[name].[chunkhash:8].js", + }, + optimization: { + minimize: false, + runtimeChunk: false, + splitChunks: false, + }, + plugins: [new ChunkLoaderPlugin({test: entry => entry === "selected"})], + }); + + try { + const stats = await runCompiler(compiler); + const asyncFiles = (name: string): string[] => { + const entrypoint = stats.compilation.entrypoints.get(name); + + if (!entrypoint) { + throw new Error(`Entrypoint ${name} was not found`); + } + + return entrypoint + .getEntrypointChunk() + .getAllAsyncChunks() + .flatMap(chunk => Array.from(chunk.files)); + }; + const initialSource = (name: string): string => { + const file = stats.compilation.entrypoints + .get(name) + ?.getFiles() + .find(filename => filename.endsWith(".js")); + + if (!file) { + throw new Error(`Initial JavaScript for ${name} was not found`); + } + + return stats.compilation.getAsset(file)!.source.source().toString(); + }; + + expect(asyncFiles("selected")).toEqual([expect.stringMatching(/^custom\/js\/.+\.[a-f0-9]{8}\.js$/)]); + expect(asyncFiles("inlined")).toEqual([]); + expect(asyncFiles("unselected")).toEqual([expect.stringMatching(/^custom\/js\/.+\.[a-f0-9]{8}\.js$/)]); + + expect(initialSource("selected")).toContain("Unable to resolve the extension URL for chunk loading"); + expect(initialSource("selected")).toContain("import(url)"); + expect(initialSource("inlined")).not.toContain("resolveExtensionChunkPublicPath"); + expect(initialSource("unselected")).not.toContain("resolveExtensionChunkPublicPath"); + } finally { + await closeCompiler(compiler); + fs.rmSync(outputPath, {force: true, recursive: true}); + } +}); diff --git a/src/cli/bundler/plugins/ChunkLoaderPlugin.ts b/src/cli/bundler/plugins/ChunkLoaderPlugin.ts new file mode 100644 index 00000000..f1199c5a --- /dev/null +++ b/src/cli/bundler/plugins/ChunkLoaderPlugin.ts @@ -0,0 +1,85 @@ +import {type Chunk, type Compiler, RuntimeGlobals, RuntimeModule} from "@rspack/core"; + +const PluginName = "ChunkLoaderPlugin"; + +export interface ChunkLoaderPluginOptions { + test: (entry: string) => boolean; +} + +class ChunkLoaderRuntimeModule extends RuntimeModule { + public constructor() { + super("extension native chunk loading", RuntimeModule.STAGE_TRIGGER); + } + + public generate(): string { + const ensureChunk = RuntimeGlobals.ensureChunk; + const loadScript = RuntimeGlobals.loadScript; + const publicPath = RuntimeGlobals.publicPath; + + return `var resolveExtensionChunkPublicPath = function() { + if (!${publicPath}) { + var extensionApi = globalThis.browser && globalThis.browser.runtime && globalThis.browser.runtime.getURL + ? globalThis.browser + : globalThis.chrome; + + if (!extensionApi || !extensionApi.runtime || !extensionApi.runtime.getURL) { + return Promise.reject(new Error("Unable to resolve the extension URL for chunk loading")); + } + + ${publicPath} = extensionApi.runtime.getURL("/"); + } + + return Promise.resolve(); + }; + + var ensureExtensionChunk = ${ensureChunk}; + + ${ensureChunk} = function(chunkId) { + return resolveExtensionChunkPublicPath().then(function() { + return ensureExtensionChunk(chunkId); + }); + }; + + ${loadScript} = function(url, done) { + import(url).then( + function() { + done({type: "load", target: {src: url}}); + }, + function(error) { + done({type: "error", target: {src: url}, error: error}); + } + ); + };`; + } +} + +export default class ChunkLoaderPlugin { + public constructor(private readonly options: ChunkLoaderPluginOptions) {} + + public apply(compiler: Compiler): void { + compiler.hooks.thisCompilation.tap(PluginName, compilation => { + const injected = new Set(); + + compilation.hooks.additionalTreeRuntimeRequirements.tap( + {name: PluginName, stage: 10_000}, + (chunk, requirements) => { + if ( + chunk.name === undefined || + !this.options.test(chunk.name) || + chunk.getAllAsyncChunks().length === 0 || + injected.has(chunk) + ) { + return; + } + + requirements.add(RuntimeGlobals.loadScript); + requirements.add(RuntimeGlobals.ensureChunk); + requirements.add(RuntimeGlobals.publicPath); + + injected.add(chunk); + compilation.addRuntimeModule(chunk, new ChunkLoaderRuntimeModule()); + } + ); + }); + } +} diff --git a/src/cli/bundler/plugins/EntrypointPlugin.test.ts b/src/cli/bundler/plugins/EntrypointPlugin.test.ts new file mode 100644 index 00000000..8e499695 --- /dev/null +++ b/src/cli/bundler/plugins/EntrypointPlugin.test.ts @@ -0,0 +1,121 @@ +/** @jest-environment node */ + +import type {Compiler, EntryNormalized} from "@rspack/core"; + +import EntrypointPlugin from "./EntrypointPlugin"; + +class TestEntrypointPlugin extends EntrypointPlugin { + public applyEntryOptions(entry: EntryNormalized): void { + this.hookEntryOption(entry); + } + + public applyWatch(compiler: Compiler): Promise { + return this.hookWatchRun(compiler); + } +} + +test("applies entry options while preserving existing entry configuration", () => { + const plugin = new TestEntrypointPlugin( + new Map([ + [ + "example.content", + new Set([ + { + file: "example.content.ts", + import: "src/example.content.ts", + }, + ]), + ], + ]) + ).entryOptions({ + asyncChunks: false, + publicPath: "", + }); + const entry = { + "example.content": { + import: ["existing.js"], + filename: "custom/[name].js", + asyncChunks: true, + publicPath: "auto", + }, + } as EntryNormalized; + + plugin.applyEntryOptions(entry); + + expect(entry["example.content"]).toMatchObject({ + import: ["existing.js", "virtual/example.content.ts"], + filename: "custom/[name].js", + asyncChunks: false, + publicPath: "", + }); +}); + +test("resolves entry options independently for each entrypoint", () => { + const entryFile = (name: string) => ({ + file: `${name}.ts`, + import: `src/${name}.ts`, + }); + const plugin = new TestEntrypointPlugin( + new Map([ + ["isolated.content", new Set([entryFile("isolated.content")])], + ["main.content", new Set([entryFile("main.content")])], + ]) + ).entryOptions(name => ({ + asyncChunks: name !== "main.content", + layer: name === "main.content" ? "adnbn:content:main" : "adnbn:content:isolated", + publicPath: "", + })); + const entry = { + "isolated.content": ["isolated.js"], + "main.content": ["main.js"], + } as EntryNormalized; + + plugin.applyEntryOptions(entry); + + expect(entry["isolated.content"]).toMatchObject({ + asyncChunks: true, + layer: "adnbn:content:isolated", + publicPath: "", + }); + expect(entry["main.content"]).toMatchObject({ + asyncChunks: false, + layer: "adnbn:content:main", + publicPath: "", + }); +}); + +test("refreshes entry options during watch when virtual modules stay unchanged", async () => { + const entries = new Map([ + [ + "example.content", + new Set([ + { + file: "example.content.ts", + import: "src/example.content.ts", + }, + ]), + ], + ]); + let world: "ISOLATED" | "MAIN" = "ISOLATED"; + const plugin = new TestEntrypointPlugin(entries) + .entryOptions(() => ({ + asyncChunks: world === "ISOLATED", + layer: `adnbn:content:${world.toLowerCase()}`, + })) + .watch(async () => entries); + const entry = {"example.content": ["existing.js"]} as EntryNormalized; + + plugin.applyEntryOptions(entry); + world = "MAIN"; + + await plugin.applyWatch({ + context: process.cwd(), + modifiedFiles: new Set(["example.content.ts"]), + options: {entry}, + } as unknown as Compiler); + + expect(entry["example.content"]).toMatchObject({ + asyncChunks: false, + layer: "adnbn:content:main", + }); +}); diff --git a/src/cli/bundler/plugins/EntrypointPlugin.ts b/src/cli/bundler/plugins/EntrypointPlugin.ts index 1552261e..ae3f4ccf 100644 --- a/src/cli/bundler/plugins/EntrypointPlugin.ts +++ b/src/cli/bundler/plugins/EntrypointPlugin.ts @@ -1,7 +1,7 @@ import _ from "lodash"; import path from "path"; -import {Compiler, EntryNormalized} from "@rspack/core"; +import {Compiler, type EntryDescription, EntryNormalized} from "@rspack/core"; import {RspackVirtualModulePlugin as VirtualModulesPlugin} from "rspack-plugin-virtual-module"; import {EntrypointEntries, EntrypointFile} from "@typing/entrypoint"; @@ -26,11 +26,16 @@ export type EntrypointPluginModules = Map; +export type EntrypointPluginEntryOptions = Omit; + +export type EntrypointPluginEntryOptionsResolver = (name: string) => EntrypointPluginEntryOptions; + export default class EntrypointPlugin { private readonly pluginName: string = "EntrypointPlugin"; private _plugin?: VirtualModulesPlugin; private _modules?: EntrypointPluginEntryModules; + private readonly _entryOptions: EntrypointPluginEntryOptionsResolver[] = []; protected template?: EntrypointPluginTemplate; protected update?: EntrypointPluginUpdate; @@ -81,6 +86,12 @@ export default class EntrypointPlugin { return this; } + public entryOptions(options: EntrypointPluginEntryOptions | EntrypointPluginEntryOptionsResolver): this { + this._entryOptions.push(_.isFunction(options) ? options : () => options); + + return this; + } + public watch(update: EntrypointPluginUpdate): this { this.update = update; @@ -106,15 +117,20 @@ export default class EntrypointPlugin { protected hookEntryOption(entry: EntryNormalized): void { if (_.isPlainObject(entry)) { this.modules.entries().forEach(([name, modules]) => { - let currentFiles = structuredClone(entry[name] ?? []); + const current = structuredClone(entry[name] ?? []); + let currentFiles = current; + let currentOptions: EntrypointPluginEntryOptions = {}; if ("import" in currentFiles) { + currentOptions = currentFiles; currentFiles = currentFiles.import; } currentFiles.push(...Array.from(modules.values(), ({name}) => name)); entry[name] = { + ...currentOptions, + ...this.resolveEntryOptions(name), import: _.uniq(currentFiles), }; }); @@ -149,35 +165,52 @@ export default class EntrypointPlugin { const removedContents = new Map(Array.from(currentContents).filter(entry => !updatedContents.has(entry[0]))); - const addedContents = new Map(Array.from(updatedContents).filter(entry => !currentContents.has(entry[0]))); + const changedContents = new Map( + Array.from(updatedContents).filter(([name, content]) => currentContents.get(name) !== content) + ); - if (removedContents.size > 0 || addedContents.size > 0) { - removedContents.keys().forEach(name => { - this.plugin.writeModule(name, ""); - }); + removedContents.keys().forEach(name => { + this.plugin.writeModule(name, ""); + }); - addedContents.forEach((content, name) => { - this.plugin.writeModule(name, content); - }); + changedContents.forEach((content, name) => { + this.plugin.writeModule(name, content); + }); - updatedModules.entries().forEach(([name, modules]) => { - let entry: string[] = structuredClone(compiler.options.entry[name]); + this.modules.keys().forEach(name => { + if (!updatedModules.has(name)) { + delete compiler.options.entry[name]; + } + }); - if ("import" in entry) { - entry = entry.import as string[]; - } + updatedModules.entries().forEach(([name, modules]) => { + const current = structuredClone(compiler.options.entry[name] ?? []); + let entry = current; + let currentOptions: EntrypointPluginEntryOptions = {}; - entry = entry.filter(file => !removedContents.has(file)); + if ("import" in entry) { + currentOptions = entry; + entry = entry.import as string[]; + } - entry.push(...Array.from(modules.values(), ({name}) => name)); + entry = entry.filter(file => !removedContents.has(file)); - compiler.options.entry[name] = _.uniq(entry); - }); - } + entry.push(...Array.from(modules.values(), ({name}) => name)); + + compiler.options.entry[name] = { + ...currentOptions, + ...this.resolveEntryOptions(name), + import: _.uniq(entry), + }; + }); this._modules = updatedModules; } + protected resolveEntryOptions(name: string): EntrypointPluginEntryOptions { + return Object.assign({}, ...this._entryOptions.map(resolve => resolve(name))); + } + protected createModules(entries: EntrypointEntries): EntrypointPluginEntryModules { const entryModules: EntrypointPluginEntryModules = new Map(); diff --git a/src/cli/bundler/plugins/ManifestPlugin.test.ts b/src/cli/bundler/plugins/ManifestPlugin.test.ts new file mode 100644 index 00000000..ca661f3d --- /dev/null +++ b/src/cli/bundler/plugins/ManifestPlugin.test.ts @@ -0,0 +1,69 @@ +import type {EntrypointAssetsMap} from "@typing/entrypoint"; + +import {createManifestDependencies} from "./ManifestPlugin"; + +describe("createManifestDependencies", () => { + it("keeps only initial content files static and exposes every async file as a runtime resource", () => { + const buildAssets: EntrypointAssetsMap = { + content: { + initial: { + js: ["js/common.js", "js/content.js"], + css: ["css/content.css"], + }, + async: { + js: ["js/lazy.js"], + css: ["css/lazy.css"], + }, + assets: ["assets/image.svg"], + }, + }; + + const dependency = createManifestDependencies(buildAssets).get("content"); + + expect(dependency).toBeDefined(); + expect(Array.from(dependency!.js)).toEqual(["js/common.js", "js/content.js"]); + expect(Array.from(dependency!.css)).toEqual(["css/content.css"]); + expect(Array.from(dependency!.assets)).toEqual(["assets/image.svg", "js/lazy.js", "css/lazy.css"]); + }); + + it("keeps background files isolated from content dependencies", () => { + const buildAssets: EntrypointAssetsMap = { + background: { + initial: { + js: ["js/background.js"], + css: [], + }, + async: { + js: [], + css: [], + }, + assets: ["assets/background.svg"], + }, + content: { + initial: { + js: ["js/content.js"], + css: ["css/content.css"], + }, + async: { + js: ["js/content-lazy.js"], + css: ["css/content-lazy.css"], + }, + assets: ["assets/content.svg"], + }, + }; + + const dependencies = createManifestDependencies(buildAssets); + const content = dependencies.get("content"); + + expect(content).toBeDefined(); + expect(Array.from(content!.js)).toEqual(["js/content.js"]); + expect(Array.from(content!.css)).toEqual(["css/content.css"]); + expect(Array.from(content!.assets)).toEqual([ + "assets/content.svg", + "js/content-lazy.js", + "css/content-lazy.css", + ]); + expect(Array.from(content!.js)).not.toContain("js/background.js"); + expect(Array.from(content!.assets)).not.toContain("assets/background.svg"); + }); +}); diff --git a/src/cli/bundler/plugins/ManifestPlugin.ts b/src/cli/bundler/plugins/ManifestPlugin.ts index b15cedd9..b4a1a0f4 100644 --- a/src/cli/bundler/plugins/ManifestPlugin.ts +++ b/src/cli/bundler/plugins/ManifestPlugin.ts @@ -1,8 +1,25 @@ -import rspack, {Chunk, Compilation, Compiler} from "@rspack/core"; +import rspack, {Compilation, Compiler} from "@rspack/core"; -import {toPosix} from "@cli/utils/path"; +import {getCompilationBuildAssets} from "@cli/bundler/utils/output"; import {ManifestBuilder, ManifestDependencies, ManifestDependency} from "@typing/manifest"; +import {EntrypointAssetsMap} from "@typing/entrypoint"; + +export const createManifestDependencies = (buildAssets: EntrypointAssetsMap): ManifestDependencies => { + const entryDependencies: ManifestDependencies = new Map(); + + Object.entries(buildAssets).forEach(([entryName, assets]) => { + const dependencies: ManifestDependency = { + assets: new Set([...assets.assets, ...assets.async.js, ...assets.async.css]), + css: new Set(assets.initial.css), + js: new Set(assets.initial.js), + }; + + entryDependencies.set(entryName, dependencies); + }); + + return entryDependencies; +}; class ManifestPlugin { constructor(private readonly manifest: ManifestBuilder) {} @@ -12,52 +29,16 @@ class ManifestPlugin { compilation.hooks.processAssets.tap( { name: "ManifestPlugin", - stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, + stage: Compilation.PROCESS_ASSETS_STAGE_REPORT, }, () => { - const entryDependencies: ManifestDependencies = new Map(); - - compilation.entrypoints.forEach((entryPoint, entryName) => { - const dependencies: ManifestDependency = { - assets: new Set(), - css: new Set(), - js: new Set(), - }; - - const allChunks = new Set(); - - this.collectAllChunks(entryPoint, allChunks); - - allChunks.forEach((chunk: Chunk) => { - chunk.files.forEach((fileName: string) => { - fileName = toPosix(fileName); - - if (fileName.endsWith(".js")) { - dependencies.js.add(fileName); - } else if (fileName.endsWith(".css")) { - dependencies.css.add(fileName); - } else if (this.isAsset(fileName)) { - dependencies.assets.add(fileName); - } - }); - - const auxiliaryFiles = chunk.auxiliaryFiles || []; + const buildAssets = getCompilationBuildAssets(compilation); - auxiliaryFiles.forEach((fileName: string) => { - fileName = toPosix(fileName); + if (!buildAssets) { + throw new Error("Build assets are unavailable before manifest generation"); + } - if (fileName.endsWith(".css")) { - dependencies.css.add(fileName); - } else if (this.isAsset(fileName)) { - dependencies.assets.add(fileName); - } - }); - }); - - entryDependencies.set(entryName, dependencies); - }); - - const manifest = this.manifest.setDependencies(entryDependencies).get(); + const manifest = this.manifest.setDependencies(createManifestDependencies(buildAssets)).get(); const json = JSON.stringify(manifest, null, 2); compilation.emitAsset("manifest.json", new rspack.sources.RawSource(json)); @@ -65,28 +46,6 @@ class ManifestPlugin { ); }); } - - private collectAllChunks(entryPointOrChunkGroup: any, collectedChunks: Set): Set { - if (entryPointOrChunkGroup.chunks) { - entryPointOrChunkGroup.chunks.forEach((chunk: Chunk) => { - if (!collectedChunks.has(chunk)) { - collectedChunks.add(chunk); - } - }); - } - - if (entryPointOrChunkGroup.childrenIterable) { - for (const childGroup of entryPointOrChunkGroup.childrenIterable) { - this.collectAllChunks(childGroup, collectedChunks); - } - } - - return collectedChunks; - } - - private isAsset(file: string): boolean { - return /\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)$/.test(file); - } } export default ManifestPlugin; diff --git a/src/cli/bundler/plugins/index.ts b/src/cli/bundler/plugins/index.ts index 20072af0..5a16daae 100644 --- a/src/cli/bundler/plugins/index.ts +++ b/src/cli/bundler/plugins/index.ts @@ -1,5 +1,7 @@ export { default as EntrypointPlugin, + type EntrypointPluginEntryOptions, + type EntrypointPluginEntryOptionsResolver, type EntrypointPluginModules, type EntrypointPluginModule, type EntrypointPluginEntryModules, @@ -7,6 +9,10 @@ export { type EntrypointPluginUpdate, } from "./EntrypointPlugin"; +export {default as ChunkLoaderPlugin, type ChunkLoaderPluginOptions} from "./ChunkLoaderPlugin"; + +export {default as BuildAssetsMapPlugin} from "./BuildAssetsMapPlugin"; + export { default as GenerateJsonPlugin, type GenerateJsonPluginData, diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.css b/src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.css new file mode 100644 index 00000000..987c0998 --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.css @@ -0,0 +1,8 @@ +@font-face { + font-family: "Build assets fixture"; + src: url("./font.woff2") format("woff2"); +} + +.alpha { + color: red; +} diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.lazy.css b/src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.lazy.css new file mode 100644 index 00000000..29712401 --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.lazy.css @@ -0,0 +1,3 @@ +.alpha-lazy { + background-image: url("./lazy-image.svg"); +} diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.lazy.js b/src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.lazy.js new file mode 100644 index 00000000..e63dc6eb --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.lazy.js @@ -0,0 +1,3 @@ +import "./alpha.lazy.css"; + +export const alphaLazy = true; diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.plan-a.js b/src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.plan-a.js new file mode 100644 index 00000000..d1853d23 --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.plan-a.js @@ -0,0 +1,22 @@ +import {getEntrypointAssetsMap, getEntrypointAssets} from "adnbn"; + +import "./alpha.css"; +import payload from "./payload.js?resource"; +import {shared} from "./shared.js"; + +const traceGetter = getter => { + try { + return {ok: true, value: getter()}; + } catch (error) { + return {error: error instanceof Error ? error.message : String(error), ok: false}; + } +}; + +globalThis.__alphaEntrypointAssets = getEntrypointAssets(); +globalThis.__buildAssetsGetterTrace = { + current: traceGetter(getEntrypointAssets), + full: traceGetter(getEntrypointAssetsMap), +}; +globalThis.__alphaPayload = payload; +globalThis.__alphaShared = shared; +globalThis.loadAlphaFixture = () => import(/* webpackChunkName: "alpha-lazy" */ "./alpha.lazy.js"); diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/background-async.plan-a.js b/src/cli/bundler/plugins/tests/fixtures/build-assets/background-async.plan-a.js new file mode 100644 index 00000000..bc88f73c --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/background-async.plan-a.js @@ -0,0 +1,4 @@ +import {getEntrypointAssetsMap} from "adnbn"; + +globalThis.__backgroundBuildAssets = getEntrypointAssetsMap(); +globalThis.loadBackgroundFixture = () => import(/* webpackChunkName: "background-lazy" */ "./alpha.lazy.js"); diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/background-shared.plan-a.js b/src/cli/bundler/plugins/tests/fixtures/build-assets/background-shared.plan-a.js new file mode 100644 index 00000000..098692ce --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/background-shared.plan-a.js @@ -0,0 +1,6 @@ +import {getEntrypointAssetsMap} from "adnbn"; + +import {shared} from "./shared.js"; + +globalThis.__backgroundBuildAssets = getEntrypointAssetsMap(); +globalThis.__backgroundShared = shared; diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/background.plan-a.js b/src/cli/bundler/plugins/tests/fixtures/build-assets/background.plan-a.js new file mode 100644 index 00000000..093189e8 --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/background.plan-a.js @@ -0,0 +1,15 @@ +import {getEntrypointAssetsMap, getEntrypointAssets} from "adnbn"; + +const traceGetter = getter => { + try { + return {ok: true, value: getter()}; + } catch (error) { + return {error: error instanceof Error ? error.message : String(error), ok: false}; + } +}; + +globalThis.__backgroundBuildAssets = getEntrypointAssetsMap(); +globalThis.__buildAssetsGetterTrace = { + current: traceGetter(getEntrypointAssets), + full: traceGetter(getEntrypointAssetsMap), +}; diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.changed.css b/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.changed.css new file mode 100644 index 00000000..ceab525c --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.changed.css @@ -0,0 +1,3 @@ +.beta { + color: purple; +} diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.css b/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.css new file mode 100644 index 00000000..776d9b35 --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.css @@ -0,0 +1,3 @@ +.beta { + color: blue; +} diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.lazy.css b/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.lazy.css new file mode 100644 index 00000000..b8ed4764 --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.lazy.css @@ -0,0 +1,3 @@ +.beta-lazy { + color: green; +} diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.lazy.js b/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.lazy.js new file mode 100644 index 00000000..dfe8ba84 --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.lazy.js @@ -0,0 +1,3 @@ +import "./beta.lazy.css"; + +export const betaLazy = true; diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.plan-a.js b/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.plan-a.js new file mode 100644 index 00000000..37e02b1f --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.plan-a.js @@ -0,0 +1,20 @@ +import {getEntrypointAssetsMap, getEntrypointAssets} from "adnbn"; + +import "./beta.css"; +import {shared} from "./shared.js"; + +const traceGetter = getter => { + try { + return {ok: true, value: getter()}; + } catch (error) { + return {error: error instanceof Error ? error.message : String(error), ok: false}; + } +}; + +globalThis.__betaEntrypointAssets = getEntrypointAssets(); +globalThis.__buildAssetsGetterTrace = { + current: traceGetter(getEntrypointAssets), + full: traceGetter(getEntrypointAssetsMap), +}; +globalThis.__betaShared = shared; +globalThis.loadBetaFixture = () => import(/* webpackChunkName: "beta-lazy" */ "./beta.lazy.js"); diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/font.woff2 b/src/cli/bundler/plugins/tests/fixtures/build-assets/font.woff2 new file mode 100644 index 00000000..784e043d --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/font.woff2 @@ -0,0 +1 @@ +build-assets-fixture-font diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/lazy-image.svg b/src/cli/bundler/plugins/tests/fixtures/build-assets/lazy-image.svg new file mode 100644 index 00000000..40fedc61 --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/lazy-image.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/payload.js b/src/cli/bundler/plugins/tests/fixtures/build-assets/payload.js new file mode 100644 index 00000000..55568142 --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/payload.js @@ -0,0 +1 @@ +globalThis.__buildAssetsPayloadExecuted = true; diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/shared.changed.js b/src/cli/bundler/plugins/tests/fixtures/build-assets/shared.changed.js new file mode 100644 index 00000000..7840096c --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/shared.changed.js @@ -0,0 +1 @@ +export const shared = "changed shared fixture"; diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/shared.js b/src/cli/bundler/plugins/tests/fixtures/build-assets/shared.js new file mode 100644 index 00000000..1e743448 --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/build-assets/shared.js @@ -0,0 +1 @@ +export const shared = "shared fixture"; diff --git a/src/cli/bundler/plugins/tests/fixtures/chunk-loader/entry.js b/src/cli/bundler/plugins/tests/fixtures/chunk-loader/entry.js new file mode 100644 index 00000000..12a555d2 --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/chunk-loader/entry.js @@ -0,0 +1 @@ +export const load = () => import("./lazy.js"); diff --git a/src/cli/bundler/plugins/tests/fixtures/chunk-loader/lazy.js b/src/cli/bundler/plugins/tests/fixtures/chunk-loader/lazy.js new file mode 100644 index 00000000..24e54566 --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/chunk-loader/lazy.js @@ -0,0 +1 @@ +export const value = "lazy"; diff --git a/src/cli/bundler/plugins/tests/fixtures/chunk-loader/unselected.js b/src/cli/bundler/plugins/tests/fixtures/chunk-loader/unselected.js new file mode 100644 index 00000000..4775ee3d --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/chunk-loader/unselected.js @@ -0,0 +1 @@ +export const load = () => import("./unselected.lazy.js"); diff --git a/src/cli/bundler/plugins/tests/fixtures/chunk-loader/unselected.lazy.js b/src/cli/bundler/plugins/tests/fixtures/chunk-loader/unselected.lazy.js new file mode 100644 index 00000000..b62bc73e --- /dev/null +++ b/src/cli/bundler/plugins/tests/fixtures/chunk-loader/unselected.lazy.js @@ -0,0 +1 @@ +export const value = "view-lazy"; diff --git a/src/cli/bundler/utils/output.test.ts b/src/cli/bundler/utils/output.test.ts new file mode 100644 index 00000000..100e3651 --- /dev/null +++ b/src/cli/bundler/utils/output.test.ts @@ -0,0 +1,26 @@ +import type {AssetInfo, PathData} from "@rspack/core"; + +import {appFilenameResolver} from "./output"; + +describe("appFilenameResolver", () => { + test("preserves string templates while resolving the app and directory", () => { + expect(appFilenameResolver("Build Assets", "[name].[app].[fullhash:8].js", "custom/js")).toBe( + "custom/js/[name].build-assets.[fullhash:8].js" + ); + }); + + test("preserves user callbacks and resolves their returned template", () => { + const pathData = {id: "entry"} as PathData; + const assetInfo = {} as AssetInfo; + const filename = jest.fn((_pathData: PathData, _assetInfo?: AssetInfo) => { + return "[name].[app].[contenthash:8].js"; + }); + const resolver = appFilenameResolver("Build Assets", filename, "custom/js"); + + expect(typeof resolver).toBe("function"); + expect((resolver as typeof filename)(pathData, assetInfo)).toBe( + "custom/js/[name].build-assets.[contenthash:8].js" + ); + expect(filename).toHaveBeenCalledWith(pathData, assetInfo); + }); +}); diff --git a/src/cli/bundler/utils/output.ts b/src/cli/bundler/utils/output.ts index bc35ad1c..cc88d50d 100644 --- a/src/cli/bundler/utils/output.ts +++ b/src/cli/bundler/utils/output.ts @@ -1,20 +1,199 @@ -import {Filename} from "@rspack/core"; +import type {AssetInfo, Chunk, Compilation, Filename, Module} from "@rspack/core"; import _ from "lodash"; import path from "path"; +import {toPosix} from "@cli/utils/path"; + +import type {EntrypointAssetsMap, EntrypointAssetsMapEntry} from "@typing/entrypoint"; + +type AssetKind = "asset" | "css" | "js"; + +const compilationBuildAssets = new WeakMap(); + // prettier-ignore export const appFilenameResolver = - (app: string, filename: Filename, dirname?: string): Extract => - (pathData, assetInfo): string => { - app = _.kebabCase(app); - - let name = _.isFunction(filename) ? filename(pathData, assetInfo) : filename; + (app: string, filename: Filename, dirname?: string): Filename => { + app = _.kebabCase(app); + const resolve = (name: string): string => { name = name.replaceAll("[app]", app); - if (dirname) { - return path.posix.join(dirname, name); + return dirname ? path.posix.join(dirname, name) : name; + }; + + if (!_.isFunction(filename)) { + return resolve(filename); + } + + return (pathData, assetInfo): string => { + return resolve(filename(pathData, assetInfo)); + }; + }; + +const addRelatedSourceMaps = (sourceMaps: Set, related: AssetInfo["related"]): void => { + const sourceMap = related?.sourceMap; + + if (Array.isArray(sourceMap)) { + sourceMap.forEach(file => sourceMaps.add(toPosix(file))); + } else if (sourceMap) { + sourceMaps.add(toPosix(sourceMap)); + } +}; + +const getRelatedSourceMaps = (compilation: Compilation): ReadonlySet => { + const sourceMaps = new Set(); + + compilation.getAssets().forEach(({info}) => addRelatedSourceMaps(sourceMaps, info.related)); + + return sourceMaps; +}; + +const isIgnoredAsset = (file: string, info: AssetInfo, sourceMaps: ReadonlySet): boolean => { + return Boolean(info.development || info.hotModuleReplacement || sourceMaps.has(file)); +}; + +const classifyAsset = (file: string, info: AssetInfo, fromModuleAssets: boolean): AssetKind => { + if (info.assetType === "javascript") { + return "js"; + } + + if (info.assetType === "css" || info.assetType === "extract-css") { + return "css"; + } + + if (info.assetType || info.sourceFilename || fromModuleAssets) { + return "asset"; + } + + if (file.endsWith(".js") || file.endsWith(".mjs") || file.endsWith(".cjs")) { + return "js"; + } + + if (file.endsWith(".css")) { + return "css"; + } + + return "asset"; +}; + +const getAssetKind = ( + compilation: Compilation, + file: string, + sourceMaps: ReadonlySet, + fromModuleAssets = false +): AssetKind | undefined => { + file = toPosix(file); + + const asset = compilation.getAsset(file); + + if (!asset || isIgnoredAsset(file, asset.info, sourceMaps)) { + return; + } + + return classifyAsset(file, asset.info, fromModuleAssets); +}; + +const getModuleAssetNames = (module: Module): readonly string[] => { + return Object.keys(module.buildInfo?.assets ?? {}); +}; + +const collectResources = ( + compilation: Compilation, + chunks: Iterable, + sourceMaps: ReadonlySet +): readonly string[] => { + const candidates = new Map(); + + for (const chunk of chunks) { + for (const file of [...chunk.files, ...chunk.auxiliaryFiles]) { + const normalized = toPosix(file); + + candidates.set(normalized, candidates.get(normalized) ?? false); + } + + for (const module of compilation.chunkGraph.getChunkModulesIterable(chunk)) { + for (const file of getModuleAssetNames(module)) { + candidates.set(toPosix(file), true); } + } + } + + return Array.from(candidates) + .filter(([file, fromModuleAssets]) => { + return getAssetKind(compilation, file, sourceMaps, fromModuleAssets) === "asset"; + }) + .map(([file]) => file) + .toSorted(); +}; + +const collectCodeFiles = ( + compilation: Compilation, + files: Iterable, + sourceMaps: ReadonlySet +): {css: readonly string[]; js: readonly string[]} => { + const css = new Set(); + const js = new Set(); + + for (const rawFile of files) { + const file = toPosix(rawFile); + const kind = getAssetKind(compilation, file, sourceMaps); + + if (kind === "js") { + js.add(file); + } else if (kind === "css") { + css.add(file); + } + } + + return {js: Array.from(js), css: Array.from(css)}; +}; - return name; +const collectAsyncCodeFiles = ( + compilation: Compilation, + chunks: Iterable, + sourceMaps: ReadonlySet, + initialFiles: ReadonlySet +): {css: readonly string[]; js: readonly string[]} => { + const files = new Set(); + + for (const chunk of chunks) { + for (const file of [...chunk.files, ...chunk.auxiliaryFiles]) { + const normalized = toPosix(file); + + if (!initialFiles.has(normalized)) { + files.add(normalized); + } + } + } + + return collectCodeFiles(compilation, files, sourceMaps); +}; + +export const collectBuildAssets = (compilation: Compilation): EntrypointAssetsMap => { + const sourceMaps = getRelatedSourceMaps(compilation); + const assets = Object.create(null) as Record; + + const entrypoints = Array.from(compilation.entrypoints).toSorted(([left], [right]) => left.localeCompare(right)); + + for (const [name, entrypoint] of entrypoints) { + const initial = collectCodeFiles(compilation, entrypoint.getFiles(), sourceMaps); + const initialFiles = new Set([...initial.js, ...initial.css]); + const entryChunk = entrypoint.getEntrypointChunk(); + + assets[name] = { + initial, + async: collectAsyncCodeFiles(compilation, entryChunk.getAllAsyncChunks(), sourceMaps, initialFiles), + assets: collectResources(compilation, entryChunk.getAllReferencedChunks(), sourceMaps), }; + } + + return assets; +}; + +export const setCompilationBuildAssets = (compilation: Compilation, assets: EntrypointAssetsMap): void => { + compilationBuildAssets.set(compilation, assets); +}; + +export const getCompilationBuildAssets = (compilation: Compilation): EntrypointAssetsMap | undefined => { + return compilationBuildAssets.get(compilation); +}; diff --git a/src/cli/entrypoint/file/injectors/core.ts b/src/cli/entrypoint/file/injectors/core.ts index 15e664f5..b87462ab 100644 --- a/src/cli/entrypoint/file/injectors/core.ts +++ b/src/cli/entrypoint/file/injectors/core.ts @@ -1,7 +1,7 @@ import {Command, Mode, PackageName} from "@typing/app"; import {Browser} from "@typing/browser"; import {RelayAllFrames, RelayMethod} from "@typing/relay"; -import {ContentScriptAppend, ContentScriptDeclarative, ContentScriptMarker} from "@typing/content"; +import {ContentScriptAppend, ContentScriptDeclarative, ContentScriptMarker, ContentScriptWorld} from "@typing/content"; import {OffscreenReason} from "@typing/offscreen"; import {SandboxAllow, SandboxSource} from "@typing/sandbox"; import {CspSource} from "@typing/csp"; @@ -65,6 +65,15 @@ export default (): Injector[] => { }); }); + Object.entries(ContentScriptWorld).forEach(([key, value]) => { + resolvers.push({ + from: PackageName, + target: "ContentScriptWorld", + name: key, + value, + }); + }); + Object.entries(CspSource).forEach(([key, value]) => { resolvers.push({ from: PackageName, diff --git a/src/cli/entrypoint/parser/ContentParser.test.ts b/src/cli/entrypoint/parser/ContentParser.test.ts new file mode 100644 index 00000000..1366a034 --- /dev/null +++ b/src/cli/entrypoint/parser/ContentParser.test.ts @@ -0,0 +1,30 @@ +import path from "path"; + +import ContentParser from "./ContentParser"; + +import type {ReadonlyConfig} from "@typing/config"; +import {ContentScriptWorld} from "@typing/content"; + +const rootDir = path.resolve(__dirname, "../../../.."); +const fixtures = path.resolve(__dirname, "tests", "fixtures", "content"); + +const parser = new ContentParser({rootDir} as ReadonlyConfig); + +const file = (name: string) => { + const filename = path.join(fixtures, "options", "world", name); + + return {file: filename, import: filename}; +}; + +describe("ContentParser", () => { + test.each([ + ["enum.content.ts", ContentScriptWorld.Main], + ["string.content.ts", ContentScriptWorld.Isolated], + ])("parses the execution world from %s", (name, world) => { + expect(parser.options(file(name))).toEqual({ + matches: ["http://*/*", "https://*/*"], + runAt: "document_idle", + world, + }); + }); +}); diff --git a/src/cli/entrypoint/parser/ContentParser.ts b/src/cli/entrypoint/parser/ContentParser.ts index 7c729a66..e82b7fc9 100644 --- a/src/cli/entrypoint/parser/ContentParser.ts +++ b/src/cli/entrypoint/parser/ContentParser.ts @@ -7,6 +7,7 @@ import { ContentScriptEntrypointOptions, ContentScriptMarker, ContentScriptMatches, + ContentScriptWorld, } from "@typing/content"; import {EntrypointFile, EntrypointOptions} from "@typing/entrypoint"; @@ -23,7 +24,7 @@ export default class { // prettier-ignore const backgroundPlugin = EntrypointPlugin.from(await background.entry().entries()) + .entryOptions({asyncChunks: false, runtime: false}) .virtual(file => virtualBackgroundModule(file)); // prettier-ignore diff --git a/src/cli/plugins/content/ContentManager.test.ts b/src/cli/plugins/content/ContentManager.test.ts index 482bb527..d49b5bda 100644 --- a/src/cli/plugins/content/ContentManager.test.ts +++ b/src/cli/plugins/content/ContentManager.test.ts @@ -3,18 +3,23 @@ import ContentManager from "./ContentManager"; import type {ContentDriver, ContentItems, ContentProvider} from "./types"; import type {ReadonlyConfig} from "@typing/config"; -import type {ContentScriptEntrypointOptions} from "@typing/content"; +import {ContentScriptWorld, 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 + private readonly optional: ManifestOptionalPermissions, + private contentItems: ContentItems = new Map() ) {} + public setItems(contentItems: ContentItems): void { + this.contentItems = contentItems; + } + public async items(): Promise> { - return new Map(); + return this.contentItems; } public async permissions(): Promise { @@ -66,3 +71,114 @@ describe("ContentManager permissions", () => { await expect(manager.optionalPermissions()).resolves.toEqual(new Set(["activeTab"])); }); }); + +describe("ContentManager execution worlds", () => { + test("normalizes MV2 worlds before grouping and warns without changing provider options", async () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + const main = {file: "main.content.ts", import: "./main.content"}; + const isolated = {file: "isolated.content.ts", import: "./isolated.content"}; + const mainOptions = {world: ContentScriptWorld.Main, matches: ["https://example.com/*"]}; + const items: ContentItems = new Map([ + ["main", {file: main, options: mainOptions}], + ["isolated", {file: isolated, options: {matches: mainOptions.matches}}], + ]); + const manager = new ContentManager({ + manifestVersion: 2, + concatContentScripts: true, + rootDir: process.cwd(), + } as ReadonlyConfig).provider(new ProviderFixture(new DriverFixture(new Set(), new Set(), items))); + + try { + await expect(manager.entries()).resolves.toEqual(new Map([["main.content", new Set([main, isolated])]])); + await expect(manager.entryWorlds()).resolves.toEqual( + new Map([["main.content", ContentScriptWorld.Isolated]]) + ); + expect(Array.from(await manager.manifest())).toEqual([ + expect.objectContaining({entry: "main.content", world: ContentScriptWorld.Isolated}), + ]); + expect(mainOptions.world).toBe(ContentScriptWorld.Main); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + 'Content script "main.content.ts" requests world "MAIN", but Addon Bone does not support MAIN content scripts in Manifest V2. It will be built and run in ISOLATED.' + ); + } finally { + warn.mockRestore(); + } + }); + + test("refreshes MV2 effective worlds and warnings after clearing for a watch rebuild", async () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + const file = {file: "changing.content.ts", import: "./changing.content"}; + const driver = new DriverFixture(new Set(), new Set(), new Map([["changing", {file, options: {}}]])); + const manager = new ContentManager({ + manifestVersion: 2, + concatContentScripts: false, + rootDir: process.cwd(), + } as ReadonlyConfig).provider(new ProviderFixture(driver)); + + try { + await expect(manager.entryWorlds()).resolves.toEqual( + new Map([["changing.content", ContentScriptWorld.Isolated]]) + ); + expect(warn).not.toHaveBeenCalled(); + + driver.setItems(new Map([["changing", {file, options: {world: ContentScriptWorld.Main}}]])); + manager.clear(); + + await expect(manager.entryWorlds()).resolves.toEqual( + new Map([["changing.content", ContentScriptWorld.Isolated]]) + ); + await manager.entries(); + await manager.manifest(); + expect(warn).toHaveBeenCalledTimes(1); + + driver.setItems(new Map([["changing", {file, options: {world: ContentScriptWorld.Isolated}}]])); + manager.clear(); + await manager.entryWorlds(); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + } + }); + + test("treats an omitted world as ISOLATED and keeps MAIN entries separate", async () => { + const isolated = {file: "isolated.content.ts", import: "./isolated.content"}; + const main = {file: "main.content.ts", import: "./main.content"}; + const reservedMain = {file: "common-main.content.ts", import: "./common-main.content"}; + const items: ContentItems = new Map([ + ["isolated", {file: isolated, options: {}}], + ["main", {file: main, options: {world: "MAIN"}}], + ["common-main", {file: reservedMain, options: {world: ContentScriptWorld.Main}}], + ]); + const manager = new ContentManager({ + concatContentScripts: false, + rootDir: process.cwd(), + } as ReadonlyConfig).provider(new ProviderFixture(new DriverFixture(new Set(), new Set(), items))); + + await expect(manager.entryWorlds()).resolves.toEqual( + new Map([ + ["isolated.content", ContentScriptWorld.Isolated], + ["main.content", ContentScriptWorld.Main], + ["common-main1.content", ContentScriptWorld.Main], + ]) + ); + }); + + test("recalculates concatenated entrypoint names after clear", async () => { + const options = {matches: ["https://example.com/*"]}; + const first = {file: "first.content.ts", import: "./first.content"}; + const renamed = {file: "renamed.content.ts", import: "./renamed.content"}; + const driver = new DriverFixture(new Set(), new Set(), new Map([["first", {file: first, options}]])); + const manager = new ContentManager({ + concatContentScripts: true, + rootDir: process.cwd(), + } as ReadonlyConfig).provider(new ProviderFixture(driver)); + + await expect(manager.entries()).resolves.toEqual(new Map([["first.content", new Set([first])]])); + + driver.setItems(new Map([["renamed", {file: renamed, options}]])); + manager.clear(); + + await expect(manager.entries()).resolves.toEqual(new Map([["renamed.content", new Set([renamed])]])); + }); +}); diff --git a/src/cli/plugins/content/ContentManager.ts b/src/cli/plugins/content/ContentManager.ts index 9fac5de6..7a17dece 100644 --- a/src/cli/plugins/content/ContentManager.ts +++ b/src/cli/plugins/content/ContentManager.ts @@ -1,11 +1,16 @@ import ContentName from "./ContentName"; import {ContentGroupItems, ContentProvider} from "./types"; -import {getContentScriptConfigFromOptions} from "./utils"; +import {getContentChunkName, getContentScriptConfigFromOptions} from "./utils"; import {ReadonlyConfig} from "@typing/config"; -import {ContentScriptDeclarative, ContentScriptEntrypointOptions} from "@typing/content"; -import {EntrypointEntries, EntrypointFile, EntrypointType} from "@typing/entrypoint"; +import { + ContentScriptDeclarative, + ContentScriptEntrypointOptions, + ContentScriptWorld, + type ContentScriptWorldValue, +} from "@typing/content"; +import {EntrypointEntries, EntrypointFile} from "@typing/entrypoint"; import { ManifestContentScripts, ManifestHostPermissions, @@ -13,7 +18,7 @@ import { ManifestPermissions, } from "@typing/manifest"; -export default class { +export default class ContentManager { protected readonly providers = new Set>(); protected readonly names: ContentName; @@ -24,8 +29,10 @@ export default class { protected _permissions?: Promise<[ManifestPermissions, ManifestOptionalPermissions]>; - constructor(config: ReadonlyConfig) { + constructor(protected readonly config: ReadonlyConfig) { this.names = new ContentName(config); + + Object.values(ContentScriptWorld).forEach(world => this.names.reserve(getContentChunkName(world))); } public provider(provider: ContentProvider): this { @@ -44,15 +51,35 @@ export default class { for (const items of content) { for (const [name, item] of items) { - const entry = this.names.create(name, item.options); + const options = this.normalizeOptions(item.file, item.options); + const entry = this.names.create(name, options); - group.set(entry, new Set([...(group.get(entry) || []), item])); + group.set(entry, new Set([...(group.get(entry) || []), {...item, options}])); } } return group; } + protected normalizeOptions( + file: EntrypointFile, + options: ContentScriptEntrypointOptions + ): ContentScriptEntrypointOptions { + const world = this.resolveWorld(options.world); + + if (this.config.manifestVersion !== 2) { + return options; + } + + if (world === ContentScriptWorld.Main) { + console.warn( + `Content script "${file.file}" requests world "MAIN", but Addon Bone does not support MAIN content scripts in Manifest V2. It will be built and run in ISOLATED.` + ); + } + + return {...options, world: ContentScriptWorld.Isolated}; + } + public async group(): Promise> { return (this._group ??= await this.getGroup()); } @@ -186,18 +213,33 @@ export default class { return (await this.group()).size === 0; } - public chunkName(): string { - return this.names.getChunkName(); - } + public async entryWorlds(): Promise> { + const entries = new Map(); - public likely(name?: string): boolean { - if (!name) { - return false; + for (const [entry, items] of await this.group()) { + const worlds = new Set(Array.from(items, ({options}) => this.resolveWorld(options.world))); + const world = worlds.values().next().value; + + if (worlds.size !== 1 || !world) { + throw new Error(`Content entrypoint "${entry}" cannot mix execution worlds`); + } + + entries.set(entry, world); } - return [EntrypointType.Relay, EntrypointType.ContentScript].some( - type => name === type || name.endsWith(`.${type}`) - ); + return entries; + } + + protected resolveWorld(world?: ContentScriptWorldValue): ContentScriptWorld { + switch (world) { + case undefined: + case ContentScriptWorld.Isolated: + return ContentScriptWorld.Isolated; + case ContentScriptWorld.Main: + return ContentScriptWorld.Main; + default: + throw new Error(`Unsupported content script execution world "${String(world)}"`); + } } public clear(): this { diff --git a/src/cli/plugins/content/ContentName.ts b/src/cli/plugins/content/ContentName.ts index 68f95c4e..06aec37c 100644 --- a/src/cli/plugins/content/ContentName.ts +++ b/src/cli/plugins/content/ContentName.ts @@ -14,19 +14,11 @@ export default class ContentName extends NameGenerator implements ContentNameGen constructor(protected readonly config: ReadonlyConfig) { super(EntrypointType.ContentScript); - - this.reserve(this.getChunkName()); - } - - public getChunkName(): string { - return "common." + this.entrypoint; } public create(name: string, options: ContentScriptEntrypointOptions): string { - const entry = this.name(name); - if (!this.config.concatContentScripts) { - return entry; + return this.name(name); } const key = stringify(getContentScriptConfigFromOptions(options)); @@ -37,8 +29,16 @@ export default class ContentName extends NameGenerator implements ContentNameGen return existingEntry; } + const entry = this.name(name); + this._names.set(key, entry); return entry; } + + public reset(): this { + this._names.clear(); + + return super.reset(); + } } diff --git a/src/cli/plugins/content/index.ts b/src/cli/plugins/content/index.ts index e58cc26e..6048f2a3 100644 --- a/src/cli/plugins/content/index.ts +++ b/src/cli/plugins/content/index.ts @@ -1,17 +1,18 @@ -import {Configuration as RspackConfig, DefinePlugin, NormalModule} from "@rspack/core"; +import {Chunk, Configuration as RspackConfig, DefinePlugin, NormalModule} from "@rspack/core"; import {merge as mergeConfig} from "webpack-merge"; import ContentManager from "./ContentManager"; import Content from "./Content"; import Relay from "./Relay"; import RelayDeclaration from "./RelayDeclaration"; +import {getContentChunkName, getContentLayer} from "./utils"; import {definePlugin} from "@main/plugin"; -import {EntrypointPlugin, onlyViaTopLevelEntry} from "@cli/bundler"; -import {getResolvePath, getSourcePath} from "@cli/resolvers/path"; +import {ChunkLoaderPlugin, EntrypointPlugin, onlyViaTopLevelEntry} from "@cli/bundler"; import {Command} from "@typing/app"; +import {ContentScriptWorld} from "@typing/content"; import {RelayOptions} from "@typing/relay"; export default definePlugin(() => { @@ -48,9 +49,32 @@ export default definePlugin(() => { } else { options = await relay.getOptionsMap(); + const entries = await manager.entries(); + let entryWorlds = await manager.entryWorlds(); + const getEntryWorld = (name: string): ContentScriptWorld => { + const world = entryWorlds.get(name); + + if (!world) { + throw new Error(`Execution world for content entrypoint "${name}" is unavailable`); + } + + return world; + }; + // prettier-ignore - const plugin = EntrypointPlugin.from(await manager.entries()) - .virtual(file => manager.virtual(file)); + const plugin = EntrypointPlugin.from(entries) + .virtual(file => manager.virtual(file)) + .entryOptions(name => { + const world = getEntryWorld(name); + + return { + // MAIN cannot resolve an installed extension URL without crossing contexts. + // ISOLATED keeps physical async chunks and resolves them through the extension API. + asyncChunks: world === ContentScriptWorld.Isolated, + layer: getContentLayer(world), + publicPath: "", + }; + }); if (config.command === Command.Watch) { plugin.watch(async () => { @@ -58,43 +82,54 @@ export default definePlugin(() => { relayDeclaration.dictionary(await relay.dictionary()).build(); - return manager.entries(); + const entries = await manager.entries(); + entryWorlds = await manager.entryWorlds(); + + return entries; }); } const entryTypeFilter = onlyViaTopLevelEntry(["content", "relay"]); - rspack = { - plugins: [plugin], - optimization: { - splitChunks: { - cacheGroups: { - adnbnContent: { - minChunks: 2, - name: manager.chunkName(), - test: (module, context) => { - const nm = module as NormalModule; - - if (!nm.resource) { - return false; - } - - if (nm.resource.startsWith(getResolvePath(getSourcePath(config)))) { - return false; - } - - return entryTypeFilter(module, context); - }, - chunks: (chunk): boolean => { - return manager.likely(chunk.name); - }, - enforce: false, - reuseExistingChunk: true, - priority: 20, - }, - }, - }, + const contentCacheGroup = (world: ContentScriptWorld) => ({ + minChunks: 2, + name: getContentChunkName(world), + test: ( + module: Parameters[0], + context: Parameters[1] + ) => { + const normalModule = module as NormalModule; + + if (!normalModule.resource) { + return false; + } + + return entryTypeFilter(module, context); }, + chunks: (chunk: Chunk): boolean => + chunk.name !== undefined && entryWorlds.get(chunk.name) === world, + enforce: false, + reuseExistingChunk: false, + priority: 20, + }); + + rspack = { + plugins: [ + plugin, + new ChunkLoaderPlugin({ + test: entry => entryWorlds.get(entry) === ContentScriptWorld.Isolated, + }), + ], + optimization: config.commonChunks + ? { + splitChunks: { + cacheGroups: { + adnbnContentIsolated: contentCacheGroup(ContentScriptWorld.Isolated), + adnbnContentMain: contentCacheGroup(ContentScriptWorld.Main), + }, + }, + } + : undefined, }; } diff --git a/src/cli/plugins/content/utils.ts b/src/cli/plugins/content/utils.ts index 6eba1c6f..ae46f9c0 100644 --- a/src/cli/plugins/content/utils.ts +++ b/src/cli/plugins/content/utils.ts @@ -1,6 +1,7 @@ import _ from "lodash"; -import {ContentScriptConfig, ContentScriptEntrypointOptions} from "@typing/content"; +import {ContentScriptConfig, ContentScriptEntrypointOptions, ContentScriptWorld} from "@typing/content"; +import {EntrypointType} from "@typing/entrypoint"; export const getContentScriptConfigFromOptions = (options: ContentScriptEntrypointOptions): ContentScriptConfig => { const config = _.pick(options, [ @@ -25,3 +26,11 @@ export const getContentScriptConfigFromOptions = (options: ContentScriptEntrypoi excludeGlobs: sort(config.excludeGlobs), }; }; + +export const getContentChunkName = (world: ContentScriptWorld): string => { + return `${world === ContentScriptWorld.Main ? "common-main" : "common"}.${EntrypointType.ContentScript}`; +}; + +export const getContentLayer = (world: ContentScriptWorld): string => { + return `adnbn:content:${world.toLowerCase()}`; +}; diff --git a/src/cli/plugins/output.ts b/src/cli/plugins/output.ts index 0d9677a6..493fca4a 100644 --- a/src/cli/plugins/output.ts +++ b/src/cli/plugins/output.ts @@ -3,21 +3,36 @@ import {Configuration as RspackConfig} from "@rspack/core"; import {definePlugin} from "@main/plugin"; -import {appFilenameResolver} from "@cli/bundler"; +import {appFilenameResolver, BuildAssetsMapPlugin} from "@cli/bundler"; import {getOutputPath, getResolvePath} from "@cli/resolvers/path"; import {Command} from "@typing/app"; +import {BackgroundEntryName} from "@typing/background"; + +const serializeFilename = (filename: unknown): string => { + return typeof filename === "function" ? filename.toString() : String(filename ?? ""); +}; export default definePlugin(() => { return { name: "adnbn:output", bundler: ({config}) => { - const {app, jsDir, jsFilename} = config; + const {app, assetsDir, assetsFilename, cssDir, cssFilename, jsDir, jsFilename} = config; const kebabApp = _.kebabCase(app); const camelApp = _.camelCase(app); const filename = appFilenameResolver(app, jsFilename, jsDir); + const cssOutputFilename = appFilenameResolver(app, cssFilename, cssDir); + const buildHashSalt = JSON.stringify({ + app, + assetsDir, + assetsFilename: serializeFilename(assetsFilename), + cssDir, + cssFilename: serializeFilename(cssFilename), + jsDir, + jsFilename: serializeFilename(jsFilename), + }); return { output: { @@ -30,6 +45,14 @@ export default definePlugin(() => { uniqueName: kebabApp, clean: config.command === Command.Build, }, + plugins: [ + new BuildAssetsMapPlugin({ + buildHashSalt, + cssChunkFilename: cssOutputFilename, + cssFilename: cssOutputFilename, + fullMapEntrypoint: BackgroundEntryName, + }), + ], } satisfies RspackConfig; }, }; diff --git a/src/entry/content/core/resolvers/watch.test.ts b/src/entry/content/core/resolvers/watch.test.ts new file mode 100644 index 00000000..55d564a6 --- /dev/null +++ b/src/entry/content/core/resolvers/watch.test.ts @@ -0,0 +1,24 @@ +import {contentScriptMutationObserverResolver} from "./watch"; + +describe("contentScriptMutationObserverResolver", () => { + test("observes the document element before body exists", () => { + const body = document.body; + const observe = jest.spyOn(MutationObserver.prototype, "observe"); + + body.remove(); + + try { + const unwatch = contentScriptMutationObserverResolver()(jest.fn(), {} as never); + + expect(observe).toHaveBeenCalledWith( + document.documentElement, + expect.objectContaining({childList: true, subtree: true}) + ); + + unwatch(); + } finally { + document.documentElement.append(body); + observe.mockRestore(); + } + }); +}); diff --git a/src/entry/content/core/resolvers/watch.ts b/src/entry/content/core/resolvers/watch.ts index abdda7a2..b05ea45a 100644 --- a/src/entry/content/core/resolvers/watch.ts +++ b/src/entry/content/core/resolvers/watch.ts @@ -14,7 +14,7 @@ export const contentScriptMutationObserverResolver = const observer = new MutationObserver(handle); - observer.observe(document.body, { + observer.observe(document.body ?? document.documentElement ?? document, { childList: true, subtree: true, attributes: true, diff --git a/src/main/entrypoint.test.ts b/src/main/entrypoint.test.ts new file mode 100644 index 00000000..561d4891 --- /dev/null +++ b/src/main/entrypoint.test.ts @@ -0,0 +1,73 @@ +import {getEntrypointAssetsMap, getEntrypointAssets} from "./entrypoint"; + +import type {EntrypointAssetsMap, EntrypointAssets} from "@typing/entrypoint"; + +interface BuildAssetsGlobal { + __webpack_require__?: ((moduleId: string | number) => unknown) & { + __adnbnBuildAssets?: EntrypointAssetsMap; + __adnbnCurrentEntrypointAssets?: EntrypointAssets; + }; +} + +describe("getEntrypointAssetsMap", () => { + const target = globalThis as BuildAssetsGlobal; + const descriptor = Object.getOwnPropertyDescriptor(target, "__webpack_require__"); + + afterEach(() => { + if (descriptor) { + Object.defineProperty(target, "__webpack_require__", descriptor); + } else { + Reflect.deleteProperty(target, "__webpack_require__"); + } + }); + + test("returns the map owned by the current bundle runtime", () => { + const assets: EntrypointAssetsMap = { + content: { + initial: {js: ["content.js"], css: ["content.css"]}, + async: {js: ["lazy.js"], css: ["lazy.css"]}, + assets: ["image.png"], + }, + }; + + const runtime = () => undefined; + runtime.__adnbnBuildAssets = assets; + target.__webpack_require__ = runtime; + + expect(getEntrypointAssetsMap()).toBe(assets); + }); + + test("returns the current entrypoint assets from its own bundle runtime", () => { + const assets: EntrypointAssets = { + initial: {js: ["content.js"], css: ["content.css"]}, + async: {js: ["lazy.js"], css: ["lazy.css"]}, + }; + + const runtime = () => undefined; + runtime.__adnbnCurrentEntrypointAssets = assets; + target.__webpack_require__ = runtime; + + expect(getEntrypointAssets()).toBe(assets); + }); + + test("fails clearly when a local runtime calls the background-only getter", () => { + const runtime = () => undefined; + runtime.__adnbnCurrentEntrypointAssets = { + initial: {js: ["content.js"], css: []}, + async: {js: [], css: []}, + }; + target.__webpack_require__ = runtime; + + expect(() => getEntrypointAssetsMap()).toThrow( + "getEntrypointAssetsMap() is available only in the background entrypoint" + ); + }); + + test("fails clearly when the current-entrypoint map is unavailable", () => { + const runtime = () => undefined; + runtime.__adnbnBuildAssets = {}; + target.__webpack_require__ = runtime; + + expect(() => getEntrypointAssets()).toThrow("Current entrypoint assets are unavailable in this runtime"); + }); +}); diff --git a/src/main/entrypoint.ts b/src/main/entrypoint.ts new file mode 100644 index 00000000..8ec85886 --- /dev/null +++ b/src/main/entrypoint.ts @@ -0,0 +1,37 @@ +import type {EntrypointAssets, EntrypointAssetsMap} from "@typing/entrypoint"; + +interface BuildAssetsRuntime { + (moduleId: string | number): unknown; + __adnbnBuildAssets?: EntrypointAssetsMap; + __adnbnCurrentEntrypointAssets?: EntrypointAssets; +} + +declare const __webpack_require__: BuildAssetsRuntime; + +export type { + EntrypointAssets, + EntrypointAssetsFiles, + EntrypointAssetsMap, + EntrypointAssetsMapEntry, +} from "@typing/entrypoint"; + +export const getEntrypointAssetsMap = (): EntrypointAssetsMap => { + const assets = typeof __webpack_require__ === "function" ? __webpack_require__.__adnbnBuildAssets : undefined; + + if (!assets) { + throw new Error("getEntrypointAssetsMap() is available only in the background entrypoint"); + } + + return assets; +}; + +export const getEntrypointAssets = (): EntrypointAssets => { + const assets = + typeof __webpack_require__ === "function" ? __webpack_require__.__adnbnCurrentEntrypointAssets : undefined; + + if (!assets) { + throw new Error("Current entrypoint assets are unavailable in this runtime"); + } + + return assets; +}; diff --git a/src/main/index.ts b/src/main/index.ts index 3d9cb5b4..c2d909a8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,7 @@ export * from "./command"; export * from "./config"; export * from "./content"; export * from "./csp"; +export * from "./entrypoint"; export * from "./env"; export * from "./icon"; export * from "./manifest"; diff --git a/src/types/content.ts b/src/types/content.ts index ef47248b..1dc9e1e7 100644 --- a/src/types/content.ts +++ b/src/types/content.ts @@ -4,11 +4,17 @@ import {Optional} from "utility-types"; import {EntrypointBuilder, EntrypointOptions} from "@typing/entrypoint"; import {Awaiter, PickNonFunctionProperties} from "@typing/helpers"; -type ExecutionWorld = chrome.scripting.ExecutionWorld; type RunAt = chrome.extensionTypes.RunAt; export const ContentScriptMatches = ["http://*/*", "https://*/*"]; +export enum ContentScriptWorld { + Isolated = "ISOLATED", + Main = "MAIN", +} + +export type ContentScriptWorldValue = ContentScriptWorld | `${chrome.scripting.ExecutionWorld}`; + export enum ContentScriptDeclarative { Required = "required", Optional = "optional", @@ -43,8 +49,13 @@ export interface ContentScriptConfig { runAt?: RunAt; /** * See https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts#isolated_world + * + * Dynamic imports keep physical async chunks in ISOLATED. MAIN keeps the same Promise-based + * execution semantics but includes dynamic dependencies in the initial entrypoint graph. + * Manifest V2 builds always use ISOLATED. Requesting MAIN emits a build warning and applies + * ISOLATED grouping and chunk loading rules before generating the manifest. */ - world?: ExecutionWorld; + world?: ContentScriptWorldValue; /** * See https://developer.chrome.com/docs/extensions/mv3/content_scripts/ * @default false diff --git a/src/types/entrypoint.ts b/src/types/entrypoint.ts index 890ec60c..c079b2a5 100644 --- a/src/types/entrypoint.ts +++ b/src/types/entrypoint.ts @@ -88,6 +88,22 @@ export interface EntrypointFile { */ export type EntrypointEntries = Map>; +export interface EntrypointAssetsFiles { + readonly js: readonly string[]; + readonly css: readonly string[]; +} + +export interface EntrypointAssets { + readonly initial: EntrypointAssetsFiles; + readonly async: EntrypointAssetsFiles; +} + +export interface EntrypointAssetsMapEntry extends EntrypointAssets { + readonly assets: readonly string[]; +} + +export type EntrypointAssetsMap = Readonly>; + export interface EntrypointParser { options(file: EntrypointFile): O; diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 00000000..08dfd2bd --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,94 @@ +# Integration tests + +These tests build local Addon Bone applications through the public CLI. Build checks live in `build`; tests that launch Chrome or Firefox live in `browser`. Each area keeps its test beside the application scenarios it uses. + +## Layout + +```text +tests/integration/ +├── build/options/ +│ ├── options.integration.test.ts +│ └── embedded/ +├── browser/ +│ ├── content/ +│ │ ├── entrypoint-assets.integration.test.ts +│ │ ├── entrypoint-assets.firefox.integration.test.ts +│ │ ├── utils.ts +│ │ └── entrypoint-assets/ +│ ├── offscreen/ +│ │ ├── service.integration.test.ts +│ │ └── service/ +│ ├── options/ +│ │ ├── options.integration.test.ts +│ │ ├── react/ +│ │ └── vanilla/ +│ └── utils/ +│ ├── BidiClient.ts +│ ├── browser.ts +│ ├── chrome.ts +│ ├── firefox.ts +│ └── site.ts +├── utils/ +│ ├── fixture.ts +│ └── process.ts +├── prepare.ts +└── typecheck.ts +``` + +Use kebab-case for directory names and for filenames containing multiple words, including helpers and scenario tests. Files whose primary export is a class or React component use PascalCase matching that export. Tests for a specific class also preserve its name, for example `ContentManager.test.ts`. Keep framework entrypoint suffixes such as `.content.ts` and `.page.ts`. Put an entrypoint or component with its own styles in one directory; standalone entrypoints can remain single files. + +Each application retains its own `package.json`, `adnbn.config.ts`, and `tsconfig.json`. The package boundary prevents the framework package's `sideEffects: false` setting from discarding fixture CSS imports. + +## Prepare the editor environment + +Install dependencies once at the repository root, then run: + +```bash +npm run prepare:integration +``` + +This builds the framework, links each fixture's declared dependencies to the local framework or root `node_modules`, and builds all five applications. The generated `.adnbn` configuration and declarations remain beside their sources, so the editor can resolve `adnbn`, virtual imports such as `adnbn/browser`, CSS/SVG modules, and generated transport contracts. + +No separate install or lockfile is needed in each fixture. Dependency versions come from the root installation. Preparation can be repeated; matching links are reused, while conflicting existing dependency locations produce an error instead of being overwritten. + +The generated `.adnbn`, `node_modules`, and `dist` directories are ignored by Git. Rerun preparation after changing framework APIs or fixture entrypoints and contracts. + +To prepare and typecheck every fixture, including its configuration: + +```bash +npm run typecheck:integration +``` + +The root `typecheck` checks framework and test-runner code; `typecheck:integration` additionally checks the fixture applications against their generated declarations. CI runs both. + +## Run tests + +Run these commands from the repository root: + +```bash +npm run test:integration +npm run test:integration:build +npm run test:chrome +npm run test:firefox +``` + +They run all integration tests, only build checks, only Chrome checks, or only Firefox checks respectively. Each command builds the framework first. Browser tests require Chrome with `Extensions.loadUnpacked` support and Firefox with WebDriver BiDi `webExtension.install` support. Set `ADNBN_CHROME_BIN` or `ADNBN_FIREFOX_BIN` to the browser's absolute executable path if automatic discovery selects the wrong browser. Node must provide the built-in `WebSocket` API. CI installs both browsers on Linux; Windows runs the non-browser suite. + +Tests copy application inputs to unique directories under `.cache/integration`. Prepared dependencies and generated files are excluded from the copy and recreated there. Cleanup removes only the run's copy and temporary Chrome profile; it does not remove the editor environment in the source fixture or change the `addon` playground. + +## Coverage + +- `build/options/embedded`: ten manifest checks covering explicit `openInTab: false` across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. No browser is launched. +- `browser/options`: two Chrome MV3 cases covering Vanilla and React rendering, CSS, state/events, opening Options from background, and a View chunk shared with a Page. +- `browser/offscreen/service`: one Chrome MV3 round trip from background through Offscreen to a registered background service. +- `browser/content/entrypoint-assets`: one Chrome MV3 case and two Firefox cases (MV2 and MV3) using the same application and probe assertions. They cover current asset getters, rejecting the full-map getter outside background, common chunks, dynamic imports, CSS/SVG resource URLs, world separation, and top/child frames. The Chrome case also reads the full-map readiness flag in background. + +In MV3, ISOLATED retains physical async chunks and MAIN includes dynamic dependencies in its initial graph. In Addon Bone's MV2 pipeline, every content entry uses ISOLATED, including entries requesting MAIN. Such requests emit a build warning before entrypoint grouping; the runtime test verifies async JS/CSS and the absence of page-visible globals for the downgraded entries. This is a framework policy, not a claim that all Firefox versions lack native MAIN support in MV2. + +Browser execution covers the installed Chrome in MV3 and Firefox in MV2/MV3. It does not certify older browser versions. Building other browser targets does not verify their runtime behavior. + +## The content test site + +`browser/content/entrypoint-assets/site` is the ordinary website receiving the content scripts, not an extension entrypoint. A local HTTP server serves `top.html`, `frames.html`, and `child.html` with a restrictive CSP: `default-src 'none'; frame-src 'self'; img-src 'self'`. Same-origin image requests allow the browser's automatic favicon request, which receives an empty 204 response; scripts and styles remain restricted by `default-src`. The frame pages exercise `allFrames` and independent execution in the top document and its child iframe. + +Keep these HTML files outside the application's `src` directory. They need no separate package or build tool. The server uses an ephemeral loopback port and stops after the test. diff --git a/tests/integration/browser/content/entrypoint-assets.firefox.integration.test.ts b/tests/integration/browser/content/entrypoint-assets.firefox.integration.test.ts new file mode 100644 index 00000000..836abf25 --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets.firefox.integration.test.ts @@ -0,0 +1,181 @@ +/** @jest-environment node */ + +import {mkdtemp, readFile, rm, stat} from "fs/promises"; +import os from "os"; +import path from "path"; +import {spawn, type ChildProcess} from "child_process"; + +import BidiClient from "../utils/BidiClient"; +import {findFirefoxBinary} from "../utils/firefox"; +import {getFreePort, stop, waitFor} from "../utils/browser"; +import {startIntegrationSite, type IntegrationSite} from "../utils/site"; +import {createIntegrationFixture, type IntegrationFixture} from "../../utils/fixture"; +import {DocumentStateExpression, expectLoadedProbe, type DocumentState} from "./utils"; + +const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); +const fixtureDir = path.join(__dirname, "entrypoint-assets"); + +interface FrameState { + top: DocumentState; + child: DocumentState; +} + +jest.setTimeout(90_000); + +test.each([2, 3] as const)( + "Firefox MV%s loads entrypoint assets in the effective execution world", + async manifestVersion => { + const firefoxBinary = findFirefoxBinary(); + + if (!firefoxBinary || !path.isAbsolute(firefoxBinary)) { + throw new Error( + "Firefox is not installed. Install Firefox or set ADNBN_FIREFOX_BIN to its absolute executable path." + ); + } + + const userDataDir = await mkdtemp(path.join(os.tmpdir(), "adnbn-content-firefox-")); + let firefox: ChildProcess | undefined; + let browser: BidiClient | undefined; + let fixture: IntegrationFixture | undefined; + let site: IntegrationSite | undefined; + let output = ""; + let lastState: DocumentState | FrameState | undefined; + + const expectDocument = (state: DocumentState, frame: string): void => { + expectLoadedProbe(state.isolated, "ISOLATED", frame, true, "moz-extension:"); + // Probe names record the requested world. Page-global visibility verifies the effective world. + expectLoadedProbe(state.main, "MAIN", frame, manifestVersion === 2, "moz-extension:"); + expect(state.isolatedGlobal).toBe(false); + expect(state.mainGlobal).toBe(manifestVersion === 3); + expect(state.isolatedSecondaryRuns).toBe("1"); + expect(state.mainSecondaryRuns).toBe("1"); + }; + + try { + fixture = await createIntegrationFixture(rootDir, fixtureDir); + const extensionDir = await fixture.build({browser: "firefox", manifestVersion}); + const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); + const scripts = manifest.content_scripts as Array<{js: string[]; css?: string[]; world?: string}>; + const resources = + manifestVersion === 2 + ? (manifest.web_accessible_resources as string[]) + : (manifest.web_accessible_resources.flatMap( + (entry: {resources: string[]}) => entry.resources + ) as string[]); + + expect(manifest.manifest_version).toBe(manifestVersion); + expect(scripts).toHaveLength(4); + expect(manifest.background.scripts).toHaveLength(1); + expect(scripts.every(script => !script.js.includes(manifest.background.scripts[0]))).toBe(true); + + if (manifestVersion === 2) { + expect(scripts.every(script => script.world === undefined)).toBe(true); + expect( + manifest.web_accessible_resources.every((resource: unknown) => typeof resource === "string") + ).toBe(true); + const common = scripts[0].js.filter(file => scripts.every(script => script.js.includes(file))); + expect(common).toEqual([expect.stringMatching(/^js\/common\.content\..+\.js$/)]); + } else { + expect(scripts.map(script => script.world).sort()).toEqual(["ISOLATED", "ISOLATED", "MAIN", "MAIN"]); + expect( + manifest.web_accessible_resources.every( + (entry: {resources: string[]; matches: string[]}) => + Array.isArray(entry.resources) && Array.isArray(entry.matches) + ) + ).toBe(true); + } + + expect(resources.some(file => /^js\/.+\.js$/.test(file))).toBe(true); + expect(resources.some(file => /^css\/.+\.css$/.test(file))).toBe(true); + expect(resources.some(file => /^assets\/probe-[a-f0-9]{4}\.svg$/.test(file))).toBe(true); + + for (const file of new Set([ + ...resources, + ...scripts.flatMap(script => [...script.js, ...(script.css ?? [])]), + ])) { + await expect(stat(path.join(extensionDir, file))).resolves.toBeDefined(); + } + + site = await startIntegrationSite(path.join(fixture.directory, "site")); + const port = await getFreePort(); + firefox = spawn( + firefoxBinary, + [ + "--headless", + "--no-remote", + "--profile", + userDataDir, + "--remote-debugging-port", + String(port), + "about:blank", + ], + {stdio: ["ignore", "ignore", "pipe"]} + ); + firefox.stderr?.on("data", chunk => (output += chunk)); + + browser = await waitFor(() => BidiClient.connect(`ws://127.0.0.1:${port}/session`)); + const session = await browser.send("session.new", {capabilities: {alwaysMatch: {}}}); + console.info(`Firefox ${session.capabilities.browserVersion}: testing Manifest V${manifestVersion}`); + await browser.send("session.subscribe", {events: ["log.entryAdded"]}); + const extension = await browser.send("webExtension.install", { + extensionData: {path: extensionDir, type: "path"}, + }); + expect(typeof extension.extension).toBe("string"); + const {context} = await browser.send("browsingContext.create", {type: "tab"}); + + await browser.send("browsingContext.navigate", {context, url: `${site.origin}/top.html`, wait: "complete"}); + const top = await waitFor(async () => { + const state = await browser!.evaluate(context, `${DocumentStateExpression}(document)`); + lastState = state; + const statuses = [state?.isolated?.async, state?.main?.async]; + return statuses.every(status => status !== undefined && status !== "pending") ? state : undefined; + }); + expectDocument(top, "top"); + + await browser.send("browsingContext.navigate", { + context, + url: `${site.origin}/frames.html`, + wait: "complete", + }); + const framed = await waitFor(async () => { + const state = await browser!.evaluate( + context, + `(() => { + const child = document.querySelector('[data-testid="child-frame"]')?.contentDocument; + if (!child || child.readyState !== 'complete') return undefined; + return {top: ${DocumentStateExpression}(document), child: ${DocumentStateExpression}(child)}; + })()` + ); + lastState = state; + const statuses = [ + state?.top?.isolated?.async, + state?.top?.main?.async, + state?.child?.isolated?.async, + state?.child?.main?.async, + ]; + return statuses.every(status => status !== undefined && status !== "pending") ? state : undefined; + }); + expectDocument(framed.top, "top"); + expectDocument(framed.child, "child"); + expect(browser.runtimeErrors).toEqual([]); + } catch (error) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}; last state: ${JSON.stringify(lastState)}; Runtime errors: ${JSON.stringify(browser?.runtimeErrors ?? [])}; Firefox output: ${output}`, + {cause: error} + ); + } finally { + if (browser) { + try { + await browser.send("session.end", {}, 2_000); + } catch { + // Firefox may close the socket before returning the session.end response. + } + await browser.close(); + } + if (firefox) await stop(firefox); + await site?.close(); + await fixture?.dispose(); + await rm(userDataDir, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } + } +); diff --git a/tests/integration/browser/content/entrypoint-assets.integration.test.ts b/tests/integration/browser/content/entrypoint-assets.integration.test.ts new file mode 100644 index 00000000..f5a2ba3c --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets.integration.test.ts @@ -0,0 +1,236 @@ +/** @jest-environment node */ + +import {mkdtemp, readFile, rm, stat} from "fs/promises"; +import os from "os"; +import path from "path"; +import {spawn, type ChildProcess} from "child_process"; + +import {browserVersion, CdpClient, findChromeBinary, targets} from "../utils/chrome"; +import {getFreePort, stop, waitFor} from "../utils/browser"; +import {startIntegrationSite, type IntegrationSite} from "../utils/site"; +import {createIntegrationFixture, type IntegrationFixture} from "../../utils/fixture"; + +import {DocumentStateExpression, expectLoadedProbe, type DocumentState} from "./utils"; + +const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); +const fixtureDir = path.join(__dirname, "entrypoint-assets"); +const chromeBinary = findChromeBinary(rootDir); + +const evaluate = async (browser: CdpClient, sessionId: string, expression: string): Promise => { + const result = await browser.send( + "Runtime.evaluate", + {expression, awaitPromise: true, returnByValue: true}, + sessionId + ); + + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text); + } + + return result.result.value; +}; + +const readDocumentState = async (browser: CdpClient, sessionId: string): Promise => { + return evaluate(browser, sessionId, `${DocumentStateExpression}(document)`); +}; + +const waitForLoadedDocument = async (browser: CdpClient, sessionId: string): Promise => { + let lastState: DocumentState | undefined; + + try { + return await waitFor(async () => { + lastState = await readDocumentState(browser, sessionId); + const statuses = [lastState.isolated?.async, lastState.main?.async]; + + return statuses.every(status => status !== undefined && status !== "pending") ? lastState : undefined; + }); + } catch (error) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}; last document state: ${JSON.stringify(lastState)}`, + {cause: error} + ); + } +}; + +jest.setTimeout(90_000); + +test("Chrome MV3 loads ISOLATED chunks lazily and keeps MAIN dynamic imports in the initial graph", async () => { + if (!chromeBinary || !path.isAbsolute(chromeBinary)) { + throw new Error( + "Chrome is not installed or could not be found. Install Chrome or set ADNBN_CHROME_BIN to its absolute executable path." + ); + } + + const userDataDir = await mkdtemp(path.join(os.tmpdir(), "adnbn-content-entrypoint-assets-")); + const debuggingPort = await getFreePort(); + let chrome: ChildProcess | undefined; + let browser: CdpClient | undefined; + let site: IntegrationSite | undefined; + let fixture: IntegrationFixture | undefined; + let chromeOutput = ""; + + try { + fixture = await createIntegrationFixture(rootDir, fixtureDir); + const extensionDir = await fixture.build(); + + const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); + const contentScripts = manifest.content_scripts as Array<{css?: string[]; js: string[]; world?: string}>; + const backgroundFile = manifest.background.service_worker as string; + const resources = (manifest.web_accessible_resources ?? []).flatMap( + (item: {resources?: string[]}) => item.resources ?? [] + ); + + expect(contentScripts).toHaveLength(4); + expect(contentScripts.map(script => script.world).sort()).toEqual(["ISOLATED", "ISOLATED", "MAIN", "MAIN"]); + expect(contentScripts.every(script => !script.js.includes(backgroundFile))).toBe(true); + + const isolatedScripts = contentScripts.filter(script => script.world === "ISOLATED"); + const mainScripts = contentScripts.filter(script => script.world === "MAIN"); + const sharedFiles = (scripts: Array<{js: string[]}>): string[] => { + return scripts[0].js.filter(file => scripts.slice(1).every(script => script.js.includes(file))); + }; + + expect(sharedFiles(isolatedScripts)).toEqual([expect.stringMatching(/^js\/common\.content\..+\.js$/)]); + expect(sharedFiles(mainScripts)).toEqual([expect.stringMatching(/^js\/common-main\.content\..+\.js$/)]); + + for (const isolated of isolatedScripts) { + for (const main of mainScripts) { + expect(isolated.js.filter(file => main.js.includes(file))).toEqual([]); + } + } + expect(resources.some((file: string) => /^assets\/probe-[a-f0-9]{4}\.svg$/.test(file))).toBe(true); + expect(resources.some((file: string) => /^js\/.+\.js$/.test(file))).toBe(true); + expect(resources.some((file: string) => /^css\/.+\.css$/.test(file))).toBe(true); + + for (const script of contentScripts) { + for (const file of [...script.js, ...(script.css ?? [])]) { + await expect(stat(path.join(extensionDir, file))).resolves.toBeDefined(); + } + } + + site = await startIntegrationSite(path.join(fixture.directory, "site")); + chrome = spawn( + chromeBinary, + [ + "--headless=new", + "--no-sandbox", + "--no-first-run", + "--no-default-browser-check", + "--enable-logging=stderr", + "--v=0", + `--remote-debugging-port=${debuggingPort}`, + `--user-data-dir=${userDataDir}`, + "about:blank", + ], + {stdio: ["ignore", "ignore", "pipe"]} + ); + chrome.stderr?.on("data", chunk => (chromeOutput += chunk)); + + const {webSocketDebuggerUrl} = await waitFor(() => browserVersion(debuggingPort)); + browser = await CdpClient.connect(webSocketDebuggerUrl); + const extension = await browser.send("Extensions.loadUnpacked", {path: extensionDir}); + const extensionId = extension.id as string | undefined; + + if (!extensionId) { + throw new Error("Chrome did not return an extension ID after loading the content fixture"); + } + + const worker = await waitFor(async () => { + return (await targets(debuggingPort)).find( + target => + target.type === "service_worker" && + target.url === `chrome-extension://${extensionId}/${backgroundFile}` + ); + }); + const {sessionId: workerSessionId} = await browser.send("Target.attachToTarget", { + targetId: worker.id, + flatten: true, + }); + + await browser.send("Runtime.enable", {}, workerSessionId); + await waitFor(async () => { + return (await evaluate( + browser!, + workerSessionId, + "globalThis.__adnbnContentBuildAssetsBackgroundReady === true" + )) + ? true + : undefined; + }); + + const {targetId} = await browser.send("Target.createTarget", {url: "about:blank"}); + const {sessionId} = await browser.send("Target.attachToTarget", {targetId, flatten: true}); + + await browser.send("Runtime.enable", {}, sessionId); + await browser.send("Page.enable", {}, sessionId); + await browser.send("Page.navigate", {url: `${site.origin}/top.html`}, sessionId); + + const top = await waitForLoadedDocument(browser, sessionId); + + expectLoadedProbe(top.isolated, "ISOLATED", "top", true); + expectLoadedProbe(top.main, "MAIN", "top", false); + expect(top.isolatedGlobal).toBe(false); + expect(top.mainGlobal).toBe(true); + expect(top.isolatedSecondaryRuns).toBe("1"); + expect(top.mainSecondaryRuns).toBe("1"); + + await browser.send("Page.navigate", {url: `${site.origin}/frames.html`}, sessionId); + + const framed = await waitFor(async () => { + const state = await evaluate( + browser!, + sessionId, + `(() => { + const frame = document.querySelector('[data-testid="child-frame"]'); + const child = frame?.contentDocument; + + if (!child || child.readyState !== 'complete') return undefined; + + return { + top: ${DocumentStateExpression}(document), + child: ${DocumentStateExpression}(child), + }; + })()` + ); + const statuses = [ + state?.top?.isolated?.async, + state?.top?.main?.async, + state?.child?.isolated?.async, + state?.child?.main?.async, + ]; + + return statuses.every(status => status !== undefined && status !== "pending") ? state : undefined; + }); + + expectLoadedProbe(framed.top.isolated, "ISOLATED", "top", true); + expectLoadedProbe(framed.top.main, "MAIN", "top", false); + expectLoadedProbe(framed.child.isolated, "ISOLATED", "child", true); + expectLoadedProbe(framed.child.main, "MAIN", "child", false); + expect(framed.top.isolatedGlobal).toBe(false); + expect(framed.top.mainGlobal).toBe(true); + expect(framed.child.isolatedGlobal).toBe(false); + expect(framed.child.mainGlobal).toBe(true); + expect(framed.top.isolatedSecondaryRuns).toBe("1"); + expect(framed.top.mainSecondaryRuns).toBe("1"); + expect(framed.child.isolatedSecondaryRuns).toBe("1"); + expect(framed.child.mainSecondaryRuns).toBe("1"); + expect(browser.runtimeErrors).toEqual([]); + } catch (error) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}; Runtime errors: ${JSON.stringify( + browser?.runtimeErrors ?? [] + )}; Chrome output: ${chromeOutput}`, + {cause: error} + ); + } finally { + await browser?.close(); + + if (chrome) { + await stop(chrome); + } + + await site?.close(); + await fixture?.dispose(); + await rm(userDataDir, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } +}); diff --git a/tests/integration/browser/content/entrypoint-assets/adnbn.config.ts b/tests/integration/browser/content/entrypoint-assets/adnbn.config.ts new file mode 100644 index 00000000..af3e419f --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets/adnbn.config.ts @@ -0,0 +1,12 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Content Entrypoint Assets Integration", + description: "Content runtime coverage for entrypoint assets and dynamic imports in Chrome and Firefox.", + version: "1.0.0", + specific: {gecko: {id: "entrypoint-assets@adnbn.test"}}, + concatContentScripts: false, + jsFilename: "[name].[chunkhash:8].js", + cssFilename: "[name].[contenthash:8].css", + assetsFilename: "[name]-[contenthash:4][ext]", +}); diff --git a/tests/integration/browser/content/entrypoint-assets/package.json b/tests/integration/browser/content/entrypoint-assets/package.json new file mode 100644 index 00000000..a5a5dbde --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-content-entrypoint-assets", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../.." + } +} diff --git a/tests/integration/browser/content/entrypoint-assets/site/child.html b/tests/integration/browser/content/entrypoint-assets/site/child.html new file mode 100644 index 00000000..59c43b1c --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets/site/child.html @@ -0,0 +1,10 @@ + + + + + Content entrypoint assets: child frame + + +
Child frame fixture
+ + diff --git a/tests/integration/browser/content/entrypoint-assets/site/frames.html b/tests/integration/browser/content/entrypoint-assets/site/frames.html new file mode 100644 index 00000000..bc285b3c --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets/site/frames.html @@ -0,0 +1,11 @@ + + + + + Content entrypoint assets: frames + + +
Top frame fixture
+ + + diff --git a/tests/integration/browser/content/entrypoint-assets/site/top.html b/tests/integration/browser/content/entrypoint-assets/site/top.html new file mode 100644 index 00000000..5a245a47 --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets/site/top.html @@ -0,0 +1,10 @@ + + + + + Content entrypoint assets: top frame + + +
Top frame fixture
+ + diff --git a/tests/integration/browser/content/entrypoint-assets/src/background.ts b/tests/integration/browser/content/entrypoint-assets/src/background.ts new file mode 100644 index 00000000..646cd5cb --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets/src/background.ts @@ -0,0 +1,21 @@ +import {defineBackground, getEntrypointAssetsMap} from "adnbn"; + +declare global { + var __adnbnContentBuildAssetsBackgroundReady: boolean | undefined; +} + +export default defineBackground({ + main() { + const assets = getEntrypointAssetsMap(); + const expectedEntries = [ + "background", + "isolated-secondary.content", + "isolated.content", + "main-secondary.content", + "main.content", + ]; + + globalThis.__adnbnContentBuildAssetsBackgroundReady = + Object.keys(assets).length === expectedEntries.length && expectedEntries.every(entry => entry in assets); + }, +}); diff --git a/tests/integration/browser/content/entrypoint-assets/src/isolated-secondary.content.ts b/tests/integration/browser/content/entrypoint-assets/src/isolated-secondary.content.ts new file mode 100644 index 00000000..003e1831 --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets/src/isolated-secondary.content.ts @@ -0,0 +1,12 @@ +import {defineContentScript} from "adnbn"; + +export default defineContentScript({ + matches: ["http://127.0.0.1/*"], + world: "ISOLATED", + runAt: "document_start", + allFrames: true, + main() { + const root = document.documentElement; + root.dataset.adnbnIsolatedSecondaryRuns = String(Number(root.dataset.adnbnIsolatedSecondaryRuns ?? "0") + 1); + }, +}); diff --git a/tests/integration/browser/content/entrypoint-assets/src/isolated.content.ts b/tests/integration/browser/content/entrypoint-assets/src/isolated.content.ts new file mode 100644 index 00000000..c35e6b47 --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets/src/isolated.content.ts @@ -0,0 +1,19 @@ +import {ContentScriptWorld, defineContentScript} from "adnbn"; + +import {createProbe} from "./probe"; + +declare global { + var __adnbnIsolatedWorldVisibleToPage: boolean | undefined; +} + +export default defineContentScript({ + matches: ["http://127.0.0.1/*"], + world: ContentScriptWorld.Isolated, + runAt: "document_start", + allFrames: true, + render() { + globalThis.__adnbnIsolatedWorldVisibleToPage = true; + + return createProbe("ISOLATED"); + }, +}); diff --git a/tests/integration/browser/content/entrypoint-assets/src/main-secondary.content.ts b/tests/integration/browser/content/entrypoint-assets/src/main-secondary.content.ts new file mode 100644 index 00000000..d1e2a04b --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets/src/main-secondary.content.ts @@ -0,0 +1,12 @@ +import {defineContentScript} from "adnbn"; + +export default defineContentScript({ + matches: ["http://127.0.0.1/*"], + world: "MAIN", + runAt: "document_start", + allFrames: true, + main() { + const root = document.documentElement; + root.dataset.adnbnMainSecondaryRuns = String(Number(root.dataset.adnbnMainSecondaryRuns ?? "0") + 1); + }, +}); diff --git a/tests/integration/browser/content/entrypoint-assets/src/main.content.ts b/tests/integration/browser/content/entrypoint-assets/src/main.content.ts new file mode 100644 index 00000000..aafd1f9e --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets/src/main.content.ts @@ -0,0 +1,19 @@ +import {defineContentScript} from "adnbn"; + +import {createProbe} from "./probe"; + +declare global { + var __adnbnMainWorldVisibleToPage: boolean | undefined; +} + +export default defineContentScript({ + matches: ["http://127.0.0.1/*"], + world: "MAIN", + runAt: "document_start", + allFrames: true, + render() { + globalThis.__adnbnMainWorldVisibleToPage = true; + + return createProbe("MAIN"); + }, +}); diff --git a/tests/integration/browser/content/entrypoint-assets/src/probe/async/index.ts b/tests/integration/browser/content/entrypoint-assets/src/probe/async/index.ts new file mode 100644 index 00000000..32169683 --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets/src/probe/async/index.ts @@ -0,0 +1,9 @@ +import probeAsset from "./probe.svg"; +import styles from "./styles.module.css"; + +export const AsyncProbeValue = "loaded"; + +export const applyAsyncProbe = (root: HTMLElement): void => { + root.classList.add(styles.loaded); + root.dataset.asset = probeAsset; +}; diff --git a/tests/integration/browser/content/entrypoint-assets/src/probe/async/probe.svg b/tests/integration/browser/content/entrypoint-assets/src/probe/async/probe.svg new file mode 100644 index 00000000..a977143c --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets/src/probe/async/probe.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/integration/browser/content/entrypoint-assets/src/probe/async/styles.module.css b/tests/integration/browser/content/entrypoint-assets/src/probe/async/styles.module.css new file mode 100644 index 00000000..0ba3d9d2 --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets/src/probe/async/styles.module.css @@ -0,0 +1,4 @@ +.loaded { + color: rgb(17, 34, 51); + background-image: url("./probe.svg?browser"); +} diff --git a/tests/integration/browser/content/entrypoint-assets/src/probe/index.ts b/tests/integration/browser/content/entrypoint-assets/src/probe/index.ts new file mode 100644 index 00000000..6d7fa14b --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets/src/probe/index.ts @@ -0,0 +1,61 @@ +import {getEntrypointAssetsMap, getEntrypointAssets} from "adnbn"; +import {getUrl} from "adnbn/browser"; + +import styles from "./styles.module.css"; + +export type ProbeWorld = "ISOLATED" | "MAIN"; + +const FullGetterError = "getEntrypointAssetsMap() is available only in the background entrypoint"; + +export const createProbe = (world: ProbeWorld): HTMLElement => { + const root = document.createElement("section"); + root.classList.add(styles.root); + root.dataset.testid = `content-${world.toLowerCase()}`; + root.dataset.world = world; + root.dataset.frame = window === window.top ? "top" : "child"; + root.dataset.async = "pending"; + root.dataset.runs = "1"; + try { + getUrl("/"); + root.dataset.publicPath = "extension"; + } catch (cause) { + root.dataset.publicPath = `unavailable: ${cause instanceof Error ? cause.message : String(cause)}`; + } + + try { + const current = getEntrypointAssets(); + + root.dataset.currentGetter = "ok"; + root.dataset.initialJs = String(current.initial.js.length); + root.dataset.initialCss = String(current.initial.css.length); + root.dataset.asyncJs = String(current.async.js.length); + root.dataset.asyncCss = String(current.async.css.length); + } catch (cause) { + root.dataset.currentGetter = cause instanceof Error ? cause.message : String(cause); + } + + try { + getEntrypointAssetsMap(); + root.dataset.fullGetter = "unexpected"; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + root.dataset.fullGetter = message === FullGetterError ? "blocked" : message; + } + + void import("./async") + .then(module => { + module.applyAsyncProbe(root); + root.dataset.async = module.AsyncProbeValue; + }) + .catch(cause => { + root.dataset.async = `failed: ${cause instanceof Error ? cause.message : String(cause)}`; + root.dataset.extensionResources = JSON.stringify( + performance + .getEntriesByType("resource") + .map(entry => entry.name) + .filter(name => /^(chrome|moz)-extension:\/\//.test(name)) + ); + }); + + return root; +}; diff --git a/tests/integration/browser/content/entrypoint-assets/src/probe/styles.module.css b/tests/integration/browser/content/entrypoint-assets/src/probe/styles.module.css new file mode 100644 index 00000000..8b98de94 --- /dev/null +++ b/tests/integration/browser/content/entrypoint-assets/src/probe/styles.module.css @@ -0,0 +1,3 @@ +.root { + display: block; +} diff --git a/tests/integration/browser/offscreen-service/tsconfig.json b/tests/integration/browser/content/entrypoint-assets/tsconfig.json similarity index 100% rename from tests/integration/browser/offscreen-service/tsconfig.json rename to tests/integration/browser/content/entrypoint-assets/tsconfig.json diff --git a/tests/integration/browser/content/utils.ts b/tests/integration/browser/content/utils.ts new file mode 100644 index 00000000..313e0755 --- /dev/null +++ b/tests/integration/browser/content/utils.ts @@ -0,0 +1,106 @@ +export interface ProbeState { + async: string; + asyncCss: string; + asyncJs: string; + asset: string; + backgroundImage: string; + color: string; + count: number; + currentGetter: string; + frame: string; + fullGetter: string; + extensionResources: string; + initialCss: string; + initialJs: string; + publicPath: string; + runs: string; + world: string; +} + +export interface DocumentState { + isolated?: ProbeState; + isolatedGlobal: boolean; + isolatedSecondaryRuns?: string; + main?: ProbeState; + mainGlobal: boolean; + mainSecondaryRuns?: string; +} + +export const DocumentStateExpression = ` + (doc => { + const probe = world => { + const selector = '[data-testid="content-' + world.toLowerCase() + '"]'; + const root = doc.querySelector(selector); + + if (!root) return undefined; + + const style = doc.defaultView.getComputedStyle(root); + + return { + async: root.dataset.async, + asyncCss: root.dataset.asyncCss, + asyncJs: root.dataset.asyncJs, + asset: root.dataset.asset, + backgroundImage: style.backgroundImage, + color: style.color, + count: doc.querySelectorAll(selector).length, + currentGetter: root.dataset.currentGetter, + frame: root.dataset.frame, + fullGetter: root.dataset.fullGetter, + extensionResources: root.dataset.extensionResources, + initialCss: root.dataset.initialCss, + initialJs: root.dataset.initialJs, + publicPath: root.dataset.publicPath, + runs: root.dataset.runs, + world: root.dataset.world, + }; + }; + + return { + isolated: probe('ISOLATED'), + main: probe('MAIN'), + isolatedGlobal: doc.defaultView.__adnbnIsolatedWorldVisibleToPage === true, + mainGlobal: doc.defaultView.__adnbnMainWorldVisibleToPage === true, + isolatedSecondaryRuns: doc.documentElement.dataset.adnbnIsolatedSecondaryRuns, + mainSecondaryRuns: doc.documentElement.dataset.adnbnMainSecondaryRuns, + }; + })`; + +export const expectLoadedProbe = ( + probe: ProbeState | undefined, + world: "ISOLATED" | "MAIN", + frame: string, + expectedAsyncChunks: boolean, + protocol = "chrome-extension:" +): void => { + if (probe?.async !== "loaded") { + throw new Error(`The ${world} ${frame} probe did not load: ${JSON.stringify(probe)}`); + } + + expect(probe).toMatchObject({ + async: "loaded", + currentGetter: "ok", + frame, + fullGetter: "blocked", + runs: "1", + world, + }); + expect(probe?.count).toBe(1); + expect(Number(probe?.initialJs)).toBeGreaterThan(0); + expect(Number(probe?.initialCss)).toBeGreaterThan(0); + if (expectedAsyncChunks) { + expect(Number(probe?.asyncJs)).toBeGreaterThan(0); + expect(Number(probe?.asyncCss)).toBeGreaterThan(0); + expect(probe?.publicPath).toBe("extension"); + } else { + expect(Number(probe?.asyncJs)).toBe(0); + expect(Number(probe?.asyncCss)).toBe(0); + expect(probe?.publicPath).toMatch(/^unavailable:/); + } + expect(probe?.asset).toMatch(/assets\/probe-[a-f0-9]{4}\.svg$/); + expect(probe?.color).toBe("rgb(17, 34, 51)"); + expect(probe?.backgroundImage).toMatch( + /^url\("(?:chrome|moz)-extension:\/\/[^/]+\/assets\/probe-[a-f0-9]{4}\.svg"\)$/ + ); + expect(probe?.backgroundImage).toContain(protocol); +}; diff --git a/tests/integration/browser/offscreen-service/package.json b/tests/integration/browser/offscreen-service/package.json deleted file mode 100644 index 0b3b2b5a..00000000 --- a/tests/integration/browser/offscreen-service/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "adnbn-offscreen-service-integration", - "version": "1.0.0", - "private": true, - "type": "module" -} diff --git a/tests/integration/browser/offscreen-service.integration.test.ts b/tests/integration/browser/offscreen/service.integration.test.ts similarity index 81% rename from tests/integration/browser/offscreen-service.integration.test.ts rename to tests/integration/browser/offscreen/service.integration.test.ts index 6ad73706..dc23c60f 100644 --- a/tests/integration/browser/offscreen-service.integration.test.ts +++ b/tests/integration/browser/offscreen/service.integration.test.ts @@ -1,25 +1,16 @@ /** @jest-environment node */ -import {mkdir, mkdtemp, rm, symlink} from "fs/promises"; +import {mkdtemp, rm} from "fs/promises"; import os from "os"; import path from "path"; import {spawn, type ChildProcess} from "child_process"; -import { - browserVersion, - CdpClient, - type CdpTarget, - findChromeBinary, - getFreePort, - run, - stop, - targets, - waitFor, -} from "./utils/chrome"; - -const rootDir = path.resolve(__dirname, "..", "..", ".."); -const fixtureDir = path.join(__dirname, "offscreen-service"); -const extensionDir = path.join(fixtureDir, "dist", "myapp-chrome-mv3"); +import {browserVersion, CdpClient, type CdpTarget, findChromeBinary, targets} from "../utils/chrome"; +import {getFreePort, stop, waitFor} from "../utils/browser"; +import {createIntegrationFixture, type IntegrationFixture} from "../../utils/fixture"; + +const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); +const fixtureDir = path.join(__dirname, "service"); const chromeBinary = findChromeBinary(rootDir); jest.setTimeout(60_000); @@ -31,17 +22,16 @@ test("Chrome MV3 offscreen calls the registered background service", async () => ); } - const frameworkLink = path.join(fixtureDir, "node_modules", "adnbn"); const userDataDir = await mkdtemp(path.join(os.tmpdir(), "adnbn-offscreen-service-")); const debuggingPort = await getFreePort(); let chrome: ChildProcess | undefined; let browser: CdpClient | undefined; + let fixture: IntegrationFixture | undefined; let chromeOutput = ""; try { - await mkdir(path.dirname(frameworkLink), {recursive: true}); - await symlink(rootDir, frameworkLink, "dir"); - await run(process.execPath, [path.join(rootDir, "bin", "adnbn.js"), "build", ".", "-b", "chrome"], fixtureDir); + fixture = await createIntegrationFixture(rootDir, fixtureDir); + const extensionDir = await fixture.build(); chrome = spawn( chromeBinary, @@ -140,9 +130,7 @@ test("Chrome MV3 offscreen calls the registered background service", async () => await stop(chrome); } - await rm(path.join(fixtureDir, "node_modules"), {recursive: true, force: true}); - await rm(path.join(fixtureDir, ".adnbn"), {recursive: true, force: true}); - await rm(path.join(fixtureDir, "dist"), {recursive: true, force: true}); + await fixture?.dispose(); await rm(userDataDir, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); } }); diff --git a/tests/integration/browser/offscreen-service/adnbn.config.ts b/tests/integration/browser/offscreen/service/adnbn.config.ts similarity index 100% rename from tests/integration/browser/offscreen-service/adnbn.config.ts rename to tests/integration/browser/offscreen/service/adnbn.config.ts diff --git a/tests/integration/browser/offscreen/service/package.json b/tests/integration/browser/offscreen/service/package.json new file mode 100644 index 00000000..6809ad80 --- /dev/null +++ b/tests/integration/browser/offscreen/service/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-offscreen-service", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "adnbn": "file:../../../../.." + } +} diff --git a/tests/integration/browser/offscreen-service/src/background/index.ts b/tests/integration/browser/offscreen/service/src/background.ts similarity index 100% rename from tests/integration/browser/offscreen-service/src/background/index.ts rename to tests/integration/browser/offscreen/service/src/background.ts diff --git a/tests/integration/browser/offscreen-service/src/offscreen/index.ts b/tests/integration/browser/offscreen/service/src/offscreen.ts similarity index 100% rename from tests/integration/browser/offscreen-service/src/offscreen/index.ts rename to tests/integration/browser/offscreen/service/src/offscreen.ts diff --git a/tests/integration/browser/offscreen-service/src/services/offscreen-test.service.ts b/tests/integration/browser/offscreen/service/src/service.ts similarity index 100% rename from tests/integration/browser/offscreen-service/src/services/offscreen-test.service.ts rename to tests/integration/browser/offscreen/service/src/service.ts diff --git a/tests/integration/browser/options/embedded/tsconfig.json b/tests/integration/browser/offscreen/service/tsconfig.json similarity index 100% rename from tests/integration/browser/options/embedded/tsconfig.json rename to tests/integration/browser/offscreen/service/tsconfig.json diff --git a/tests/integration/browser/options/.gitignore b/tests/integration/browser/options/.gitignore deleted file mode 100644 index 7e2b3b14..00000000 --- a/tests/integration/browser/options/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*/.adnbn/ diff --git a/tests/integration/browser/options/README.md b/tests/integration/browser/options/README.md index d81e5efb..9d12d33d 100644 --- a/tests/integration/browser/options/README.md +++ b/tests/integration/browser/options/README.md @@ -1,31 +1,18 @@ -# Options integration fixtures +# Options browser fixtures -Run from the repository root after installing its dependencies: +See the [integration guide](../../README.md) for editor preparation, dependency setup, and test isolation. + +Run only the Options browser cases from the repository root: ```bash -npm run build -node ./node_modules/jest/bin/jest.js tests/integration/browser/options.integration.test.ts --runInBand +npm run test -- tests/integration/browser/options/options.integration.test.ts --runInBand ``` -The test builds the local framework fixtures, loads each browser fixture in an isolated Chrome profile through CDP, and calls `chrome.runtime.openOptionsPage()` from its real background service worker. Set `ADNBN_CHROME_BIN` to an absolute Chrome executable path if automatic discovery selects the wrong browser. Chrome must support `Extensions.loadUnpacked`; Node must provide the built-in `WebSocket` API. - -| Fixture | Coverage | -| ---------- | ------------------------------------------------------------------------------------------------------------------ | -| `vanilla` | `options.ts`, default `openInTab: true`, DOM rendering and event handling | -| `react` | `options.tsx`, explicit `openInTab: true`, React rendering and state, custom `as` and `htmlDir` | -| `embedded` | Build-only verification that explicit `openInTab: false` reaches the manifest for all five browsers in MV2 and MV3 | - -The two browser cases check the exact `options_ui` object, the absence of `options_page`, loading of the common View chunk shared with a second Page, CSS application, and runtime errors. Their expected output paths are `options.html` and `ui/preferences.options.html` inside each fixture's `dist/myapp-chrome-mv3` directory. - -The suite creates temporary package links, generated files, build output, and Chrome profiles, then removes them. It does not use your regular browser profile or modify the `addon` playground. +- `vanilla/src/options/index.ts` uses DOM rendering and event handling with default `openInTab: true`. +- `react/src/options/index.tsx` uses React state, explicit `openInTab: true`, and custom `as` and `htmlDir` values. -Each fixture has its own package boundary so it does not inherit the framework -package's `sideEffects: false` and lose CSS imports during production builds. +Each Options entrypoint keeps its `styles.css` in the same directory. `background.ts` and the independent `help.page.ts` or `help.page.tsx` remain standalone files. -To run only the embedded manifest build without launching Chrome: - -```bash -node ./node_modules/jest/bin/jest.js tests/integration/browser/options.integration.test.ts --runInBand -t "preserves explicit" -``` +Both cases open Options from the real background service worker in Chrome MV3. They check the exact `options_ui` object, the absence of `options_page`, a View chunk shared with the Help page, CSS application, state changes, and runtime errors. Expected pages are `options.html` and `ui/preferences.options.html`. -Browser execution covers Chrome MV3. The `embedded` cases build Chrome, Edge, Opera, Safari, and Firefox targets in MV2 and MV3; they do not test embedded settings UI. Loading in other browsers or MV2, and opening settings through the browser's own menus, require separate checks. +The embedded Options manifest matrix lives separately in `tests/integration/build/options/embedded` and runs without Chrome through `npm run test:integration:build`. It does not verify embedded settings UI in a browser. diff --git a/tests/integration/browser/options/embedded/package.json b/tests/integration/browser/options/embedded/package.json deleted file mode 100644 index f6bf019f..00000000 --- a/tests/integration/browser/options/embedded/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "addon-bone-options-embedded-fixture", - "private": true, - "type": "module" -} diff --git a/tests/integration/browser/options.integration.test.ts b/tests/integration/browser/options/options.integration.test.ts similarity index 69% rename from tests/integration/browser/options.integration.test.ts rename to tests/integration/browser/options/options.integration.test.ts index 35186e85..603cfa1a 100644 --- a/tests/integration/browser/options.integration.test.ts +++ b/tests/integration/browser/options/options.integration.test.ts @@ -1,56 +1,16 @@ /** @jest-environment node */ -import {mkdir, mkdtemp, readFile, rm, symlink} from "fs/promises"; +import {mkdtemp, readFile, rm} from "fs/promises"; import os from "os"; import path from "path"; import {spawn, type ChildProcess} from "child_process"; -import {browserVersion, CdpClient, findChromeBinary, getFreePort, run, stop, targets, waitFor} from "./utils/chrome"; +import {browserVersion, CdpClient, findChromeBinary, targets} from "../utils/chrome"; +import {getFreePort, stop, waitFor} from "../utils/browser"; +import {createIntegrationFixture, type IntegrationFixture} from "../../utils/fixture"; -const rootDir = path.resolve(__dirname, "..", "..", ".."); -const fixturesDir = path.join(__dirname, "options"); +const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); const chromeBinary = findChromeBinary(rootDir); -const artifactDir = (fixtureDir: string, browser = "chrome", manifestVersion = 3): string => - path.join(fixtureDir, "dist", `myapp-${browser}-mv${manifestVersion}`); - -const buildFixture = async ( - fixtureDir: string, - { - react = false, - browser = "chrome", - manifestVersion = 3, - }: {react?: boolean; browser?: string; manifestVersion?: 2 | 3} = {} -): Promise => { - const modulesDir = path.join(fixtureDir, "node_modules"); - - await mkdir(modulesDir, {recursive: true}); - await symlink(rootDir, path.join(modulesDir, "adnbn"), "dir"); - - if (react) { - for (const dependency of ["react", "react-dom", "scheduler"]) { - await symlink(path.join(rootDir, "node_modules", dependency), path.join(modulesDir, dependency), "dir"); - } - } - - await run( - process.execPath, - [ - path.join(rootDir, "bin", "adnbn.js"), - "build", - ".", - "-b", - browser, - ...(manifestVersion === 2 ? ["--mv2"] : []), - ], - fixtureDir - ); -}; - -const cleanFixture = async (fixtureDir: string): Promise => { - for (const directory of ["node_modules", ".adnbn", "dist"]) { - await rm(path.join(fixtureDir, directory), {recursive: true, force: true}); - } -}; jest.setTimeout(90_000); @@ -64,16 +24,17 @@ test.each([ ); } - const fixtureDir = path.join(fixturesDir, adapter); - const extensionDir = artifactDir(fixtureDir); + const fixtureDir = path.join(__dirname, adapter); const userDataDir = await mkdtemp(path.join(os.tmpdir(), `adnbn-options-${adapter}-`)); const debuggingPort = await getFreePort(); let chrome: ChildProcess | undefined; let browser: CdpClient | undefined; + let fixture: IntegrationFixture | undefined; let chromeOutput = ""; try { - await buildFixture(fixtureDir, {react: adapter === "react"}); + fixture = await createIntegrationFixture(rootDir, fixtureDir); + const extensionDir = await fixture.build(); const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); const optionsHtml = await readFile(path.join(extensionDir, page), "utf8"); @@ -198,27 +159,7 @@ test.each([ await stop(chrome); } - await cleanFixture(fixtureDir); + await fixture?.dispose(); await rm(userDataDir, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); } }); - -describe.each(["chrome", "edge", "opera", "safari", "firefox"])("%s options manifest", browser => { - test.each([2, 3] as const)("MV%s build preserves explicit openInTab false", async manifestVersion => { - const fixtureDir = path.join(fixturesDir, "embedded"); - - try { - await buildFixture(fixtureDir, {browser, manifestVersion}); - - const extensionDir = artifactDir(fixtureDir, browser, manifestVersion); - const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); - - expect(manifest.manifest_version).toBe(manifestVersion); - expect(manifest.options_ui).toEqual({page: "options.html", open_in_tab: false}); - expect(manifest.options_page).toBeUndefined(); - expect(await readFile(path.join(extensionDir, manifest.options_ui.page), "utf8")).toContain("; + error?: string; + message?: string; +} + +interface BidiPendingRequest { + resolve(value: Record): void; + reject(error: Error): void; + timeout: NodeJS.Timeout; +} + +/** Minimal WebDriver BiDi client for Firefox integration tests. */ +export default class BidiClient { + private nextId = 1; + private readonly pending = new Map(); + + public readonly runtimeErrors: string[] = []; + + private constructor(private readonly socket: WebSocket) { + socket.addEventListener("message", event => this.receive(JSON.parse(String(event.data)))); + socket.addEventListener("close", () => this.rejectPending(new Error("Firefox BiDi connection closed"))); + socket.addEventListener("error", () => this.rejectPending(new Error("Firefox BiDi connection failed"))); + } + + public static connect(url: string, timeout = 1_000): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(url); + const timer = setTimeout(() => { + socket.close(); + reject(new Error(`Timed out connecting to Firefox BiDi at ${url}`)); + }, timeout); + + socket.addEventListener( + "open", + () => { + clearTimeout(timer); + resolve(new BidiClient(socket)); + }, + {once: true} + ); + socket.addEventListener( + "error", + () => { + clearTimeout(timer); + reject(new Error(`Unable to connect to Firefox BiDi at ${url}`)); + }, + {once: true} + ); + }); + } + + public send(method: string, params: Record = {}, timeout = 15_000): Promise> { + const id = this.nextId++; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Firefox BiDi request timed out: ${method}`)); + }, timeout); + + this.pending.set(id, {resolve, reject, timeout: timer}); + + try { + this.socket.send(JSON.stringify({id, method, params})); + } catch (error) { + this.pending.delete(id); + clearTimeout(timer); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + public async evaluate(context: string, expression: string): Promise { + const response = await this.send("script.evaluate", { + target: {context}, + expression: `(async () => JSON.stringify(await (${expression})))()`, + awaitPromise: true, + }); + + if (response.type === "exception") { + throw new Error(response.exceptionDetails?.text ?? "Firefox evaluation failed"); + } + + return response.result?.type === "undefined" ? undefined : JSON.parse(response.result.value); + } + + public async close(): Promise { + if (this.socket.readyState === WebSocket.CLOSED) { + return; + } + + await new Promise(resolve => { + const timer = setTimeout(resolve, 1_000); + this.socket.addEventListener( + "close", + () => { + clearTimeout(timer); + resolve(); + }, + {once: true} + ); + this.socket.close(); + }); + } + + private receive(message: BidiMessage): void { + if (message.method === "log.entryAdded" && message.params?.level === "error") { + this.runtimeErrors.push(message.params.text ?? "Firefox runtime error"); + } + + if (message.id === undefined) { + return; + } + + const pending = this.pending.get(message.id); + + if (!pending) { + return; + } + + this.pending.delete(message.id); + clearTimeout(pending.timeout); + + if (message.type === "error") { + pending.reject(new Error(message.message ?? message.error ?? "Firefox BiDi request failed")); + } else { + pending.resolve(message.result ?? {}); + } + } + + private rejectPending(error: Error): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(error); + } + this.pending.clear(); + } +} diff --git a/tests/integration/browser/utils/browser.ts b/tests/integration/browser/utils/browser.ts new file mode 100644 index 00000000..5885ce8d --- /dev/null +++ b/tests/integration/browser/utils/browser.ts @@ -0,0 +1,88 @@ +import {createServer} from "net"; +import type {ChildProcess} from "child_process"; + +const delay = (milliseconds: number) => new Promise(resolve => setTimeout(resolve, milliseconds)); + +export const waitFor = async (callback: () => Promise, timeout = 15_000): Promise => { + const deadline = Date.now() + timeout; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const value = await callback(); + + if (value !== undefined) { + return value; + } + } catch (error) { + lastError = error; + } + + await delay(100); + } + + const detail = lastError instanceof Error ? `: ${lastError.message}` : ""; + + throw new Error(`Timed out waiting for the browser${detail}`); +}; + +export const getFreePort = (): Promise => { + return new Promise((resolve, reject) => { + const server = createServer(); + + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Unable to reserve a debugging port")); + + return; + } + + server.close(error => (error ? reject(error) : resolve(address.port))); + }); + }); +}; + +export const stop = async (process: ChildProcess): Promise => { + if (process.exitCode !== null || process.killed) { + return; + } + + const waitForExit = (timeout: number): Promise => + new Promise(resolve => { + const onExit = () => { + clearTimeout(timer); + resolve(true); + }; + const timer = setTimeout(() => { + process.off("exit", onExit); + resolve(false); + }, timeout); + + process.once("exit", onExit); + }); + + const stopProcess = (signal: NodeJS.Signals): void => { + try { + process.kill(signal); + } catch (error) { + if (!(error instanceof Error) || !error.message.includes("ESRCH")) { + throw error; + } + } + }; + + const stopped = waitForExit(5_000); + + stopProcess("SIGTERM"); + + if (!(await stopped) && process.exitCode === null) { + const killed = waitForExit(5_000); + + stopProcess("SIGKILL"); + await killed; + } +}; diff --git a/tests/integration/browser/utils/chrome.ts b/tests/integration/browser/utils/chrome.ts index 5a702a28..64155bb6 100644 --- a/tests/integration/browser/utils/chrome.ts +++ b/tests/integration/browser/utils/chrome.ts @@ -1,6 +1,5 @@ -import {createServer} from "net"; import path from "path"; -import {spawn, spawnSync, type ChildProcess} from "child_process"; +import {spawnSync} from "child_process"; type CdpMessage = { id?: number; @@ -38,84 +37,6 @@ export const findChromeBinary = (rootDir: string): string | undefined => { return chromePath || undefined; }; -const delay = (milliseconds: number) => new Promise(resolve => setTimeout(resolve, milliseconds)); - -export const waitFor = async (callback: () => Promise, timeout = 15_000): Promise => { - const deadline = Date.now() + timeout; - let lastError: unknown; - - while (Date.now() < deadline) { - try { - const value = await callback(); - - if (value !== undefined) { - return value; - } - } catch (error) { - lastError = error; - } - - await delay(100); - } - - const detail = lastError instanceof Error ? `: ${lastError.message}` : ""; - - throw new Error(`Timed out waiting for Chrome${detail}`); -}; - -export const getFreePort = (): Promise => { - return new Promise((resolve, reject) => { - const server = createServer(); - - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - - if (!address || typeof address === "string") { - server.close(); - reject(new Error("Unable to reserve a debugging port")); - - return; - } - - server.close(error => (error ? reject(error) : resolve(address.port))); - }); - }); -}; - -export const run = (command: string, args: string[], cwd: string, timeout = 30_000): Promise => { - return new Promise((resolve, reject) => { - const process = spawn(command, args, {cwd, stdio: ["ignore", "pipe", "pipe"]}); - let output = ""; - let settled = false; - - const finish = (callback: () => void): void => { - if (settled) { - return; - } - - settled = true; - clearTimeout(runTimeout); - callback(); - }; - const runTimeout = setTimeout(() => { - process.kill("SIGKILL"); - finish(() => reject(new Error(`${command} ${args.join(" ")} timed out after ${timeout} ms\n${output}`))); - }, timeout); - - process.stdout.on("data", chunk => (output += chunk)); - process.stderr.on("data", chunk => (output += chunk)); - process.once("error", error => finish(() => reject(error))); - process.once("exit", code => { - if (code === 0) { - finish(resolve); - } else { - finish(() => reject(new Error(`${command} ${args.join(" ")} exited with ${code}\n${output}`))); - } - }); - }); -}; - export class CdpClient { private nextId = 1; private readonly pending = new Map(); @@ -258,44 +179,3 @@ export const browserVersion = async (port: number): Promise<{webSocketDebuggerUr return response.json() as Promise<{webSocketDebuggerUrl: string}>; }; - -export const stop = async (process: ChildProcess): Promise => { - if (process.exitCode !== null || process.killed) { - return; - } - - const waitForExit = (timeout: number): Promise => - new Promise(resolve => { - const onExit = () => { - clearTimeout(timer); - resolve(true); - }; - const timer = setTimeout(() => { - process.off("exit", onExit); - resolve(false); - }, timeout); - - process.once("exit", onExit); - }); - - const stopProcess = (signal: NodeJS.Signals): void => { - try { - process.kill(signal); - } catch (error) { - if (!(error instanceof Error) || !error.message.includes("ESRCH")) { - throw error; - } - } - }; - - const stopped = waitForExit(5_000); - - stopProcess("SIGTERM"); - - if (!(await stopped) && process.exitCode === null) { - const killed = waitForExit(5_000); - - stopProcess("SIGKILL"); - await killed; - } -}; diff --git a/tests/integration/browser/utils/firefox.ts b/tests/integration/browser/utils/firefox.ts new file mode 100644 index 00000000..56dadf1a --- /dev/null +++ b/tests/integration/browser/utils/firefox.ts @@ -0,0 +1,22 @@ +import {existsSync} from "fs"; +import path from "path"; + +export const findFirefoxBinary = (): string | undefined => { + if (process.env.ADNBN_FIREFOX_BIN) { + return process.env.ADNBN_FIREFOX_BIN; + } + + const filename = process.platform === "win32" ? "firefox.exe" : "firefox"; + const candidates = [ + "/Applications/Firefox.app/Contents/MacOS/firefox", + ...[process.env.ProgramFiles, process.env["ProgramFiles(x86)"]] + .filter((directory): directory is string => Boolean(directory)) + .map(directory => path.join(directory, "Mozilla Firefox", filename)), + ...(process.env.PATH ?? "") + .split(path.delimiter) + .filter(Boolean) + .map(directory => path.resolve(directory, filename)), + ]; + + return candidates.find(candidate => existsSync(candidate)); +}; diff --git a/tests/integration/browser/utils/site.ts b/tests/integration/browser/utils/site.ts new file mode 100644 index 00000000..53397481 --- /dev/null +++ b/tests/integration/browser/utils/site.ts @@ -0,0 +1,52 @@ +import {createServer} from "http"; +import {readFile} from "fs/promises"; +import path from "path"; + +export interface IntegrationSite { + readonly origin: string; + close(): Promise; +} + +export const startIntegrationSite = async (directory: string): Promise => { + const server = createServer(async (request, response) => { + try { + const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname; + + // Browsers request a favicon independently of the extension under test. + if (pathname === "/favicon.ico") { + response.writeHead(204); + response.end(); + return; + } + + const filename = path.basename(pathname === "/" ? "top.html" : pathname); + const content = await readFile(path.join(directory, filename)); + + response.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": "default-src 'none'; frame-src 'self'; img-src 'self'", + }); + response.end(content); + } catch { + response.writeHead(404); + response.end("Not found"); + } + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + + const address = server.address(); + + if (!address || typeof address === "string") { + server.close(); + throw new Error("Unable to determine the integration site address"); + } + + return { + origin: `http://127.0.0.1:${address.port}`, + close: () => new Promise((resolve, reject) => server.close(error => (error ? reject(error) : resolve()))), + }; +}; diff --git a/tests/integration/browser/options/embedded/adnbn.config.ts b/tests/integration/build/options/embedded/adnbn.config.ts similarity index 100% rename from tests/integration/browser/options/embedded/adnbn.config.ts rename to tests/integration/build/options/embedded/adnbn.config.ts diff --git a/tests/integration/build/options/embedded/package.json b/tests/integration/build/options/embedded/package.json new file mode 100644 index 00000000..3d1a819e --- /dev/null +++ b/tests/integration/build/options/embedded/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-options-embedded", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../.." + } +} diff --git a/tests/integration/browser/options/embedded/src/options.ts b/tests/integration/build/options/embedded/src/options.ts similarity index 100% rename from tests/integration/browser/options/embedded/src/options.ts rename to tests/integration/build/options/embedded/src/options.ts diff --git a/tests/integration/build/options/embedded/tsconfig.json b/tests/integration/build/options/embedded/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/build/options/embedded/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/build/options/options.integration.test.ts b/tests/integration/build/options/options.integration.test.ts new file mode 100644 index 00000000..83ecfb43 --- /dev/null +++ b/tests/integration/build/options/options.integration.test.ts @@ -0,0 +1,29 @@ +/** @jest-environment node */ + +import {readFile} from "fs/promises"; +import path from "path"; + +import {createIntegrationFixture} from "../../utils/fixture"; + +const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); +const fixtureDir = path.join(__dirname, "embedded"); + +jest.setTimeout(90_000); + +describe.each(["chrome", "edge", "opera", "safari", "firefox"])("%s options manifest", browser => { + test.each([2, 3] as const)("MV%s build preserves explicit openInTab false", async manifestVersion => { + const fixture = await createIntegrationFixture(rootDir, fixtureDir); + + try { + const extensionDir = await fixture.build({browser, manifestVersion}); + const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); + + expect(manifest.manifest_version).toBe(manifestVersion); + expect(manifest.options_ui).toEqual({page: "options.html", open_in_tab: false}); + expect(manifest.options_page).toBeUndefined(); + expect(await readFile(path.join(extensionDir, manifest.options_ui.page), "utf8")).toContain("; + dispose(): Promise; +} + +const GeneratedDirectories = new Set(["node_modules", ".adnbn", "dist"]); + +const linkDependency = async (source: string, destination: string): Promise => { + const target = await realpath(source); + const existing = await lstat(destination).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") { + throw error; + } + + return undefined; + }); + + if (existing) { + if ((await realpath(destination)) === target) { + return; + } + + throw new Error(`Integration dependency already exists at ${destination}; expected a link to ${target}`); + } + + await mkdir(path.dirname(destination), {recursive: true}); + await symlink(target, destination, process.platform === "win32" ? "junction" : "dir"); +}; + +export const prepareIntegrationFixture = async ( + projectRoot: string, + directory: string, + {browser = "chrome", manifestVersion = 3}: IntegrationFixtureBuildOptions = {} +): Promise => { + const manifest = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")) as { + dependencies?: Record; + }; + + for (const dependency of Object.keys(manifest.dependencies ?? {})) { + await linkDependency( + dependency === "adnbn" ? projectRoot : path.join(projectRoot, "node_modules", dependency), + path.join(directory, "node_modules", dependency) + ); + } + + await run( + process.execPath, + [ + path.join(projectRoot, "bin", "adnbn.js"), + "build", + ".", + "-b", + browser, + ...(manifestVersion === 2 ? ["--mv2"] : []), + ], + directory + ); + + return path.join(directory, "dist", `myapp-${browser}-mv${manifestVersion}`); +}; + +export const createIntegrationFixture = async ( + projectRoot: string, + sourceDirectory: string +): Promise => { + const cacheDirectory = path.join(projectRoot, ".cache", "integration"); + + await mkdir(cacheDirectory, {recursive: true}); + + const directory = await mkdtemp(path.join(cacheDirectory, "fixture-")); + const dispose = () => rm(directory, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + + try { + await cp(sourceDirectory, directory, { + recursive: true, + filter: source => !GeneratedDirectories.has(path.basename(source)), + }); + } catch (error) { + await dispose(); + throw error; + } + + return { + directory, + build: options => prepareIntegrationFixture(projectRoot, directory, options), + dispose, + }; +}; + +export const findIntegrationFixtures = async (directory: string): Promise => { + const entries = await readdir(directory, {withFileTypes: true}); + + if (entries.some(entry => entry.isFile() && entry.name === "adnbn.config.ts")) { + return [directory]; + } + + const fixtures: string[] = []; + + for (const entry of entries) { + if (entry.isDirectory() && !GeneratedDirectories.has(entry.name)) { + fixtures.push(...(await findIntegrationFixtures(path.join(directory, entry.name)))); + } + } + + return fixtures.sort(); +}; diff --git a/tests/integration/utils/process.ts b/tests/integration/utils/process.ts new file mode 100644 index 00000000..861bdb15 --- /dev/null +++ b/tests/integration/utils/process.ts @@ -0,0 +1,34 @@ +import {spawn} from "child_process"; + +export const run = (command: string, args: string[], cwd: string, timeout = 30_000): Promise => { + return new Promise((resolve, reject) => { + const child = spawn(command, args, {cwd, stdio: ["ignore", "pipe", "pipe"]}); + let output = ""; + let settled = false; + + const finish = (callback: () => void): void => { + if (settled) { + return; + } + + settled = true; + clearTimeout(runTimeout); + callback(); + }; + const runTimeout = setTimeout(() => { + child.kill("SIGKILL"); + finish(() => reject(new Error(`${command} ${args.join(" ")} timed out after ${timeout} ms\n${output}`))); + }, timeout); + + child.stdout.on("data", chunk => (output += chunk)); + child.stderr.on("data", chunk => (output += chunk)); + child.once("error", error => finish(() => reject(error))); + child.once("exit", code => { + if (code === 0) { + finish(resolve); + } else { + finish(() => reject(new Error(`${command} ${args.join(" ")} exited with ${code}\n${output}`))); + } + }); + }); +}; diff --git a/tsconfig.tests.json b/tsconfig.tests.json index ede40a0d..dc1d5059 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -8,6 +8,7 @@ "src/**/*.test.tsx", "src/**/*.d.ts", "tests/**/*.test.ts", + "tests/integration/*.ts", "tests/jest*.ts" ], "exclude": [ From f2af015e43eeab25acb0dda9638ef46aae09241a Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:44:08 +0300 Subject: [PATCH 07/38] test(entrypoint): make virtual path assertion cross-platform --- src/cli/bundler/plugins/EntrypointPlugin.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli/bundler/plugins/EntrypointPlugin.test.ts b/src/cli/bundler/plugins/EntrypointPlugin.test.ts index 8e499695..14c5151a 100644 --- a/src/cli/bundler/plugins/EntrypointPlugin.test.ts +++ b/src/cli/bundler/plugins/EntrypointPlugin.test.ts @@ -1,5 +1,7 @@ /** @jest-environment node */ +import path from "path"; + import type {Compiler, EntryNormalized} from "@rspack/core"; import EntrypointPlugin from "./EntrypointPlugin"; @@ -43,7 +45,7 @@ test("applies entry options while preserving existing entry configuration", () = plugin.applyEntryOptions(entry); expect(entry["example.content"]).toMatchObject({ - import: ["existing.js", "virtual/example.content.ts"], + import: ["existing.js", path.join("virtual", "example.content.ts")], filename: "custom/[name].js", asyncChunks: false, publicPath: "", From 1721c92f252347ad94bbc69e0d25adbd44e91919 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:17:24 +0300 Subject: [PATCH 08/38] feat(content): add shadow DOM support --- jest.config.ts | 2 + src/cli/builders/manifest/ManifestBase.ts | 8 +- src/cli/builders/manifest/ManifestV3.test.ts | 28 + .../BuildAssetsMapPlugin.test.ts | 39 +- .../BuildAssetsMapPlugin.ts | 36 +- .../bundler/plugins/build-assets-map/index.ts | 1 + .../tests/fixtures}/alpha.css | 0 .../tests/fixtures}/alpha.lazy.css | 0 .../tests/fixtures}/alpha.lazy.js | 0 .../tests/fixtures}/alpha.plan-a.js | 0 .../fixtures}/background-async.plan-a.js | 0 .../fixtures}/background-shared.plan-a.js | 0 .../tests/fixtures}/background.plan-a.js | 0 .../tests/fixtures}/beta.changed.css | 0 .../tests/fixtures}/beta.css | 0 .../tests/fixtures}/beta.lazy.css | 0 .../tests/fixtures}/beta.lazy.js | 0 .../tests/fixtures}/beta.plan-a.js | 0 .../tests/fixtures}/font.woff2 | 0 .../tests/fixtures}/lazy-image.svg | 0 .../tests/fixtures}/payload.js | 0 .../tests/fixtures/shared-with-css.js | 3 + .../tests/fixtures}/shared.changed.js | 0 .../tests/fixtures/shared.css | 3 + .../tests/fixtures}/shared.js | 0 .../ChunkLoaderPlugin.test.ts | 2 +- .../{ => chunk-loader}/ChunkLoaderPlugin.ts | 45 +- src/cli/bundler/plugins/chunk-loader/index.ts | 1 + .../bundler/plugins/chunk-loader/templates.ts | 17 + .../templates/runtime.template.js | 35 ++ .../tests/fixtures}/entry.js | 0 .../tests/fixtures}/lazy.js | 0 .../tests/fixtures}/unselected.js | 0 .../tests/fixtures}/unselected.lazy.js | 0 src/cli/bundler/plugins/index.ts | 6 +- .../shadow-styles/ShadowStylesPlugin.test.ts | 548 ++++++++++++++++++ .../shadow-styles/ShadowStylesPlugin.ts | 123 ++++ .../bundler/plugins/shadow-styles/index.ts | 1 + .../plugins/shadow-styles/templates.ts | 38 ++ .../templates/css-loader.template.js | 10 + .../templates/runtime.template.js | 108 ++++ .../shadow-styles/tests/fixtures/normal.css | 3 + .../shadow-styles/tests/fixtures/normal.js | 3 + .../tests/fixtures/normal.lazy.css | 3 + .../tests/fixtures/normal.lazy.js | 3 + .../shadow-styles/tests/fixtures/shadow.css | 3 + .../shadow-styles/tests/fixtures/shadow.js | 4 + .../tests/fixtures/shadow.lazy-a.css | 3 + .../tests/fixtures/shadow.lazy-b.css | 3 + .../tests/fixtures/shadow.lazy.js | 4 + src/cli/bundler/utils/runtime-template.ts | 21 + src/cli/entrypoint/file/SourceFile.ts | 4 + src/cli/entrypoint/parser/AbstractParser.ts | 2 +- .../entrypoint/parser/ContentParser.test.ts | 31 +- src/cli/entrypoint/parser/ContentParser.ts | 13 +- src/cli/entrypoint/parser/RelayParser.test.ts | 4 + src/cli/entrypoint/parser/RelayParser.ts | 33 +- .../invalid/shadow-identifier.content.ts | 7 + .../content/options/shadow/false.content.ts | 5 + .../options/shadow/object/entry.content.ts | 15 + .../content/options/shadow/object/panel.woff2 | 0 .../content/options/shadow/true.content.ts | 5 + .../fixtures/relay/options/shadow/relay.ts | 7 + src/cli/plugins/asset.ts | 4 + .../plugins/content/ContentManager.test.ts | 105 ++++ src/cli/plugins/content/ContentManager.ts | 23 +- src/cli/plugins/content/ContentName.ts | 2 +- src/cli/plugins/content/index.ts | 7 +- src/cli/raw.d.ts | 4 + src/cli/virtual/virtual.d.ts | 6 - src/entry/content/README.md | 79 +++ src/entry/content/adapters/react/Node.test.ts | 44 ++ src/entry/content/adapters/react/Node.ts | 8 +- .../content/adapters/vanilla/Node.test.ts | 28 + src/entry/content/adapters/vanilla/Node.ts | 10 +- src/entry/content/core/Builder.ts | 21 + src/entry/content/core/Context.test.ts | 29 + src/entry/content/core/Context.ts | 8 + src/entry/content/core/EventNode.ts | 4 + src/entry/content/core/MarkerNode.ts | 4 + src/entry/content/core/MountBuilder.ts | 7 +- src/entry/content/core/MountNode.ts | 4 + src/entry/content/core/Node.ts | 4 + .../content/core/ShadowFontRegistry.test.ts | 80 +++ src/entry/content/core/ShadowFontRegistry.ts | 49 ++ src/entry/content/core/ShadowNode.test.ts | 116 ++++ src/entry/content/core/ShadowNode.ts | 72 +++ src/entry/content/core/shadow-styles.ts | 25 + src/types/content.ts | 31 +- src/types/manifest.ts | 6 +- src/types/relay.ts | 2 +- tests/integration/README.md | 9 +- .../browser/content/shadow-styles-utils.ts | 290 +++++++++ .../shadow-styles.firefox.integration.test.ts | 12 + .../content/shadow-styles.integration.test.ts | 9 + .../browser/content/shadow-styles/README.md | 52 ++ .../content/shadow-styles/adnbn.config.ts | 27 + .../browser/content/shadow-styles/font.py | 37 ++ .../content/shadow-styles/package.json | 9 + .../content/shadow-styles/site/child.html | 11 + .../content/shadow-styles/site/frames.html | 12 + .../content/shadow-styles/site/top.html | 14 + .../shadow-styles/src/normal.content.ts | 15 + .../shadow-styles/src/probe.content/index.ts | 92 +++ .../src/probe.content/lazy.module.css | 3 + .../shadow-styles/src/probe.content/lazy.ts | 5 + .../src/probe.content/probe.woff2 | Bin 0 -> 404 bytes .../src/probe.content/styles.module.css | 13 + .../src/secondary.content/index.ts | 61 ++ .../src/secondary.content/lazy.module.css | 3 + .../src/secondary.content/lazy.ts | 5 + .../src/secondary.content/styles.module.css | 3 + .../src/shared/styles.module.css | 3 + .../content/shadow-styles/tsconfig.json | 3 + tests/integration/browser/utils/site.ts | 7 +- tests/raw-module-resolver.cjs | 6 + tests/raw-module-transformer.cjs | 8 + tsup.config.ts | 24 + 118 files changed, 2643 insertions(+), 102 deletions(-) rename src/cli/bundler/plugins/{ => build-assets-map}/BuildAssetsMapPlugin.test.ts (94%) rename src/cli/bundler/plugins/{ => build-assets-map}/BuildAssetsMapPlugin.ts (96%) create mode 100644 src/cli/bundler/plugins/build-assets-map/index.ts rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/alpha.css (100%) rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/alpha.lazy.css (100%) rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/alpha.lazy.js (100%) rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/alpha.plan-a.js (100%) rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/background-async.plan-a.js (100%) rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/background-shared.plan-a.js (100%) rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/background.plan-a.js (100%) rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/beta.changed.css (100%) rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/beta.css (100%) rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/beta.lazy.css (100%) rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/beta.lazy.js (100%) rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/beta.plan-a.js (100%) rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/font.woff2 (100%) rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/lazy-image.svg (100%) rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/payload.js (100%) create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/shared-with-css.js rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/shared.changed.js (100%) create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/shared.css rename src/cli/bundler/plugins/{tests/fixtures/build-assets => build-assets-map/tests/fixtures}/shared.js (100%) rename src/cli/bundler/plugins/{ => chunk-loader}/ChunkLoaderPlugin.test.ts (97%) rename src/cli/bundler/plugins/{ => chunk-loader}/ChunkLoaderPlugin.ts (52%) create mode 100644 src/cli/bundler/plugins/chunk-loader/index.ts create mode 100644 src/cli/bundler/plugins/chunk-loader/templates.ts create mode 100644 src/cli/bundler/plugins/chunk-loader/templates/runtime.template.js rename src/cli/bundler/plugins/{tests/fixtures/chunk-loader => chunk-loader/tests/fixtures}/entry.js (100%) rename src/cli/bundler/plugins/{tests/fixtures/chunk-loader => chunk-loader/tests/fixtures}/lazy.js (100%) rename src/cli/bundler/plugins/{tests/fixtures/chunk-loader => chunk-loader/tests/fixtures}/unselected.js (100%) rename src/cli/bundler/plugins/{tests/fixtures/chunk-loader => chunk-loader/tests/fixtures}/unselected.lazy.js (100%) create mode 100644 src/cli/bundler/plugins/shadow-styles/ShadowStylesPlugin.test.ts create mode 100644 src/cli/bundler/plugins/shadow-styles/ShadowStylesPlugin.ts create mode 100644 src/cli/bundler/plugins/shadow-styles/index.ts create mode 100644 src/cli/bundler/plugins/shadow-styles/templates.ts create mode 100644 src/cli/bundler/plugins/shadow-styles/templates/css-loader.template.js create mode 100644 src/cli/bundler/plugins/shadow-styles/templates/runtime.template.js create mode 100644 src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.css create mode 100644 src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.js create mode 100644 src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.lazy.css create mode 100644 src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.lazy.js create mode 100644 src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.css create mode 100644 src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.js create mode 100644 src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy-a.css create mode 100644 src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy-b.css create mode 100644 src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy.js create mode 100644 src/cli/bundler/utils/runtime-template.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow-identifier.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/false.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/object/entry.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/object/panel.woff2 create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/true.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/relay/options/shadow/relay.ts create mode 100644 src/cli/raw.d.ts create mode 100644 src/entry/content/README.md create mode 100644 src/entry/content/adapters/react/Node.test.ts create mode 100644 src/entry/content/adapters/vanilla/Node.test.ts create mode 100644 src/entry/content/core/Context.test.ts create mode 100644 src/entry/content/core/ShadowFontRegistry.test.ts create mode 100644 src/entry/content/core/ShadowFontRegistry.ts create mode 100644 src/entry/content/core/ShadowNode.test.ts create mode 100644 src/entry/content/core/ShadowNode.ts create mode 100644 src/entry/content/core/shadow-styles.ts create mode 100644 tests/integration/browser/content/shadow-styles-utils.ts create mode 100644 tests/integration/browser/content/shadow-styles.firefox.integration.test.ts create mode 100644 tests/integration/browser/content/shadow-styles.integration.test.ts create mode 100644 tests/integration/browser/content/shadow-styles/README.md create mode 100644 tests/integration/browser/content/shadow-styles/adnbn.config.ts create mode 100644 tests/integration/browser/content/shadow-styles/font.py create mode 100644 tests/integration/browser/content/shadow-styles/package.json create mode 100644 tests/integration/browser/content/shadow-styles/site/child.html create mode 100644 tests/integration/browser/content/shadow-styles/site/frames.html create mode 100644 tests/integration/browser/content/shadow-styles/site/top.html create mode 100644 tests/integration/browser/content/shadow-styles/src/normal.content.ts create mode 100644 tests/integration/browser/content/shadow-styles/src/probe.content/index.ts create mode 100644 tests/integration/browser/content/shadow-styles/src/probe.content/lazy.module.css create mode 100644 tests/integration/browser/content/shadow-styles/src/probe.content/lazy.ts create mode 100644 tests/integration/browser/content/shadow-styles/src/probe.content/probe.woff2 create mode 100644 tests/integration/browser/content/shadow-styles/src/probe.content/styles.module.css create mode 100644 tests/integration/browser/content/shadow-styles/src/secondary.content/index.ts create mode 100644 tests/integration/browser/content/shadow-styles/src/secondary.content/lazy.module.css create mode 100644 tests/integration/browser/content/shadow-styles/src/secondary.content/lazy.ts create mode 100644 tests/integration/browser/content/shadow-styles/src/secondary.content/styles.module.css create mode 100644 tests/integration/browser/content/shadow-styles/src/shared/styles.module.css create mode 100644 tests/integration/browser/content/shadow-styles/tsconfig.json create mode 100644 tests/raw-module-resolver.cjs create mode 100644 tests/raw-module-transformer.cjs diff --git a/jest.config.ts b/jest.config.ts index 1c895eff..1b2e3498 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -4,6 +4,7 @@ const config: Config = { verbose: true, testEnvironment: "jsdom", setupFiles: ["/tests/jest.setup.ts"], + resolver: "/tests/raw-module-resolver.cjs", moduleNameMapper: { "^@cli/(.*)$": "/src/cli/$1", "^@entry/(.*)$": "/src/entry/$1", @@ -20,6 +21,7 @@ const config: Config = { }, extensionsToTreatAsEsm: [".ts", ".tsx"], transform: { + "^.+\\.template\\.js$": "/tests/raw-module-transformer.cjs", "^.+\\.(t|j)sx?$": [ "@swc/jest", { diff --git a/src/cli/builders/manifest/ManifestBase.ts b/src/cli/builders/manifest/ManifestBase.ts index d5c30c32..e0e6751e 100644 --- a/src/cli/builders/manifest/ManifestBase.ts +++ b/src/cli/builders/manifest/ManifestBase.ts @@ -601,6 +601,7 @@ export default abstract class implements ManifestBuilder world, matchAboutBlank, matchOriginAsFallback, + shadow, } = script; const dependencies = this.dependencies.get(entry); @@ -610,7 +611,7 @@ export default abstract class implements ManifestBuilder } const js = Array.from(dependencies.js); - const css = Array.from(dependencies.css); + const css = shadow ? [] : Array.from(dependencies.css); if (js.length === 0 && css.length === 0) { throw new ManifestError(`Content script and style entry "${entry}" not found in dependencies`); @@ -814,7 +815,10 @@ export default abstract class implements ManifestBuilder const resources: ManifestAccessibleResource[] = [...this.accessibleResources]; for (const contentScript of this.contentScripts.values()) { - const assets = this.dependencies.get(contentScript.entry)?.assets; + const dependencies = this.dependencies.get(contentScript.entry); + const assets = dependencies + ? new Set([...dependencies.assets, ...(contentScript.shadow ? dependencies.css : [])]) + : undefined; if (assets && assets.size > 0) { resources.push({ diff --git a/src/cli/builders/manifest/ManifestV3.test.ts b/src/cli/builders/manifest/ManifestV3.test.ts index 07f8a0bc..d94acbe8 100644 --- a/src/cli/builders/manifest/ManifestV3.test.ts +++ b/src/cli/builders/manifest/ManifestV3.test.ts @@ -100,6 +100,34 @@ describe("ManifestV3", () => { }); }); + it("keeps shadow initial CSS out of content_scripts and exposes it through WAR", () => { + const manifest: any = new ManifestV3(Browser.Chrome) + .setDependencies( + new Map([ + ["shadow", dependency(["shadow.js"], ["shared.css", "shadow.css"], ["lazy.css"])], + ["normal", dependency(["normal.js"], ["shared.css"], ["normal-lazy.css"])], + ]) + ) + .setContentScripts( + new Set([ + {entry: "shadow", matches: ["https://example.com/*"], shadow: true}, + {entry: "normal", matches: ["https://example.com/*"]}, + ]) + ) + .build(); + + expect(manifest.content_scripts).toEqual( + expect.arrayContaining([ + expect.objectContaining({js: ["shadow.js"], css: undefined}), + expect.objectContaining({js: ["normal.js"], css: ["shared.css"]}), + ]) + ); + expect(manifest.web_accessible_resources[0].resources).toEqual( + expect.arrayContaining(["lazy.css", "normal-lazy.css", "shared.css", "shadow.css"]) + ); + expect(manifest.content_scripts[0]).not.toHaveProperty("shadow"); + }); + it("builds permissions separately from host permissions", () => { const manifest: any = new ManifestV3(Browser.Chrome) .setPermissions(new Set(["storage"])) diff --git a/src/cli/bundler/plugins/BuildAssetsMapPlugin.test.ts b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts similarity index 94% rename from src/cli/bundler/plugins/BuildAssetsMapPlugin.test.ts rename to src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts index cf661bf0..bfc7921a 100644 --- a/src/cli/bundler/plugins/BuildAssetsMapPlugin.test.ts +++ b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts @@ -31,8 +31,8 @@ const UnrelatedFile = "public/unrelated.txt"; const FullMapPlaceholder = "__ADNBN_BUILD_ASSETS_FULL_MAP_PLACEHOLDER__"; const ChunkLoadingGlobal = "buildAssetsFixtureChunks"; -const fixtures = path.resolve(__dirname, "tests", "fixtures", "build-assets"); -const projectRoot = path.resolve(__dirname, "../../../.."); +const fixtures = path.resolve(__dirname, "tests", "fixtures"); +const projectRoot = path.resolve(__dirname, "../../../../.."); interface BuildResult { readonly assets: EntrypointAssetsMap; @@ -232,6 +232,7 @@ const compile = async ( renameBetaCssAtEmit?: boolean; resolvedHashFilename?: boolean; separateRuntime?: boolean; + sharedCssChunk?: boolean; sharedChange?: boolean; } = {} ): Promise => { @@ -342,6 +343,18 @@ const compile = async ( enforce: true, reuseExistingChunk: true, }, + ...(options.sharedCssChunk + ? { + fixtureSharedStyles: { + chunks: "all", + enforce: true, + minChunks: 2, + name: "shared-styles", + test: /[\\/]shared\.css$/, + type: "css/mini-extract", + }, + } + : {}), }, } : false, @@ -362,6 +375,13 @@ const compile = async ( }), ] : []), + ...(options.sharedCssChunk + ? [ + new NormalModuleReplacementPlugin(/shared\.js$/, resource => { + resource.request = path.resolve(fixtures, "shared-with-css.js"); + }), + ] + : []), ...(options.htmlChunks ? [new HtmlRspackPlugin({chunks: [...options.htmlChunks], filename: "fixture.html", hash: true})] : []), @@ -811,6 +831,21 @@ describe("BuildAssetsMapPlugin Plan A", () => { expectNoChangedBytesUnderStableNames(before, after); }); + test("does not invent a JavaScript file for a CSS-only shared chunk", async () => { + const build = await compile(true, {sharedCssChunk: true}); + const sharedCss = build.assets.alpha.initial.css.find(file => build.assets.beta.initial.css.includes(file)); + + expect(sharedCss).toMatch(/^custom\/css\/shared-styles\..+\.css$/); + expect(build.assets.alpha.initial.js.some(file => file.includes("shared-styles"))).toBe(false); + expect(build.assets.beta.initial.js.some(file => file.includes("shared-styles"))).toBe(false); + expect(executeEntrypoint(build, "alpha").sandbox.__alphaEntrypointAssets).toEqual( + runtimeAssets(build.assets.alpha) + ); + expect(executeEntrypoint(build, "beta").sandbox.__betaEntrypointAssets).toEqual( + runtimeAssets(build.assets.beta) + ); + }); + test("keeps configured CSS directories inside callback-generated names", async () => { const [css, styles] = await Promise.all([ compile(false, {buildHashSalt: "css-directory", cssDir: "custom/css"}), diff --git a/src/cli/bundler/plugins/BuildAssetsMapPlugin.ts b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts similarity index 96% rename from src/cli/bundler/plugins/BuildAssetsMapPlugin.ts rename to src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts index a09c3138..e33302f7 100644 --- a/src/cli/bundler/plugins/BuildAssetsMapPlugin.ts +++ b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts @@ -230,6 +230,12 @@ const chunkHasCss = (compilation: Compilation, chunk: Chunk): boolean => { }); }; +const chunkHasJavaScript = (compilation: Compilation, chunk: Chunk): boolean => { + return Array.from(compilation.chunkGraph.getChunkModulesIterable(chunk)).some(module => { + return module.type.startsWith("javascript"); + }); +}; + const getEntrypointRuntimeAssets = ( compilation: Compilation, name: string, @@ -254,14 +260,16 @@ const getEntrypointRuntimeAssets = ( return { initial: { - js: initialChunks.map(chunk => { - return resolveChunkFilename( - compilation, - chunk, - chunk === entryChunk ? jsFilename : jsChunkFilename, - JavaScriptContentHashType - ); - }), + js: initialChunks + .filter(chunk => chunkHasJavaScript(compilation, chunk)) + .map(chunk => { + return resolveChunkFilename( + compilation, + chunk, + chunk === entryChunk ? jsFilename : jsChunkFilename, + JavaScriptContentHashType + ); + }), css: initialChunks .filter(chunk => chunkHasCss(compilation, chunk)) .map(chunk => { @@ -274,9 +282,11 @@ const getEntrypointRuntimeAssets = ( }), }, async: { - js: asyncChunks.map(chunk => { - return resolveChunkFilename(compilation, chunk, jsChunkFilename, JavaScriptContentHashType); - }), + js: asyncChunks + .filter(chunk => chunkHasJavaScript(compilation, chunk)) + .map(chunk => { + return resolveChunkFilename(compilation, chunk, jsChunkFilename, JavaScriptContentHashType); + }), css: asyncChunks .filter(chunk => chunkHasCss(compilation, chunk)) .map(chunk => { @@ -692,7 +702,9 @@ const validateCurrentEntrypointMaps = ( }; if (stringify(runtimeAssets) !== stringify(finalizedAssets)) { - throw new Error(`Build assets changed after the runtime map for entrypoint "${name}" was embedded`); + throw new Error( + `Build assets changed after the runtime map for entrypoint "${name}" was embedded: ${JSON.stringify({embedded: runtimeAssets, finalized: finalizedAssets})}` + ); } } }; diff --git a/src/cli/bundler/plugins/build-assets-map/index.ts b/src/cli/bundler/plugins/build-assets-map/index.ts new file mode 100644 index 00000000..337a9d98 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/index.ts @@ -0,0 +1 @@ +export {default, type BuildAssetsMapPluginOptions} from "./BuildAssetsMapPlugin"; diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.css b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.css similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.css rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.css diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.lazy.css b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.lazy.css similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.lazy.css rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.lazy.css diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.lazy.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.lazy.js similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.lazy.js rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.lazy.js diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.plan-a.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.plan-a.js similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/alpha.plan-a.js rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.plan-a.js diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/background-async.plan-a.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-async.plan-a.js similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/background-async.plan-a.js rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-async.plan-a.js diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/background-shared.plan-a.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-shared.plan-a.js similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/background-shared.plan-a.js rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-shared.plan-a.js diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/background.plan-a.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background.plan-a.js similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/background.plan-a.js rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/background.plan-a.js diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.changed.css b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.changed.css similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/beta.changed.css rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.changed.css diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.css b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.css similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/beta.css rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.css diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.lazy.css b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.lazy.css similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/beta.lazy.css rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.lazy.css diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.lazy.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.lazy.js similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/beta.lazy.js rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.lazy.js diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/beta.plan-a.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.plan-a.js similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/beta.plan-a.js rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.plan-a.js diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/font.woff2 b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/font.woff2 similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/font.woff2 rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/font.woff2 diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/lazy-image.svg b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/lazy-image.svg similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/lazy-image.svg rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/lazy-image.svg diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/payload.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/payload.js similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/payload.js rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/payload.js diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/shared-with-css.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/shared-with-css.js new file mode 100644 index 00000000..86a372b9 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/shared-with-css.js @@ -0,0 +1,3 @@ +import "./shared.css"; + +export const shared = "shared fixture with css"; diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/shared.changed.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/shared.changed.js similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/shared.changed.js rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/shared.changed.js diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/shared.css b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/shared.css new file mode 100644 index 00000000..1e273914 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/shared.css @@ -0,0 +1,3 @@ +.shared { + border-top: 3px solid #435; +} diff --git a/src/cli/bundler/plugins/tests/fixtures/build-assets/shared.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/shared.js similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/build-assets/shared.js rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/shared.js diff --git a/src/cli/bundler/plugins/ChunkLoaderPlugin.test.ts b/src/cli/bundler/plugins/chunk-loader/ChunkLoaderPlugin.test.ts similarity index 97% rename from src/cli/bundler/plugins/ChunkLoaderPlugin.test.ts rename to src/cli/bundler/plugins/chunk-loader/ChunkLoaderPlugin.test.ts index b572e9b1..6a6aeafb 100644 --- a/src/cli/bundler/plugins/ChunkLoaderPlugin.test.ts +++ b/src/cli/bundler/plugins/chunk-loader/ChunkLoaderPlugin.test.ts @@ -8,7 +8,7 @@ import {type Compiler, rspack, type Stats} from "@rspack/core"; import ChunkLoaderPlugin from "./ChunkLoaderPlugin"; -const fixtures = path.resolve(__dirname, "tests", "fixtures", "chunk-loader"); +const fixtures = path.resolve(__dirname, "tests", "fixtures"); const closeCompiler = (compiler: Compiler): Promise => { return new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); diff --git a/src/cli/bundler/plugins/ChunkLoaderPlugin.ts b/src/cli/bundler/plugins/chunk-loader/ChunkLoaderPlugin.ts similarity index 52% rename from src/cli/bundler/plugins/ChunkLoaderPlugin.ts rename to src/cli/bundler/plugins/chunk-loader/ChunkLoaderPlugin.ts index f1199c5a..7814a7a8 100644 --- a/src/cli/bundler/plugins/ChunkLoaderPlugin.ts +++ b/src/cli/bundler/plugins/chunk-loader/ChunkLoaderPlugin.ts @@ -1,5 +1,7 @@ import {type Chunk, type Compiler, RuntimeGlobals, RuntimeModule} from "@rspack/core"; +import {renderChunkLoaderRuntime} from "./templates"; + const PluginName = "ChunkLoaderPlugin"; export interface ChunkLoaderPluginOptions { @@ -12,44 +14,11 @@ class ChunkLoaderRuntimeModule extends RuntimeModule { } public generate(): string { - const ensureChunk = RuntimeGlobals.ensureChunk; - const loadScript = RuntimeGlobals.loadScript; - const publicPath = RuntimeGlobals.publicPath; - - return `var resolveExtensionChunkPublicPath = function() { - if (!${publicPath}) { - var extensionApi = globalThis.browser && globalThis.browser.runtime && globalThis.browser.runtime.getURL - ? globalThis.browser - : globalThis.chrome; - - if (!extensionApi || !extensionApi.runtime || !extensionApi.runtime.getURL) { - return Promise.reject(new Error("Unable to resolve the extension URL for chunk loading")); - } - - ${publicPath} = extensionApi.runtime.getURL("/"); - } - - return Promise.resolve(); - }; - - var ensureExtensionChunk = ${ensureChunk}; - - ${ensureChunk} = function(chunkId) { - return resolveExtensionChunkPublicPath().then(function() { - return ensureExtensionChunk(chunkId); - }); - }; - - ${loadScript} = function(url, done) { - import(url).then( - function() { - done({type: "load", target: {src: url}}); - }, - function(error) { - done({type: "error", target: {src: url}, error: error}); - } - ); - };`; + return renderChunkLoaderRuntime({ + ensureChunk: RuntimeGlobals.ensureChunk, + loadScript: RuntimeGlobals.loadScript, + publicPath: RuntimeGlobals.publicPath, + }); } } diff --git a/src/cli/bundler/plugins/chunk-loader/index.ts b/src/cli/bundler/plugins/chunk-loader/index.ts new file mode 100644 index 00000000..0f649986 --- /dev/null +++ b/src/cli/bundler/plugins/chunk-loader/index.ts @@ -0,0 +1 @@ +export {default, type ChunkLoaderPluginOptions} from "./ChunkLoaderPlugin"; diff --git a/src/cli/bundler/plugins/chunk-loader/templates.ts b/src/cli/bundler/plugins/chunk-loader/templates.ts new file mode 100644 index 00000000..8c40c96b --- /dev/null +++ b/src/cli/bundler/plugins/chunk-loader/templates.ts @@ -0,0 +1,17 @@ +import runtimeTemplate from "./templates/runtime.template.js?raw"; + +import {renderRuntimeTemplate} from "@cli/bundler/utils/runtime-template"; + +interface ChunkLoaderRuntimeTemplateOptions { + readonly ensureChunk: string; + readonly loadScript: string; + readonly publicPath: string; +} + +export const renderChunkLoaderRuntime = (options: ChunkLoaderRuntimeTemplateOptions): string => { + return renderRuntimeTemplate(runtimeTemplate, { + __ADNBN_ENSURE_CHUNK__: options.ensureChunk, + __ADNBN_LOAD_SCRIPT__: options.loadScript, + __ADNBN_PUBLIC_PATH__: options.publicPath, + }); +}; diff --git a/src/cli/bundler/plugins/chunk-loader/templates/runtime.template.js b/src/cli/bundler/plugins/chunk-loader/templates/runtime.template.js new file mode 100644 index 00000000..2a2f6319 --- /dev/null +++ b/src/cli/bundler/plugins/chunk-loader/templates/runtime.template.js @@ -0,0 +1,35 @@ +var resolveExtensionChunkPublicPath = function () { + if (!__ADNBN_PUBLIC_PATH__) { + var extensionApi = + globalThis.browser && globalThis.browser.runtime && globalThis.browser.runtime.getURL + ? globalThis.browser + : globalThis.chrome; + + if (!extensionApi || !extensionApi.runtime || !extensionApi.runtime.getURL) { + return Promise.reject(new Error("Unable to resolve the extension URL for chunk loading")); + } + + __ADNBN_PUBLIC_PATH__ = extensionApi.runtime.getURL("/"); + } + + return Promise.resolve(); +}; + +var ensureExtensionChunk = __ADNBN_ENSURE_CHUNK__; + +__ADNBN_ENSURE_CHUNK__ = function (chunkId) { + return resolveExtensionChunkPublicPath().then(function () { + return ensureExtensionChunk(chunkId); + }); +}; + +__ADNBN_LOAD_SCRIPT__ = function (url, done) { + import(url).then( + function () { + done({type: "load", target: {src: url}}); + }, + function (error) { + done({type: "error", target: {src: url}, error: error}); + } + ); +}; diff --git a/src/cli/bundler/plugins/tests/fixtures/chunk-loader/entry.js b/src/cli/bundler/plugins/chunk-loader/tests/fixtures/entry.js similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/chunk-loader/entry.js rename to src/cli/bundler/plugins/chunk-loader/tests/fixtures/entry.js diff --git a/src/cli/bundler/plugins/tests/fixtures/chunk-loader/lazy.js b/src/cli/bundler/plugins/chunk-loader/tests/fixtures/lazy.js similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/chunk-loader/lazy.js rename to src/cli/bundler/plugins/chunk-loader/tests/fixtures/lazy.js diff --git a/src/cli/bundler/plugins/tests/fixtures/chunk-loader/unselected.js b/src/cli/bundler/plugins/chunk-loader/tests/fixtures/unselected.js similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/chunk-loader/unselected.js rename to src/cli/bundler/plugins/chunk-loader/tests/fixtures/unselected.js diff --git a/src/cli/bundler/plugins/tests/fixtures/chunk-loader/unselected.lazy.js b/src/cli/bundler/plugins/chunk-loader/tests/fixtures/unselected.lazy.js similarity index 100% rename from src/cli/bundler/plugins/tests/fixtures/chunk-loader/unselected.lazy.js rename to src/cli/bundler/plugins/chunk-loader/tests/fixtures/unselected.lazy.js diff --git a/src/cli/bundler/plugins/index.ts b/src/cli/bundler/plugins/index.ts index 5a16daae..3d21f545 100644 --- a/src/cli/bundler/plugins/index.ts +++ b/src/cli/bundler/plugins/index.ts @@ -9,9 +9,11 @@ export { type EntrypointPluginUpdate, } from "./EntrypointPlugin"; -export {default as ChunkLoaderPlugin, type ChunkLoaderPluginOptions} from "./ChunkLoaderPlugin"; +export {default as ChunkLoaderPlugin, type ChunkLoaderPluginOptions} from "./chunk-loader"; -export {default as BuildAssetsMapPlugin} from "./BuildAssetsMapPlugin"; +export {default as ShadowStylesPlugin, type ShadowStylesPluginOptions} from "./shadow-styles"; + +export {default as BuildAssetsMapPlugin, type BuildAssetsMapPluginOptions} from "./build-assets-map"; export { default as GenerateJsonPlugin, diff --git a/src/cli/bundler/plugins/shadow-styles/ShadowStylesPlugin.test.ts b/src/cli/bundler/plugins/shadow-styles/ShadowStylesPlugin.test.ts new file mode 100644 index 00000000..eebed6a3 --- /dev/null +++ b/src/cli/bundler/plugins/shadow-styles/ShadowStylesPlugin.test.ts @@ -0,0 +1,548 @@ +/** @jest-environment node */ + +import fs from "fs"; +import os from "os"; +import path from "path"; +import vm from "vm"; + +import {type Compiler, CssExtractRspackPlugin, type Filename, rspack, type Stats} from "@rspack/core"; + +import ShadowStylesPlugin, {ShadowStylesRuntimeProperty} from "./ShadowStylesPlugin"; + +const fixtures = path.resolve(__dirname, "tests", "fixtures"); +const projectRoot = path.resolve(__dirname, "../../../../.."); + +interface BuildResult { + readonly close: () => Promise; + readonly compiler: Compiler; + readonly outputPath: string; + readonly stats: Stats; +} + +interface FakeLink { + href: string; + onerror: ((event: {type: string}) => void) | null; + onload: (() => void) | null; + rel: string; + type: string; + remove(): void; +} + +interface FakeRoot { + readonly links: FakeLink[]; + insertBefore(link: FakeLink, container: object): FakeLink; +} + +interface RuntimeApi { + add(root: FakeRoot, target: object, initialStyles: string[]): void; + delete(root: FakeRoot): void; + load(url: string): Promise; +} + +interface RuntimeHarness { + readonly requestedScripts: string[]; + readonly runtime: RuntimeApi; + readonly sandbox: Record; +} + +const closeCompiler = (compiler: Compiler): Promise => { + return new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); +}; + +const runCompiler = (compiler: Compiler): Promise => { + return new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) { + reject(error); + } else if (!stats) { + reject(new Error("Rspack did not return build stats")); + } else if (stats.hasErrors()) { + reject(new Error(stats.toString({all: false, errors: true, errorDetails: true}))); + } else { + resolve(stats); + } + }); + }); +}; + +interface CompileOptions { + context?: string; + filename?: Filename; + mode?: "development" | "production"; + selected?: (entry: string) => boolean; + timeout?: number; +} + +const createCompiler = (outputPath: string, options: CompileOptions = {}): Compiler => { + const filename = options.filename ?? "js/[name].[contenthash:8].js"; + + return rspack({ + context: options.context ?? fixtures, + mode: options.mode ?? "development", + target: ["web", "es2020"], + devtool: false, + entry: { + shadow: "./shadow.js", + normal: "./normal.js", + }, + output: { + path: outputPath, + clean: true, + filename, + chunkFilename: filename, + publicPath: "https://extension.test/", + globalObject: "globalThis", + uniqueName: "shadowStylesFixture", + }, + resolveLoader: { + modules: [path.resolve(projectRoot, "node_modules"), "node_modules"], + }, + module: { + rules: [ + { + test: /\.css$/i, + sideEffects: true, + type: "javascript/auto", + use: [ + CssExtractRspackPlugin.loader, + { + loader: "css-loader", + options: {esModule: true, modules: false}, + }, + ], + }, + ], + }, + optimization: { + minimize: false, + runtimeChunk: false, + splitChunks: false, + }, + plugins: [ + new CssExtractRspackPlugin({ + filename: "css/[name].[contenthash:8].css", + chunkFilename: "css/[name].[contenthash:8].css", + }), + new ShadowStylesPlugin({ + test: options.selected ?? (entry => entry === "shadow"), + timeout: options.timeout ?? 1_000, + }), + ], + }); +}; + +const compile = async (options: CompileOptions = {}): Promise => { + const outputPath = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-shadow-styles-")); + const compiler = createCompiler(outputPath, options); + + try { + const stats = await runCompiler(compiler); + + return { + close: async () => { + await closeCompiler(compiler); + fs.rmSync(outputPath, {force: true, recursive: true}); + }, + compiler, + outputPath, + stats, + }; + } catch (error) { + await closeCompiler(compiler); + fs.rmSync(outputPath, {force: true, recursive: true}); + throw error; + } +}; + +const entryFile = (stats: Stats, entry: string, extension: ".css" | ".js"): string => { + const file = stats.compilation.entrypoints + .get(entry) + ?.getFiles() + .find(candidate => candidate.endsWith(extension)); + + if (!file) { + throw new Error(`${extension} file for entrypoint ${entry} was not found`); + } + + return file; +}; + +const source = (stats: Stats, file: string): string => { + const asset = stats.compilation.getAsset(file); + + if (!asset) { + throw new Error(`Asset ${file} was not found`); + } + + return asset.source.source().toString(); +}; + +const createRoot = (onInsert: (link: FakeLink) => void): FakeRoot => { + const links: FakeLink[] = []; + + return { + links, + insertBefore(link) { + links.push(link); + link.remove = () => { + const index = links.indexOf(link); + + if (index >= 0) links.splice(index, 1); + }; + onInsert(link); + + return link; + }, + }; +}; + +const executeShadowEntrypoint = (result: BuildResult): RuntimeHarness => { + const requestedScripts: string[] = []; + let context: vm.Context; + const sandbox: Record = { + clearTimeout, + console, + Promise, + setTimeout, + }; + + const element = (tagName: string): Record => { + const attributes = new Map(); + + return { + tagName: tagName.toUpperCase(), + getAttribute: (name: string) => attributes.get(name) ?? null, + setAttribute: (name: string, value: string) => attributes.set(name, value), + }; + }; + + const head = { + appendChild(node: Record) { + node.parentNode = head; + + if (node.tagName === "SCRIPT") { + const url = String(node.src); + const filename = url.replace("https://extension.test/", ""); + requestedScripts.push(filename); + vm.runInContext(fs.readFileSync(path.join(result.outputPath, filename), "utf8"), context); + queueMicrotask(() => node.onload?.({target: node, type: "load"})); + } else { + queueMicrotask(() => node.onload?.({target: node, type: "load"})); + } + + return node; + }, + removeChild(node: Record) { + node.parentNode = undefined; + + return node; + }, + }; + + sandbox.document = { + createElement: element, + getElementsByTagName: () => [], + head, + }; + sandbox.globalThis = sandbox; + sandbox.self = sandbox; + context = vm.createContext(sandbox); + + vm.runInContext(source(result.stats, entryFile(result.stats, "shadow", ".js")), context); + + const getRuntime = sandbox.getShadowStylesRuntime as (() => RuntimeApi) | undefined; + + if (!getRuntime) { + throw new Error("Shadow fixture did not expose the style runtime"); + } + + return {requestedScripts, runtime: getRuntime(), sandbox}; +}; + +test("replaces the CSS loading runtime only for selected entrypoints", async () => { + const result = await compile(); + + try { + const shadow = source(result.stats, entryFile(result.stats, "shadow", ".js")); + const normal = source(result.stats, entryFile(result.stats, "normal", ".js")); + + expect(shadow).toContain(ShadowStylesRuntimeProperty); + expect(shadow).toContain("shadowStyleRoots"); + expect(shadow).not.toContain("document.head.appendChild(linkTag)"); + expect(normal).not.toContain(ShadowStylesRuntimeProperty); + expect(normal).toContain("document.head.appendChild(linkTag)"); + } finally { + await result.close(); + } +}); + +test("loads initial and async CSS into every active root and keeps requested styles for late roots", async () => { + const result = await compile(); + + try { + const harness = executeShadowEntrypoint(result); + const initialUrl = `https://extension.test/${entryFile(result.stats, "shadow", ".css")}`; + const inserted: string[] = []; + const rootA = createRoot(link => { + inserted.push(`a:${link.href}`); + queueMicrotask(() => link.onload?.()); + }); + const rootB = createRoot(link => { + inserted.push(`b:${link.href}`); + queueMicrotask(() => link.onload?.()); + }); + + harness.runtime.add(rootA, {}, [initialUrl]); + harness.runtime.add(rootB, {}, [initialUrl]); + await (harness.sandbox.loadShadowStyles as () => Promise)(); + + const lazyCss = result.stats.compilation + .getAssets() + .map(asset => asset.name) + .find(file => file.includes("shadow-lazy") && file.endsWith(".css")); + + expect(lazyCss).toBeDefined(); + expect(inserted).toEqual( + expect.arrayContaining([ + `a:${initialUrl}`, + `b:${initialUrl}`, + `a:https://extension.test/${lazyCss}`, + `b:https://extension.test/${lazyCss}`, + ]) + ); + expect(rootA.links).toHaveLength(2); + expect(rootB.links).toHaveLength(2); + + const lateRoot = createRoot(link => queueMicrotask(() => link.onload?.())); + harness.runtime.add(lateRoot, {}, [initialUrl]); + await Promise.resolve(); + await Promise.resolve(); + + expect(lateRoot.links.map(link => link.href)).toEqual([initialUrl, `https://extension.test/${lazyCss}`]); + expect(harness.requestedScripts).toEqual([expect.stringMatching(/shadow-lazy.*\.js$/)]); + } finally { + await result.close(); + } +}); + +test("does not wait for a future root when import happens before rendering", async () => { + const result = await compile(); + + try { + const harness = executeShadowEntrypoint(result); + + await expect((harness.sandbox.loadShadowStyles as () => Promise)()).resolves.toMatchObject({ + shadowLazy: true, + }); + + const root = createRoot(link => queueMicrotask(() => link.onload?.())); + harness.runtime.add(root, {}, []); + await Promise.resolve(); + await Promise.resolve(); + + expect(root.links).toHaveLength(1); + expect(root.links[0].href).toContain("shadow-lazy"); + } finally { + await result.close(); + } +}); + +test("reports an initial stylesheet failure without removing the root and permits retry", async () => { + const result = await compile(); + const report = jest.spyOn(console, "error").mockImplementation(() => {}); + + try { + const harness = executeShadowEntrypoint(result); + const initialUrl = `https://extension.test/${entryFile(result.stats, "shadow", ".css")}`; + let attempt = 0; + const root = createRoot(link => { + attempt += 1; + queueMicrotask(() => { + if (attempt === 1) { + link.onerror?.({type: "error"}); + } else { + link.onload?.(); + } + }); + }); + + harness.runtime.add(root, {}, [initialUrl]); + await new Promise(resolve => setImmediate(resolve)); + + expect(report).toHaveBeenCalledWith( + expect.objectContaining({ + code: "CSS_CHUNK_LOAD_FAILED", + request: initialUrl, + type: "error", + }) + ); + expect(root.links).toHaveLength(0); + + await expect(harness.runtime.load(initialUrl)).resolves.toBeUndefined(); + expect(root.links.map(link => link.href)).toEqual([initialUrl]); + } finally { + report.mockRestore(); + await result.close(); + } +}); + +test("rejects failed and timed out styles, removes failed links and permits retry", async () => { + const result = await compile({timeout: 20}); + + try { + const harness = executeShadowEntrypoint(result); + let attempt = 0; + const root = createRoot(link => { + attempt += 1; + + if (attempt === 1) queueMicrotask(() => link.onerror?.({type: "error"})); + if (attempt === 2) queueMicrotask(() => link.onload?.()); + }); + harness.runtime.add(root, {}, []); + + await expect((harness.sandbox.loadShadowStyles as () => Promise)()).rejects.toThrow( + /shadow[\s\S]*https:\/\/extension\.test\/css\/shadow-lazy/i + ); + expect(root.links).toHaveLength(0); + await expect((harness.sandbox.loadShadowStyles as () => Promise)()).resolves.toMatchObject({ + shadowLazy: true, + }); + + harness.runtime.delete(root); + const timedOut = createRoot(link => { + if (!link.href.endsWith("/hanging.css")) queueMicrotask(() => link.onload?.()); + }); + harness.runtime.add(timedOut, {}, []); + + await expect(harness.runtime.load("https://extension.test/css/hanging.css")).rejects.toMatchObject({ + type: "timeout", + request: "https://extension.test/css/hanging.css", + }); + expect(timedOut.links.map(link => link.href)).not.toContain("https://extension.test/css/hanging.css"); + } finally { + await result.close(); + } +}); + +test("settles pending work when a root is removed and does not duplicate successful links", async () => { + const result = await compile(); + + try { + const harness = executeShadowEntrypoint(result); + const root = createRoot(() => undefined); + harness.runtime.add(root, {}, []); + const pending = harness.runtime.load("https://extension.test/css/pending.css"); + + harness.runtime.delete(root); + + await expect(pending).resolves.toBeUndefined(); + await harness.runtime.load("https://extension.test/css/pending.css"); + expect(root.links).toHaveLength(1); + } finally { + await result.close(); + } +}); + +test.each([ + ["contenthash", "js/[name].[contenthash:8].js"], + ["chunkhash", "js/[name].[chunkhash:8].js"], + ["fullhash", "js/[name].[fullhash:8].js"], +] as const)("supports user %s filename templates", async (_hash, filename) => { + const result = await compile({filename, mode: "production"}); + + try { + expect(entryFile(result.stats, "shadow", ".js")).toMatch(/^js\/shadow\.[a-f0-9]{8}\.js$/); + expect(source(result.stats, entryFile(result.stats, "shadow", ".js"))).toContain(ShadowStylesRuntimeProperty); + } finally { + await result.close(); + } +}); + +test("supports callback filename templates and includes the runtime mutation in chunk hashes", async () => { + const filename: Filename = () => "js/[name].[chunkhash:8].js"; + const shadow = await compile({filename, mode: "production"}); + const normal = await compile({filename, mode: "production", selected: () => false}); + + try { + expect(entryFile(shadow.stats, "shadow", ".js")).not.toBe(entryFile(normal.stats, "shadow", ".js")); + } finally { + await shadow.close(); + await normal.close(); + } +}); + +test("switches the selected CSS runtime from shadow to normal and back during watch rebuilds", async () => { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-shadow-styles-watch-")); + const watchFixtures = path.join(temporaryRoot, "fixtures"); + const outputPath = path.join(temporaryRoot, "dist"); + fs.cpSync(fixtures, watchFixtures, {recursive: true}); + fs.writeFileSync(path.join(watchFixtures, "watch-state.js"), "globalThis.shadowWatchState = true;\n"); + fs.writeFileSync( + path.join(watchFixtures, "shadow.js"), + `import "./watch-state.js";\n${fs.readFileSync(path.join(fixtures, "shadow.js"), "utf8")}` + ); + let shadow = true; + const compiler = createCompiler(outputPath, { + context: watchFixtures, + selected: entry => entry === "shadow" && shadow, + }); + const observations: boolean[] = []; + const watchErrors: string[] = []; + + const watcher = compiler.watch({poll: 50}, (error, stats) => { + if (error) { + watchErrors.push(error.message); + return; + } + + if (!stats || stats.hasErrors()) { + watchErrors.push(stats?.toString({all: false, errors: true, errorDetails: true}) ?? "missing stats"); + return; + } + + observations.push( + source(stats, entryFile(stats, "shadow", ".js")).includes("var shadowStyleRoots = new Map()") + ); + + if (observations.length === 1) { + shadow = false; + fs.writeFileSync(path.join(watchFixtures, "watch-state.js"), "globalThis.shadowWatchState = false;\n"); + watcher.invalidate(); + } else if (observations.length === 2) { + shadow = true; + fs.writeFileSync(path.join(watchFixtures, "watch-state.js"), "globalThis.shadowWatchState = true;\n"); + watcher.invalidate(); + } + }); + + try { + await new Promise((resolve, reject) => { + const poll = setInterval(() => { + if (observations.length === 3) { + clearInterval(poll); + clearTimeout(timeout); + resolve(); + } + }, 10); + const timeout = setTimeout(() => { + clearInterval(poll); + reject( + new Error( + `Rspack watch did not finish three rebuilds: ${JSON.stringify({observations, watchErrors})}` + ) + ); + }, 5_000); + }); + + expect(observations).toEqual([true, false, true]); + } finally { + await new Promise((resolve, reject) => { + try { + watcher.close(resolve); + } catch (error) { + reject(error); + } + }); + fs.rmSync(temporaryRoot, {force: true, recursive: true}); + } +}, 15_000); diff --git a/src/cli/bundler/plugins/shadow-styles/ShadowStylesPlugin.ts b/src/cli/bundler/plugins/shadow-styles/ShadowStylesPlugin.ts new file mode 100644 index 00000000..a311d68d --- /dev/null +++ b/src/cli/bundler/plugins/shadow-styles/ShadowStylesPlugin.ts @@ -0,0 +1,123 @@ +import {type Chunk, type Compiler, RuntimeGlobals, RuntimeModule} from "@rspack/core"; + +import {ShadowStylesRuntimeProperty} from "@entry/content/core/shadow-styles"; + +import {renderShadowStylesCssLoader, renderShadowStylesRuntime} from "./templates"; + +const PluginName = "ShadowStylesPlugin"; +const CssLoadingRuntimeIdentifier = "webpack/runtime/css loading"; +const CssRuntimeBoundary = "// object to store loaded CSS chunks"; +const CssFilenameExpression = /var href = (.+?)\(chunkId\);/; + +export {ShadowStylesRuntimeProperty}; + +export interface ShadowStylesPluginOptions { + test: (entry: string) => boolean; + timeout?: number; +} + +class ShadowStylesRuntimeModule extends RuntimeModule { + public constructor( + private readonly entry: string, + private readonly timeout: number + ) { + super(`shadow styles ${entry}`, RuntimeModule.STAGE_ATTACH); + } + + public generate(): string { + return renderShadowStylesRuntime({ + entry: this.entry, + require: RuntimeGlobals.require, + runtimeProperty: ShadowStylesRuntimeProperty, + timeout: this.timeout, + }); + } +} + +const hasAsyncCss = (chunk: Chunk): boolean => { + return Array.from(chunk.getAllAsyncChunks()).some(candidate => { + return [...candidate.files, ...candidate.auxiliaryFiles].some(file => file.endsWith(".css")); + }); +}; + +export default class ShadowStylesPlugin { + private readonly originalRuntimeSources = new WeakMap(); + + public constructor(private readonly options: ShadowStylesPluginOptions) {} + + public apply(compiler: Compiler): void { + const timeout = this.options.timeout ?? compiler.options.output.chunkLoadTimeout ?? 120_000; + + compiler.hooks.thisCompilation.tap(PluginName, compilation => { + const injected = new Set(); + const patched = new Set(); + + compilation.hooks.additionalTreeRuntimeRequirements.tap(PluginName, (chunk, requirements) => { + if (chunk.name === undefined || !this.options.test(chunk.name) || injected.has(chunk)) { + return; + } + + requirements.add(RuntimeGlobals.require); + requirements.add(RuntimeGlobals.publicPath); + injected.add(chunk); + compilation.addRuntimeModule(chunk, new ShadowStylesRuntimeModule(chunk.name, timeout)); + }); + + compilation.hooks.runtimeModule.tap(PluginName, (runtimeModule, chunk) => { + if (chunk.name === undefined || runtimeModule.identifier() !== CssLoadingRuntimeIdentifier) { + return; + } + + const source = runtimeModule.source; + + if (!source || typeof source.source !== "string") { + throw new Error(`CSS loading runtime source for shadow entrypoint "${chunk.name}" is unavailable`); + } + + if (source.source.length === 0) { + return; + } + + const originalSource = this.originalRuntimeSources.get(runtimeModule) ?? source.source; + this.originalRuntimeSources.set(runtimeModule, originalSource); + source.source = originalSource; + + if (!this.options.test(chunk.name)) { + return; + } + + const boundary = originalSource.indexOf(CssRuntimeBoundary); + const filenameExpression = originalSource.match(CssFilenameExpression)?.[1]; + + if (boundary < 0 || !filenameExpression) { + throw new Error(`CSS loading runtime seam for shadow entrypoint "${chunk.name}" is unavailable`); + } + + source.source = renderShadowStylesCssLoader({ + cssFilenameExpression: filenameExpression, + originalRuntime: originalSource.slice(boundary), + publicPath: RuntimeGlobals.publicPath, + require: RuntimeGlobals.require, + runtimeProperty: ShadowStylesRuntimeProperty, + }); + patched.add(chunk); + }); + + compilation.hooks.afterSeal.tap(PluginName, () => { + for (const entrypoint of compilation.entrypoints.values()) { + const chunk = entrypoint.getRuntimeChunk(); + + if ( + chunk && + chunk.name !== undefined && + this.options.test(chunk.name) && + hasAsyncCss(entrypoint.getEntrypointChunk()) && + !patched.has(chunk) + ) { + throw new Error(`CSS loading runtime for shadow entrypoint "${chunk.name}" was not replaced`); + } + } + }); + }); + } +} diff --git a/src/cli/bundler/plugins/shadow-styles/index.ts b/src/cli/bundler/plugins/shadow-styles/index.ts new file mode 100644 index 00000000..6671f61e --- /dev/null +++ b/src/cli/bundler/plugins/shadow-styles/index.ts @@ -0,0 +1 @@ +export {default, ShadowStylesRuntimeProperty, type ShadowStylesPluginOptions} from "./ShadowStylesPlugin"; diff --git a/src/cli/bundler/plugins/shadow-styles/templates.ts b/src/cli/bundler/plugins/shadow-styles/templates.ts new file mode 100644 index 00000000..96a0815b --- /dev/null +++ b/src/cli/bundler/plugins/shadow-styles/templates.ts @@ -0,0 +1,38 @@ +import cssLoaderTemplate from "./templates/css-loader.template.js?raw"; +import runtimeTemplate from "./templates/runtime.template.js?raw"; + +import {renderRuntimeTemplate} from "@cli/bundler/utils/runtime-template"; + +interface ShadowStylesRuntimeTemplateOptions { + readonly entry: string; + readonly require: string; + readonly runtimeProperty: string; + readonly timeout: number; +} + +interface ShadowStylesCssLoaderTemplateOptions { + readonly cssFilenameExpression: string; + readonly originalRuntime: string; + readonly publicPath: string; + readonly require: string; + readonly runtimeProperty: string; +} + +export const renderShadowStylesRuntime = (options: ShadowStylesRuntimeTemplateOptions): string => { + return renderRuntimeTemplate(runtimeTemplate, { + __ADNBN_ENTRY__: JSON.stringify(options.entry), + __ADNBN_REQUIRE__: options.require, + __ADNBN_RUNTIME_PROPERTY__: options.runtimeProperty, + __ADNBN_TIMEOUT__: JSON.stringify(options.timeout), + }); +}; + +export const renderShadowStylesCssLoader = (options: ShadowStylesCssLoaderTemplateOptions): string => { + return renderRuntimeTemplate(cssLoaderTemplate, { + __ADNBN_CSS_FILENAME_EXPRESSION__: options.cssFilenameExpression, + __ADNBN_ORIGINAL_CSS_RUNTIME__: options.originalRuntime, + __ADNBN_PUBLIC_PATH__: options.publicPath, + __ADNBN_REQUIRE__: options.require, + __ADNBN_RUNTIME_PROPERTY__: options.runtimeProperty, + }); +}; diff --git a/src/cli/bundler/plugins/shadow-styles/templates/css-loader.template.js b/src/cli/bundler/plugins/shadow-styles/templates/css-loader.template.js new file mode 100644 index 00000000..c33e19e4 --- /dev/null +++ b/src/cli/bundler/plugins/shadow-styles/templates/css-loader.template.js @@ -0,0 +1,10 @@ +if (typeof document !== "undefined") { + var loadStylesheet = function (chunkId) { + var href = __ADNBN_CSS_FILENAME_EXPRESSION__(chunkId); + var fullhref = __ADNBN_PUBLIC_PATH__ + href; + + return __ADNBN_REQUIRE__.__ADNBN_RUNTIME_PROPERTY__.load(fullhref); + }; + + __ADNBN_ORIGINAL_CSS_RUNTIME__; +} diff --git a/src/cli/bundler/plugins/shadow-styles/templates/runtime.template.js b/src/cli/bundler/plugins/shadow-styles/templates/runtime.template.js new file mode 100644 index 00000000..2424d202 --- /dev/null +++ b/src/cli/bundler/plugins/shadow-styles/templates/runtime.template.js @@ -0,0 +1,108 @@ +var shadowStyleRoots = new Map(); +var shadowRequestedStyles = new Set(); +var shadowStyleTimeout = __ADNBN_TIMEOUT__; +var shadowStyleEntry = __ADNBN_ENTRY__; + +var createShadowStyleError = function (url, type) { + var error = new Error('Loading shadow CSS for entrypoint "' + shadowStyleEntry + '" failed.\n(' + url + ")"); + error.code = "CSS_CHUNK_LOAD_FAILED"; + error.type = type; + error.request = url; + return error; +}; + +var attachShadowStyle = function (root, url) { + var rootState = shadowStyleRoots.get(root); + + if (!rootState) return Promise.resolve(); + + var existing = rootState.styles.get(url); + + if (existing) return existing.promise; + + var resolveStyle; + var rejectStyle; + var promise = new Promise(function (resolve, reject) { + resolveStyle = resolve; + rejectStyle = reject; + }); + var link = document.createElement("link"); + var record = { + link: link, + promise: promise, + reject: rejectStyle, + resolve: resolveStyle, + timer: undefined, + }; + + rootState.styles.set(url, record); + link.rel = "stylesheet"; + link.type = "text/css"; + link.href = url; + + var settle = function (error) { + link.onerror = link.onload = null; + + if (record.timer !== undefined) clearTimeout(record.timer); + + if (error) { + rootState.styles.delete(url); + link.remove(); + rejectStyle(error); + } else { + resolveStyle(); + } + }; + + link.onload = function () { + settle(); + }; + link.onerror = function (event) { + var type = event && event.type ? event.type : "error"; + settle(createShadowStyleError(url, type)); + }; + record.timer = setTimeout(function () { + settle(createShadowStyleError(url, "timeout")); + }, shadowStyleTimeout); + + root.insertBefore(link, rootState.target); + + return promise; +}; + +__ADNBN_REQUIRE__.__ADNBN_RUNTIME_PROPERTY__ = { + add: function (root, target, initialStyles) { + if (shadowStyleRoots.has(root)) return; + + shadowStyleRoots.set(root, {target: target, styles: new Map()}); + + initialStyles.concat(Array.from(shadowRequestedStyles)).forEach(function (url) { + attachShadowStyle(root, url).catch(function (error) { + console.error(error); + }); + }); + }, + delete: function (root) { + var rootState = shadowStyleRoots.get(root); + + if (!rootState) return; + + rootState.styles.forEach(function (record) { + record.link.onerror = record.link.onload = null; + + if (record.timer !== undefined) clearTimeout(record.timer); + + record.resolve(); + }); + shadowStyleRoots.delete(root); + }, + load: function (url) { + shadowRequestedStyles.add(url); + + return Promise.all( + Array.from(shadowStyleRoots.keys(), function (root) { + return attachShadowStyle(root, url); + }) + ).then(function () {}); + }, +}; diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.css b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.css new file mode 100644 index 00000000..76097eb4 --- /dev/null +++ b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.css @@ -0,0 +1,3 @@ +.normal-initial { + color: green; +} diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.js b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.js new file mode 100644 index 00000000..dec49e90 --- /dev/null +++ b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.js @@ -0,0 +1,3 @@ +import "./normal.css"; + +globalThis.loadNormalStyles = () => import(/* webpackChunkName: "normal-lazy" */ "./normal.lazy.js"); diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.lazy.css b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.lazy.css new file mode 100644 index 00000000..cdcb0a8c --- /dev/null +++ b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.lazy.css @@ -0,0 +1,3 @@ +.normal-lazy { + color: orange; +} diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.lazy.js b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.lazy.js new file mode 100644 index 00000000..9d5b1a3f --- /dev/null +++ b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.lazy.js @@ -0,0 +1,3 @@ +import "./normal.lazy.css"; + +export const normalLazy = true; diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.css b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.css new file mode 100644 index 00000000..a3c87121 --- /dev/null +++ b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.css @@ -0,0 +1,3 @@ +.shadow-initial { + color: navy; +} diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.js b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.js new file mode 100644 index 00000000..9694e3fb --- /dev/null +++ b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.js @@ -0,0 +1,4 @@ +import "./shadow.css"; + +globalThis.getShadowStylesRuntime = () => __webpack_require__.__adnbnShadowStyles; +globalThis.loadShadowStyles = () => import(/* webpackChunkName: "shadow-lazy" */ "./shadow.lazy.js"); diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy-a.css b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy-a.css new file mode 100644 index 00000000..16e02580 --- /dev/null +++ b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy-a.css @@ -0,0 +1,3 @@ +.shadow-lazy-a { + color: purple; +} diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy-b.css b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy-b.css new file mode 100644 index 00000000..38e49c58 --- /dev/null +++ b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy-b.css @@ -0,0 +1,3 @@ +.shadow-lazy-b { + background: white; +} diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy.js b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy.js new file mode 100644 index 00000000..84216c79 --- /dev/null +++ b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy.js @@ -0,0 +1,4 @@ +import "./shadow.lazy-a.css"; +import "./shadow.lazy-b.css"; + +export const shadowLazy = true; diff --git a/src/cli/bundler/utils/runtime-template.ts b/src/cli/bundler/utils/runtime-template.ts new file mode 100644 index 00000000..967b34fa --- /dev/null +++ b/src/cli/bundler/utils/runtime-template.ts @@ -0,0 +1,21 @@ +const PlaceholderPattern = /__ADNBN_[A-Z0-9_]+__/g; + +export const renderRuntimeTemplate = (template: string, values: Readonly>): string => { + let runtime = template; + + for (const [placeholder, value] of Object.entries(values)) { + if (!runtime.includes(placeholder)) { + throw new Error(`Runtime template placeholder "${placeholder}" is unavailable`); + } + + runtime = runtime.replaceAll(placeholder, value); + } + + const unresolved = runtime.match(PlaceholderPattern); + + if (unresolved) { + throw new Error(`Runtime template placeholder "${unresolved[0]}" was not replaced`); + } + + return runtime.trim(); +}; diff --git a/src/cli/entrypoint/file/SourceFile.ts b/src/cli/entrypoint/file/SourceFile.ts index 55780ef1..36411d7a 100644 --- a/src/cli/entrypoint/file/SourceFile.ts +++ b/src/cli/entrypoint/file/SourceFile.ts @@ -240,6 +240,10 @@ export default class EntryFile { // imported constant: try to resolve its value from source file const importPath = this.getImports().get(name); if (importPath) { + if (/\.[^.\\/]+$/.test(importPath) && !/\.[cm]?[jt]sx?$/.test(importPath)) { + return name; + } + // load imported file and check its variables const parser = EntryFile.make(importPath); const imported = parser.getVariables().get(name); diff --git a/src/cli/entrypoint/parser/AbstractParser.ts b/src/cli/entrypoint/parser/AbstractParser.ts index 8ef7ea39..ba5debae 100644 --- a/src/cli/entrypoint/parser/AbstractParser.ts +++ b/src/cli/entrypoint/parser/AbstractParser.ts @@ -21,7 +21,7 @@ export default abstract class AbstractParser implem manifestVersion: z.union([z.literal(2), z.literal(3)]).optional(), }); - protected abstract schema(): typeof this.CommonPropertiesSchema; + protected abstract schema(): z.AnyZodObject; protected abstract definition(): string | string[]; diff --git a/src/cli/entrypoint/parser/ContentParser.test.ts b/src/cli/entrypoint/parser/ContentParser.test.ts index 1366a034..95948cfa 100644 --- a/src/cli/entrypoint/parser/ContentParser.test.ts +++ b/src/cli/entrypoint/parser/ContentParser.test.ts @@ -10,8 +10,8 @@ const fixtures = path.resolve(__dirname, "tests", "fixtures", "content"); const parser = new ContentParser({rootDir} as ReadonlyConfig); -const file = (name: string) => { - const filename = path.join(fixtures, "options", "world", name); +const file = (...parts: string[]) => { + const filename = path.join(fixtures, ...parts); return {file: filename, import: filename}; }; @@ -21,10 +21,35 @@ describe("ContentParser", () => { ["enum.content.ts", ContentScriptWorld.Main], ["string.content.ts", ContentScriptWorld.Isolated], ])("parses the execution world from %s", (name, world) => { - expect(parser.options(file(name))).toEqual({ + expect(parser.options(file("options", "world", name))).toEqual({ matches: ["http://*/*", "https://*/*"], runAt: "document_idle", world, }); }); + + test.each([ + ["true.content.ts", true], + ["false.content.ts", false], + ])("parses the shadow literal from %s", (name, shadow) => { + expect(parser.options(file("options", "shadow", name))).toEqual({ + matches: ["http://*/*", "https://*/*"], + runAt: "document_idle", + shadow, + }); + }); + + test("collapses a shadow options object containing imported runtime values to one build bit", () => { + expect(parser.options(file("options", "shadow", "object", "entry.content.ts"))).toEqual({ + matches: ["http://*/*", "https://*/*"], + runAt: "document_idle", + shadow: true, + }); + }); + + test("rejects a shadow flag that cannot be evaluated during the build", () => { + expect(() => parser.options(file("invalid", "shadow-identifier.content.ts"))).toThrow( + /shadow must be a boolean literal or an object/ + ); + }); }); diff --git a/src/cli/entrypoint/parser/ContentParser.ts b/src/cli/entrypoint/parser/ContentParser.ts index e82b7fc9..467fc475 100644 --- a/src/cli/entrypoint/parser/ContentParser.ts +++ b/src/cli/entrypoint/parser/ContentParser.ts @@ -16,7 +16,7 @@ export default class true)]).optional(), }); } public options(file: EntrypointFile): O { + const shadow = this.getOptions(file).shadow; + + if ( + shadow !== undefined && + typeof shadow !== "boolean" && + (typeof shadow !== "object" || shadow === null || Array.isArray(shadow)) + ) { + throw new Error(`Invalid options shadow in "${file.file}": shadow must be a boolean literal or an object`); + } + const options = super.options(file); return { diff --git a/src/cli/entrypoint/parser/RelayParser.test.ts b/src/cli/entrypoint/parser/RelayParser.test.ts index abb8c037..06330be8 100644 --- a/src/cli/entrypoint/parser/RelayParser.test.ts +++ b/src/cli/entrypoint/parser/RelayParser.test.ts @@ -27,4 +27,8 @@ describe("RelayParser", () => { }) ); }); + + test("does not project the content shadow option into Relay build options", () => { + expect(parser.options(file("options", "shadow", "relay.ts"))).not.toHaveProperty("shadow"); + }); }); diff --git a/src/cli/entrypoint/parser/RelayParser.ts b/src/cli/entrypoint/parser/RelayParser.ts index 903914c2..f67a7e20 100644 --- a/src/cli/entrypoint/parser/RelayParser.ts +++ b/src/cli/entrypoint/parser/RelayParser.ts @@ -15,21 +15,24 @@ export default class extends ContentParser { return "init"; } - protected schema(): typeof this.CommonPropertiesSchema { - return super.schema().extend({ - allFrames: z.union([z.boolean(), z.nativeEnum(RelayAllFrames)]).optional(), - name: z - .string() - .trim() - .min(1) - .max(100) - .regex(/^[\p{L}_$][\p{L}\p{N}_$]*$/u, { - message: - "Key must start with a Unicode letter, `$` or `_`, and may only contain letters, digits, `$` or `_`", - }) - .optional(), - method: z.nativeEnum(RelayMethod).optional(), - }); + protected schema() { + return super + .schema() + .omit({shadow: true}) + .extend({ + allFrames: z.union([z.boolean(), z.nativeEnum(RelayAllFrames)]).optional(), + name: z + .string() + .trim() + .min(1) + .max(100) + .regex(/^[\p{L}_$][\p{L}\p{N}_$]*$/u, { + message: + "Key must start with a Unicode letter, `$` or `_`, and may only contain letters, digits, `$` or `_`", + }) + .optional(), + method: z.nativeEnum(RelayMethod).optional(), + }); } public options(file: EntrypointFile): RelayEntrypointOptions { diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow-identifier.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow-identifier.content.ts new file mode 100644 index 00000000..65cccf2f --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow-identifier.content.ts @@ -0,0 +1,7 @@ +import {defineContentScript} from "adnbn"; + +declare const SOME_IMPORTED_FLAG: boolean; + +export default defineContentScript({ + shadow: SOME_IMPORTED_FLAG, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/false.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/false.content.ts new file mode 100644 index 00000000..8e00d815 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/false.content.ts @@ -0,0 +1,5 @@ +import {defineContentScript} from "adnbn"; + +export default defineContentScript({ + shadow: false, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/object/entry.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/object/entry.content.ts new file mode 100644 index 00000000..839a1d57 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/object/entry.content.ts @@ -0,0 +1,15 @@ +import {defineContentScript} from "adnbn"; + +import panelFont from "./panel.woff2"; + +export default defineContentScript({ + shadow: { + fonts: [ + { + family: "AdnbnPanelInter", + source: panelFont, + weight: "400", + }, + ], + }, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/object/panel.woff2 b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/object/panel.woff2 new file mode 100644 index 00000000..e69de29b diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/true.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/true.content.ts new file mode 100644 index 00000000..cc0c1422 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/true.content.ts @@ -0,0 +1,5 @@ +import {defineContentScript} from "adnbn"; + +export default defineContentScript({ + shadow: true, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/options/shadow/relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/options/shadow/relay.ts new file mode 100644 index 00000000..3b04c0a6 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/shadow/relay.ts @@ -0,0 +1,7 @@ +import {defineRelay} from "adnbn"; + +export default defineRelay({ + // @ts-expect-error Relay entrypoints do not support Shadow DOM rendering. + shadow: true, + init: () => ({scan: () => true}), +}); diff --git a/src/cli/plugins/asset.ts b/src/cli/plugins/asset.ts index 3d6877f3..cc2f9c36 100644 --- a/src/cli/plugins/asset.ts +++ b/src/cli/plugins/asset.ts @@ -18,6 +18,10 @@ export default definePlugin(() => { }, module: { rules: [ + { + test: /\.(woff2?|eot|ttf|otf)$/i, + type: "asset/resource", + }, { test: /\.(png|apng|jpe?g|gif|webp|svg|avif|tiff|bmp|ico)$/i, resourceQuery: { diff --git a/src/cli/plugins/content/ContentManager.test.ts b/src/cli/plugins/content/ContentManager.test.ts index d49b5bda..453e20de 100644 --- a/src/cli/plugins/content/ContentManager.test.ts +++ b/src/cli/plugins/content/ContentManager.test.ts @@ -181,4 +181,109 @@ describe("ContentManager execution worlds", () => { await expect(manager.entries()).resolves.toEqual(new Map([["renamed.content", new Set([renamed])]])); }); + + test("normalizes MAIN shadow entrypoints to ISOLATED for Manifest V2", async () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + const file = {file: "shadow.content.ts", import: "./shadow.content"}; + const items: ContentItems = new Map([ + ["shadow", {file, options: {shadow: true, world: ContentScriptWorld.Main}}], + ]); + const manager = new ContentManager({ + manifestVersion: 2, + concatContentScripts: true, + rootDir: process.cwd(), + } as ReadonlyConfig).provider(new ProviderFixture(new DriverFixture(new Set(), new Set(), items))); + + try { + await expect(manager.entryWorlds()).resolves.toEqual( + new Map([["shadow.content", ContentScriptWorld.Isolated]]) + ); + await expect(manager.entryShadows()).resolves.toEqual(new Map([["shadow.content", true]])); + } finally { + warn.mockRestore(); + } + }); + + test("rejects Shadow DOM in the MAIN world for Manifest V3", async () => { + const file = {file: "shadow.content.ts", import: "./shadow.content"}; + const items: ContentItems = new Map([ + ["shadow", {file, options: {shadow: true, world: ContentScriptWorld.Main}}], + ]); + const manager = new ContentManager({ + manifestVersion: 3, + concatContentScripts: false, + rootDir: process.cwd(), + } as ReadonlyConfig).provider(new ProviderFixture(new DriverFixture(new Set(), new Set(), items))); + + await expect(manager.entries()).rejects.toThrow(/cannot use Shadow DOM in the MAIN execution world/); + }); +}); + +describe("ContentManager Shadow DOM entries", () => { + test("never concatenates shadow entries and keeps ordinary entries eligible for concatenation", async () => { + const matches = ["https://example.com/*"]; + const normalA = {file: "normal-a.content.ts", import: "./normal-a.content"}; + const normalB = {file: "normal-b.content.ts", import: "./normal-b.content"}; + const shadowA = {file: "shadow-a.content.ts", import: "./shadow-a.content"}; + const shadowB = {file: "shadow-b.content.ts", import: "./shadow-b.content"}; + const items: ContentItems = new Map([ + ["normal-a", {file: normalA, options: {matches}}], + ["normal-b", {file: normalB, options: {matches}}], + ["shadow-a", {file: shadowA, options: {matches, shadow: true}}], + ["shadow-b", {file: shadowB, options: {matches, shadow: true}}], + ]); + const manager = new ContentManager({ + manifestVersion: 3, + concatContentScripts: true, + rootDir: process.cwd(), + } as ReadonlyConfig).provider(new ProviderFixture(new DriverFixture(new Set(), new Set(), items))); + + await expect(manager.entries()).resolves.toEqual( + new Map([ + ["normal-a.content", new Set([normalA, normalB])], + ["shadow-a.content", new Set([shadowA])], + ["shadow-b.content", new Set([shadowB])], + ]) + ); + await expect(manager.entryShadows()).resolves.toEqual( + new Map([ + ["normal-a.content", false], + ["shadow-a.content", true], + ["shadow-b.content", true], + ]) + ); + }); + + test("recalculates shadow mode after clear for watch rebuilds", async () => { + const file = {file: "changing.content.ts", import: "./changing.content"}; + const driver = new DriverFixture( + new Set(), + new Set(), + new Map([["changing", {file, options: {shadow: true}}]]) + ); + const manager = new ContentManager({ + manifestVersion: 3, + concatContentScripts: false, + rootDir: process.cwd(), + } as ReadonlyConfig).provider(new ProviderFixture(driver)); + + await expect(manager.entryShadows()).resolves.toEqual(new Map([["changing.content", true]])); + await expect(manager.manifest()).resolves.toEqual( + new Set([expect.objectContaining({entry: "changing.content", shadow: true})]) + ); + + driver.setItems(new Map([["changing", {file, options: {shadow: false}}]])); + manager.clear(); + await expect(manager.entryShadows()).resolves.toEqual(new Map([["changing.content", false]])); + await expect(manager.manifest()).resolves.toEqual( + new Set([expect.objectContaining({entry: "changing.content", shadow: false})]) + ); + + driver.setItems(new Map([["changing", {file, options: {shadow: true}}]])); + manager.clear(); + await expect(manager.entryShadows()).resolves.toEqual(new Map([["changing.content", true]])); + await expect(manager.manifest()).resolves.toEqual( + new Set([expect.objectContaining({entry: "changing.content", shadow: true})]) + ); + }); }); diff --git a/src/cli/plugins/content/ContentManager.ts b/src/cli/plugins/content/ContentManager.ts index 7a17dece..8906c569 100644 --- a/src/cli/plugins/content/ContentManager.ts +++ b/src/cli/plugins/content/ContentManager.ts @@ -68,6 +68,10 @@ export default class ContentManager { const world = this.resolveWorld(options.world); if (this.config.manifestVersion !== 2) { + if (world === ContentScriptWorld.Main && options.shadow) { + throw new Error(`Content script "${file.file}" cannot use Shadow DOM in the MAIN execution world`); + } + return options; } @@ -105,7 +109,7 @@ export default class ContentManager { return {...acc, ...opt}; }, {} as ContentScriptEntrypointOptions); - manifest.add({entry, ...getContentScriptConfigFromOptions(options)}); + manifest.add({entry, shadow: !!options.shadow, ...getContentScriptConfigFromOptions(options)}); } return manifest; @@ -230,6 +234,23 @@ export default class ContentManager { return entries; } + public async entryShadows(): Promise> { + const entries = new Map(); + + for (const [entry, items] of await this.group()) { + const shadows = new Set(Array.from(items, ({options}) => !!options.shadow)); + const shadow = shadows.values().next().value; + + if (shadows.size !== 1 || shadow === undefined) { + throw new Error(`Content entrypoint "${entry}" cannot mix Shadow DOM modes`); + } + + entries.set(entry, shadow); + } + + return entries; + } + protected resolveWorld(world?: ContentScriptWorldValue): ContentScriptWorld { switch (world) { case undefined: diff --git a/src/cli/plugins/content/ContentName.ts b/src/cli/plugins/content/ContentName.ts index 06aec37c..0316f87f 100644 --- a/src/cli/plugins/content/ContentName.ts +++ b/src/cli/plugins/content/ContentName.ts @@ -17,7 +17,7 @@ export default class ContentName extends NameGenerator implements ContentNameGen } public create(name: string, options: ContentScriptEntrypointOptions): string { - if (!this.config.concatContentScripts) { + if (!this.config.concatContentScripts || options.shadow) { return this.name(name); } diff --git a/src/cli/plugins/content/index.ts b/src/cli/plugins/content/index.ts index 6048f2a3..288cd215 100644 --- a/src/cli/plugins/content/index.ts +++ b/src/cli/plugins/content/index.ts @@ -9,7 +9,7 @@ import {getContentChunkName, getContentLayer} from "./utils"; import {definePlugin} from "@main/plugin"; -import {ChunkLoaderPlugin, EntrypointPlugin, onlyViaTopLevelEntry} from "@cli/bundler"; +import {ChunkLoaderPlugin, EntrypointPlugin, onlyViaTopLevelEntry, ShadowStylesPlugin} from "@cli/bundler"; import {Command} from "@typing/app"; import {ContentScriptWorld} from "@typing/content"; @@ -51,6 +51,7 @@ export default definePlugin(() => { const entries = await manager.entries(); let entryWorlds = await manager.entryWorlds(); + let entryShadows = await manager.entryShadows(); const getEntryWorld = (name: string): ContentScriptWorld => { const world = entryWorlds.get(name); @@ -84,6 +85,7 @@ export default definePlugin(() => { const entries = await manager.entries(); entryWorlds = await manager.entryWorlds(); + entryShadows = await manager.entryShadows(); return entries; }); @@ -119,6 +121,9 @@ export default definePlugin(() => { new ChunkLoaderPlugin({ test: entry => entryWorlds.get(entry) === ContentScriptWorld.Isolated, }), + new ShadowStylesPlugin({ + test: entry => entryShadows.get(entry) === true, + }), ], optimization: config.commonChunks ? { diff --git a/src/cli/raw.d.ts b/src/cli/raw.d.ts new file mode 100644 index 00000000..b3cac61d --- /dev/null +++ b/src/cli/raw.d.ts @@ -0,0 +1,4 @@ +declare module "*?raw" { + const content: string; + export default content; +} diff --git a/src/cli/virtual/virtual.d.ts b/src/cli/virtual/virtual.d.ts index 079755d0..102dc9c8 100644 --- a/src/cli/virtual/virtual.d.ts +++ b/src/cli/virtual/virtual.d.ts @@ -1,9 +1,3 @@ -// Only placeholders belong here; real adnbn imports resolve to their source modules via tsconfig.json. -declare module "*?raw" { - const content: string; - export default content; -} - declare module "virtual:background-entrypoint" { type BackgroundDefinition = import("@typing/background").BackgroundDefinition; diff --git a/src/entry/content/README.md b/src/entry/content/README.md new file mode 100644 index 00000000..9686d31f --- /dev/null +++ b/src/entry/content/README.md @@ -0,0 +1,79 @@ +# Content entrypoints + +## Shadow DOM + +Set `shadow: true` to render a content entrypoint inside an open `ShadowRoot`. The framework keeps +the existing host element and its `mount` or `append` placement, creates an inner render target, +and points the React or Vanilla adapter at that container. + +```tsx +import {ContentScriptAppend, ContentScriptWorld, defineContentScriptAppend} from "adnbn"; + +import Panel from "./Panel"; + +export default defineContentScriptAppend({ + matches: ["https://example.com/*"], + world: ContentScriptWorld.Isolated, + shadow: true, + anchor: ".product", + append: ContentScriptAppend.After, + render: Panel, +}); +``` + +Shadow content entrypoints always remain separate bundler entrypoints, even when +`concatContentScripts` is enabled. `commonChunks` remains supported, so ordinary and shadow +entrypoints may still consume the same dependency or CSS chunk. The manifest loads CSS normally for +ordinary content scripts and exposes the same file through web accessible resources when a shadow +entrypoint needs it. + +Shadow DOM is supported in the effective `ISOLATED` world. `MAIN` with `shadow` fails in Manifest V3. +Manifest V2 normalizes a requested `MAIN` world to `ISOLATED` and prints a build warning before +grouping and bundling the entrypoint. + +Initial styles are extension files linked inside every root. Rendering starts immediately, so a +brief unstyled frame is possible while those files load. Lazy CSS is requested with its `import()`; +the import waits for the roots active at the start of that request. A late root receives initial CSS +and every lazy stylesheet already requested by its entrypoint. Failed and timed-out lazy styles reject +the import and can be retried. The timeout follows `output.chunkLoadTimeout`. + +Each entrypoint owns its style registry on its Rspack runtime. No background bundle, global `window` +registry, JSON map, or carrier file is added to another entrypoint. + +## Local fonts + +Browsers do not reliably register `@font-face` declared only inside a shadow stylesheet. Import a +local font and list it in `shadow.fonts` instead: + +```tsx +import panelFont from "./panel.woff2"; + +export default defineContentScriptAppend({ + shadow: { + fonts: [ + { + family: "AdnbnPanelInter", + source: panelFont, + weight: "400", + style: "normal", + }, + ], + }, + render: Panel, +}); +``` + +Supported inputs are local `.woff`, `.woff2`, `.eot`, `.ttf`, and `.otf` imports. The emitted file +keeps the configured asset filename and hash pattern. At runtime the framework creates a `FontFace`, +adds it to `document.fonts`, and starts loading it without delaying the UI. Duplicate definitions in +one entrypoint are ignored. + +Use a family name that is unlikely to collide with the page or another content entrypoint. Registered +faces remain observable in `document.fonts` until the document ends, including after the UI unmounts. +External URLs, Google Fonts, and external `@import` are unsupported: they depend on page CSP, expose a +third-party request, and do not solve shadow font registration. Self-host a local `.woff2` instead. + +File stylesheets and local fonts are covered in Chrome 155 MV3 and Firefox 155 MV2/MV3, including a +page that rejects document styles and fonts through `style-src`, `style-src-elem`, and `font-src`. +Those checks also cover top documents, iframes, multiple roots, two shadow entrypoints, shared CSS, +lazy CSS, unmount, and remount. diff --git a/src/entry/content/adapters/react/Node.test.ts b/src/entry/content/adapters/react/Node.test.ts new file mode 100644 index 00000000..bd80b17e --- /dev/null +++ b/src/entry/content/adapters/react/Node.test.ts @@ -0,0 +1,44 @@ +import {createRoot} from "react-dom/client"; + +import ReactNode from "./Node"; + +import type {ContentScriptNode} from "@typing/content"; + +const render = jest.fn(); +const unmount = jest.fn(); + +jest.mock("react-dom/client", () => ({ + createRoot: jest.fn(), +})); + +describe("ReactNode", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(createRoot).mockReturnValue({render, unmount} as never); + }); + + test("renders into the target while preserving the outer host", () => { + const anchor = document.createElement("div"); + const container = document.createElement("section"); + const target = document.createElement("div"); + const node: ContentScriptNode = { + anchor, + container, + target, + mount: jest.fn(() => true), + unmount: jest.fn(() => true), + }; + const component = "content"; + const reactNode = new ReactNode(node, component); + + expect(reactNode.mount()).toBe(true); + expect(node.mount).toHaveBeenCalledTimes(1); + expect(createRoot).toHaveBeenCalledWith(target); + expect(createRoot).not.toHaveBeenCalledWith(container); + expect(render).toHaveBeenCalledWith(component); + + expect(reactNode.unmount()).toBe(true); + expect(unmount).toHaveBeenCalledTimes(1); + expect(node.unmount).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/entry/content/adapters/react/Node.ts b/src/entry/content/adapters/react/Node.ts index 3e00dc7e..3366d4c2 100644 --- a/src/entry/content/adapters/react/Node.ts +++ b/src/entry/content/adapters/react/Node.ts @@ -19,10 +19,14 @@ export default class implements ContentScriptNode { return this.node.container; } + public get target(): Element | undefined { + return this.node.target; + } + public mount(): boolean { this.node.mount(); - if (!this.container || this.root) { + if (!this.target || this.root) { return false; } @@ -32,7 +36,7 @@ export default class implements ContentScriptNode { return false; } - this.root = createRoot(this.container); + this.root = createRoot(this.target); this.root.render(this.component); diff --git a/src/entry/content/adapters/vanilla/Node.test.ts b/src/entry/content/adapters/vanilla/Node.test.ts new file mode 100644 index 00000000..b31364d2 --- /dev/null +++ b/src/entry/content/adapters/vanilla/Node.test.ts @@ -0,0 +1,28 @@ +import VanillaNode from "./Node"; + +import type {ContentScriptNode} from "@typing/content"; + +describe("VanillaNode", () => { + test("renders into the target while preserving the outer host", () => { + const anchor = document.createElement("div"); + const container = document.createElement("section"); + const target = document.createElement("div"); + const value = document.createElement("span"); + const node: ContentScriptNode = { + anchor, + container, + target, + mount: jest.fn(() => true), + unmount: jest.fn(() => true), + }; + const vanillaNode = new VanillaNode(node, value); + + expect(vanillaNode.mount()).toBe(true); + expect(node.mount).toHaveBeenCalledTimes(1); + expect(target.contains(value)).toBe(true); + expect(container.contains(value)).toBe(false); + + expect(vanillaNode.unmount()).toBe(true); + expect(node.unmount).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/entry/content/adapters/vanilla/Node.ts b/src/entry/content/adapters/vanilla/Node.ts index f6d85beb..dfafb1a0 100644 --- a/src/entry/content/adapters/vanilla/Node.ts +++ b/src/entry/content/adapters/vanilla/Node.ts @@ -16,19 +16,23 @@ export default class implements ContentScriptNode { return this.node.container; } + public get target(): Element | undefined { + return this.node.target; + } + public mount(): boolean { this.node.mount(); - if (!this.container || this.mounted) { + if (!this.target || this.mounted) { return false; } let result: boolean = true; if (this.value instanceof Element) { - this.container.appendChild(this.value); + this.target.appendChild(this.value); } else if (typeof this.value === "string" || typeof this.value === "number") { - this.container.textContent = String(this.value); + this.target.textContent = String(this.value); } else if (this.value === null || this.value === undefined || this.value === false) { result = false; diff --git a/src/entry/content/core/Builder.ts b/src/entry/content/core/Builder.ts index 8b20d03d..51342ead 100644 --- a/src/entry/content/core/Builder.ts +++ b/src/entry/content/core/Builder.ts @@ -16,6 +16,7 @@ import ManagedContext from "./ManagedContext"; import EventEmitter from "./EventEmitter"; import AttributeMarker from "./AttributeMarker"; import WeakMarker from "./WeakMarker"; +import ShadowFontRegistry from "./ShadowFontRegistry"; import { ContentScriptAnchor, @@ -38,6 +39,8 @@ import { ContentScriptRenderHandler, ContentScriptRenderValue, ContentScriptResolvedDefinition, + ContentScriptShadow, + ContentScriptShadowOptions, ContentScriptWatchStrategy, } from "@typing/content"; @@ -56,6 +59,8 @@ export default abstract class extends Builder implements ContentScriptBuilder { protected unwatch?: () => void; + private readonly fonts: ShadowFontRegistry; + protected abstract createNode(anchor: Element): Promise; protected abstract cleanupNode(anchor: Element): Awaiter; @@ -70,8 +75,10 @@ export default abstract class extends Builder implements ContentScriptBuilder { mount: this.resolveMount(definition.mount), container: this.resolveContainer(definition.container), render: this.resolveRender(definition.render), + shadow: this.resolveShadow(definition.shadow), watch: this.resolveWatch(definition.watch), }; + this.fonts = new ShadowFontRegistry(this.definition.shadow?.fonts); } protected resolveMarker(marker: ContentScriptMarkerType | ContentScriptMarkerGetter): ContentScriptMarkerResolver { @@ -133,6 +140,14 @@ export default abstract class extends Builder implements ContentScriptBuilder { return contentScriptLocationResolver(watch); } + protected resolveShadow(shadow?: ContentScriptShadow): ContentScriptShadowOptions | undefined { + if (!shadow) { + return; + } + + return shadow === true ? {} : shadow; + } + public getContext(): ContentScriptContext { return this.context; } @@ -144,12 +159,16 @@ export default abstract class extends Builder implements ContentScriptBuilder { this.marker = await marker(options); + this.fonts.register(); + await main?.(this.context, options); if (render !== undefined) { await this.processing(); this.unwatch = watch(() => { + this.context.mount(); + this.processing().catch(e => { console.error("Content script processing on watch error", e); }); @@ -161,6 +180,8 @@ export default abstract class extends Builder implements ContentScriptBuilder { this.unwatch?.(); this.unwatch = undefined; + this.context.clear(); + this.context.unwatch(); this.marker.reset(); } diff --git a/src/entry/content/core/Context.test.ts b/src/entry/content/core/Context.test.ts new file mode 100644 index 00000000..6e7e5f45 --- /dev/null +++ b/src/entry/content/core/Context.test.ts @@ -0,0 +1,29 @@ +import Context from "./Context"; + +import type {ContentScriptEventEmitter, ContentScriptNode} from "@typing/content"; + +describe("Context", () => { + test("clears every node after unmounting it and emits removal", () => { + const remove = jest.fn(); + const emitter = { + emitRemove: remove, + } as unknown as ContentScriptEventEmitter; + const context = new (class extends Context { + public add(node: ContentScriptNode): void { + this.collection.add(node); + } + })(emitter); + const node = { + anchor: document.createElement("div"), + mount: jest.fn(), + unmount: jest.fn(() => true), + } satisfies ContentScriptNode; + context.add(node); + + context.clear(); + + expect(node.unmount).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalledWith(node); + expect(context.nodes.size).toBe(0); + }); +}); diff --git a/src/entry/content/core/Context.ts b/src/entry/content/core/Context.ts index 1a77d5ef..9eeb4eb0 100644 --- a/src/entry/content/core/Context.ts +++ b/src/entry/content/core/Context.ts @@ -43,6 +43,14 @@ export default class implements ContentScriptContext { } } + public clear(): void { + for (const node of this.collection) { + node.unmount(); + this.collection.delete(node); + this.emitter.emitRemove(node); + } + } + public watch(callback: ContentScriptEventCallback): () => void { this.emitter.on(callback); diff --git a/src/entry/content/core/EventNode.ts b/src/entry/content/core/EventNode.ts index 6c5316f5..b48cfb4b 100644 --- a/src/entry/content/core/EventNode.ts +++ b/src/entry/content/core/EventNode.ts @@ -14,6 +14,10 @@ export default class implements ContentScriptNode { return this.node.container; } + public get target(): Element | undefined { + return this.node.target; + } + public mount(): boolean { const result = this.node.mount(); diff --git a/src/entry/content/core/MarkerNode.ts b/src/entry/content/core/MarkerNode.ts index 90ba0e7b..c97a17d1 100644 --- a/src/entry/content/core/MarkerNode.ts +++ b/src/entry/content/core/MarkerNode.ts @@ -14,6 +14,10 @@ export default class implements ContentScriptNode { return this.node.container; } + public get target(): Element | undefined { + return this.node.target; + } + public mount(): boolean | undefined | void { this.marker.mount(this.anchor); diff --git a/src/entry/content/core/MountBuilder.ts b/src/entry/content/core/MountBuilder.ts index 94d63b82..bd332e2d 100644 --- a/src/entry/content/core/MountBuilder.ts +++ b/src/entry/content/core/MountBuilder.ts @@ -2,6 +2,7 @@ import Builder from "./Builder"; import Node from "./Node"; import MountNode from "./MountNode"; import MarkerNode from "./MarkerNode"; +import ShadowNode from "./ShadowNode"; import {ContentScriptNode, ContentScriptProps, ContentScriptRenderValue} from "@typing/content"; @@ -9,7 +10,7 @@ export default abstract class extends Builder { private values = new Map(); protected getProps(anchor: Element): ContentScriptProps { - const {anchor: _, mount, watch, render, container, main, ...options} = this.definition; + const {anchor: _, mount, watch, render, container, main, shadow, ...options} = this.definition; return {...options, anchor}; } @@ -43,7 +44,9 @@ export default abstract class extends Builder { container = (await this.definition.container(this.getProps(anchor))) as Element | undefined; } - return new MountNode(new MarkerNode(new Node(anchor, container), this.marker), this.definition.mount); + const node = new MountNode(new MarkerNode(new Node(anchor, container), this.marker), this.definition.mount); + + return this.definition.shadow ? new ShadowNode(node) : node; } protected cleanupNode(anchor: Element): void { diff --git a/src/entry/content/core/MountNode.ts b/src/entry/content/core/MountNode.ts index 6d0a632b..db1643dc 100644 --- a/src/entry/content/core/MountNode.ts +++ b/src/entry/content/core/MountNode.ts @@ -16,6 +16,10 @@ export default class implements ContentScriptNode { return this.node.container; } + public get target(): Element | undefined { + return this.node.target; + } + public mount(): boolean { this.node.mount(); diff --git a/src/entry/content/core/Node.ts b/src/entry/content/core/Node.ts index 0ce397de..d855d443 100644 --- a/src/entry/content/core/Node.ts +++ b/src/entry/content/core/Node.ts @@ -12,6 +12,10 @@ export default class implements ContentScriptNode { } } + public get target(): Element | undefined { + return this.container; + } + public mount(): boolean { if (!this.container && this._container) { this.container = this._container.cloneNode(false) as Element; diff --git a/src/entry/content/core/ShadowFontRegistry.test.ts b/src/entry/content/core/ShadowFontRegistry.test.ts new file mode 100644 index 00000000..a1ac7e68 --- /dev/null +++ b/src/entry/content/core/ShadowFontRegistry.test.ts @@ -0,0 +1,80 @@ +import {getUrl} from "@addon-core/browser"; + +import ShadowFontRegistry from "./ShadowFontRegistry"; + +jest.mock("@addon-core/browser", () => ({ + getUrl: jest.fn((file: string) => `chrome-extension://fixture/${file}`), +})); + +class FontFaceFixture { + public static instances: FontFaceFixture[] = []; + + public static loadError?: Error; + + public readonly load = jest.fn(() => + FontFaceFixture.loadError ? Promise.reject(FontFaceFixture.loadError) : Promise.resolve(this) + ); + + public constructor( + public readonly family: string, + public readonly source: string, + public readonly descriptors?: FontFaceDescriptors + ) { + FontFaceFixture.instances.push(this); + } +} + +describe("ShadowFontRegistry", () => { + const add = jest.fn(); + + beforeEach(() => { + FontFaceFixture.instances = []; + FontFaceFixture.loadError = undefined; + add.mockReset(); + jest.clearAllMocks(); + Object.defineProperty(globalThis, "FontFace", {configurable: true, value: FontFaceFixture}); + Object.defineProperty(document, "fonts", {configurable: true, value: {add}}); + }); + + test("registers each local definition once for the lifetime of the entry runtime", () => { + const registry = new ShadowFontRegistry([ + {family: "AdnbnPanelInter", source: "assets/panel.woff2", weight: "400"}, + {family: "AdnbnPanelInter", source: "assets/panel.woff2", weight: "400"}, + ]); + + registry.register(); + registry.register(); + + expect(FontFaceFixture.instances).toHaveLength(1); + expect(FontFaceFixture.instances[0]).toMatchObject({ + family: "AdnbnPanelInter", + source: 'url("chrome-extension://fixture/assets/panel.woff2")', + descriptors: {weight: "400"}, + }); + expect(add).toHaveBeenCalledWith(FontFaceFixture.instances[0]); + expect(FontFaceFixture.instances[0].load).toHaveBeenCalledTimes(1); + expect(getUrl).toHaveBeenCalledWith("assets/panel.woff2"); + }); + + test("reports a rejected font load without throwing into UI rendering", async () => { + const error = new Error("font unavailable"); + const report = jest.spyOn(console, "error").mockImplementation(() => {}); + FontFaceFixture.loadError = error; + const registry = new ShadowFontRegistry([{family: "BrokenFont", source: "assets/broken.woff2"}]); + + registry.register(); + await Promise.resolve(); + + new ShadowFontRegistry([{family: "RemoteFont", source: "https://fonts.example/font.woff2"}]).register(); + + expect(report).toHaveBeenCalledWith( + 'Loading shadow font "BrokenFont" from "assets/broken.woff2" failed', + error + ); + expect(report).toHaveBeenCalledWith( + 'Loading shadow font "RemoteFont" from "https://fonts.example/font.woff2" failed', + expect.any(Error) + ); + report.mockRestore(); + }); +}); diff --git a/src/entry/content/core/ShadowFontRegistry.ts b/src/entry/content/core/ShadowFontRegistry.ts new file mode 100644 index 00000000..0e546c2a --- /dev/null +++ b/src/entry/content/core/ShadowFontRegistry.ts @@ -0,0 +1,49 @@ +import {getUrl} from "@addon-core/browser"; + +import type {ContentScriptShadowFontOptions} from "@typing/content"; + +const RemoteSource = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i; + +export default class ShadowFontRegistry { + private registered = false; + + public constructor(private readonly fonts: readonly ContentScriptShadowFontOptions[] = []) {} + + public register(): void { + if (this.registered) { + return; + } + + this.registered = true; + const definitions = new Set(); + + for (const {family, source, ...descriptors} of this.fonts) { + const descriptorEntries = Object.entries(descriptors).sort(([left], [right]) => left.localeCompare(right)); + const key = JSON.stringify([family, source, descriptorEntries]); + + if (definitions.has(key)) { + continue; + } + + definitions.add(key); + + try { + if (!source || RemoteSource.test(source)) { + throw new Error("font source must be a local imported asset"); + } + + const url = getUrl(source); + const font = new FontFace(family, `url(${JSON.stringify(url)})`, descriptors); + + document.fonts.add(font); + font.load().catch(error => this.report(family, source, error)); + } catch (error) { + this.report(family, source, error); + } + } + } + + private report(family: string, source: string, error: unknown): void { + console.error(`Loading shadow font "${family}" from "${source}" failed`, error); + } +} diff --git a/src/entry/content/core/ShadowNode.test.ts b/src/entry/content/core/ShadowNode.test.ts new file mode 100644 index 00000000..73d84a00 --- /dev/null +++ b/src/entry/content/core/ShadowNode.test.ts @@ -0,0 +1,116 @@ +import {getUrl} from "@addon-core/browser"; + +import {getEntrypointAssets} from "@main/entrypoint"; + +import MountNode from "./MountNode"; +import Node from "./Node"; +import ShadowNode from "./ShadowNode"; +import {getShadowStylesRuntime, type ShadowStylesRuntime} from "./shadow-styles"; + +jest.mock("@addon-core/browser", () => ({ + getUrl: jest.fn((file: string) => `chrome-extension://fixture/${file}`), +})); + +jest.mock("@main/entrypoint", () => ({ + getEntrypointAssets: jest.fn(() => ({ + initial: {js: ["content.js"], css: ["first.css", "second.css"]}, + async: {js: [], css: []}, + })), +})); + +jest.mock("./shadow-styles", () => ({ + getShadowStylesRuntime: jest.fn(), +})); + +const createNode = (runtime: ShadowStylesRuntime, host = document.createElement("section")) => { + const anchor = document.createElement("div"); + document.body.appendChild(anchor); + jest.mocked(getShadowStylesRuntime).mockReturnValue(runtime); + + return { + anchor, + host, + node: new ShadowNode( + new MountNode(new Node(anchor, host), (_anchor, container) => { + anchor.appendChild(container); + }) + ), + }; +}; + +describe("ShadowNode", () => { + beforeEach(() => { + document.body.innerHTML = ""; + jest.clearAllMocks(); + }); + + test("creates an open root, registers initial styles and exposes the render target", () => { + const runtime: ShadowStylesRuntime = { + add: jest.fn((root, target, styles) => { + for (const style of styles) { + const link = document.createElement("link"); + link.href = style; + root.insertBefore(link, target); + } + }), + delete: jest.fn(), + load: jest.fn(async () => undefined), + }; + const {host, node} = createNode(runtime); + + expect(node.mount()).toBe(true); + expect(host.shadowRoot).not.toBeNull(); + expect(node.target).toBe(host.shadowRoot?.lastElementChild); + expect(node.target).not.toBe(host); + expect(runtime.add).toHaveBeenCalledWith(host.shadowRoot, node.target, [ + "chrome-extension://fixture/first.css", + "chrome-extension://fixture/second.css", + ]); + expect(Array.from(host.shadowRoot?.children ?? []).map(element => element.tagName)).toEqual([ + "LINK", + "LINK", + "DIV", + ]); + expect(getEntrypointAssets).toHaveBeenCalledTimes(1); + expect(getUrl).toHaveBeenCalledTimes(2); + }); + + test("unregisters the root before removing the host and creates a new root on remount", () => { + const states: boolean[] = []; + const runtime: ShadowStylesRuntime = { + add: jest.fn(), + delete: jest.fn(root => states.push(root.host.isConnected)), + load: jest.fn(async () => undefined), + }; + const {anchor, node} = createNode(runtime); + + node.mount(); + const firstHost = node.container; + const firstRoot = firstHost?.shadowRoot; + + expect(node.unmount()).toBe(true); + expect(states).toEqual([true]); + expect(firstHost?.isConnected).toBe(false); + + expect(node.mount()).toBe(true); + expect(node.container).not.toBe(firstHost); + expect(node.container?.shadowRoot).not.toBe(firstRoot); + expect(node.target?.parentNode).toBe(node.container?.shadowRoot); + expect(anchor.contains(node.container ?? null)).toBe(true); + expect(runtime.add).toHaveBeenCalledTimes(2); + }); + + test("rejects a container with an existing open ShadowRoot", () => { + const runtime: ShadowStylesRuntime = { + add: jest.fn(), + delete: jest.fn(), + load: jest.fn(async () => undefined), + }; + const host = document.createElement("section"); + host.attachShadow({mode: "open"}); + const {node} = createNode(runtime, host); + + expect(() => node.mount()).toThrow("Content script container already has an open ShadowRoot"); + expect(runtime.add).not.toHaveBeenCalled(); + }); +}); diff --git a/src/entry/content/core/ShadowNode.ts b/src/entry/content/core/ShadowNode.ts new file mode 100644 index 00000000..63e583c3 --- /dev/null +++ b/src/entry/content/core/ShadowNode.ts @@ -0,0 +1,72 @@ +import {getUrl} from "@addon-core/browser"; + +import {getEntrypointAssets} from "@main/entrypoint"; + +import {getShadowStylesRuntime, type ShadowStylesRuntime} from "./shadow-styles"; + +import type {ContentScriptNode} from "@typing/content"; + +export default class ShadowNode implements ContentScriptNode { + private root?: ShadowRoot; + + private _target?: Element; + + private runtime?: ShadowStylesRuntime; + + public constructor(protected readonly node: ContentScriptNode) {} + + public get anchor(): Element { + return this.node.anchor; + } + + public get container(): Element | undefined { + return this.node.container; + } + + public get target(): Element | undefined { + return this._target; + } + + public mount(): boolean { + const mounted = !!this.node.mount(); + + if (!this.container || this.root) { + return mounted; + } + + if (!("attachShadow" in this.container) || typeof this.container.attachShadow !== "function") { + throw new Error("Content script container does not support Shadow DOM"); + } + + if (this.container.shadowRoot) { + throw new Error("Content script container already has an open ShadowRoot"); + } + + const root = this.container.attachShadow({mode: "open"}); + const target = document.createElement("div"); + root.appendChild(target); + + const runtime = getShadowStylesRuntime(); + const initialStyles = getEntrypointAssets().initial.css.map(file => getUrl(file)); + + runtime.add(root, target, initialStyles); + + this.root = root; + this._target = target; + this.runtime = runtime; + + return mounted; + } + + public unmount(): boolean { + if (this.root) { + this.runtime?.delete(this.root); + } + + this.root = undefined; + this._target = undefined; + this.runtime = undefined; + + return !!this.node.unmount(); + } +} diff --git a/src/entry/content/core/shadow-styles.ts b/src/entry/content/core/shadow-styles.ts new file mode 100644 index 00000000..4a3b31e2 --- /dev/null +++ b/src/entry/content/core/shadow-styles.ts @@ -0,0 +1,25 @@ +export const ShadowStylesRuntimeProperty = "__adnbnShadowStyles"; + +export interface ShadowStylesRuntime { + add(root: ShadowRoot, target: Element, initialStyles: string[]): void; + delete(root: ShadowRoot): void; + load(url: string): Promise; +} + +interface WebpackRuntime { + (moduleId: string | number): unknown; + __adnbnShadowStyles?: ShadowStylesRuntime; +} + +declare const __webpack_require__: WebpackRuntime; + +export const getShadowStylesRuntime = (): ShadowStylesRuntime => { + const runtime = + typeof __webpack_require__ === "function" ? __webpack_require__[ShadowStylesRuntimeProperty] : undefined; + + if (!runtime) { + throw new Error("Shadow styles runtime is unavailable in this content entrypoint"); + } + + return runtime; +}; diff --git a/src/types/content.ts b/src/types/content.ts index 1dc9e1e7..1ae48d5d 100644 --- a/src/types/content.ts +++ b/src/types/content.ts @@ -86,7 +86,27 @@ export interface ContentScriptConfig { export type ContentScriptOptions = ContentScriptConfig & EntrypointOptions; -export type ContentScriptEntrypointOptions = Partial; +export type ContentScriptEntrypointOptions = Partial & { + shadow?: boolean; +}; + +/** + * Renders the content UI inside an open ShadowRoot. An object enables Shadow DOM and configures + * the document-level resources required by that UI. + */ +export type ContentScriptShadow = boolean | ContentScriptShadowOptions; + +export interface ContentScriptShadowOptions { + /** Local imported font files registered for this entrypoint through FontFace. */ + fonts?: ContentScriptShadowFontOptions[]; +} + +export interface ContentScriptShadowFontOptions extends FontFaceDescriptors { + /** A family name unique enough not to collide with the host page or another entrypoint. */ + family: string; + /** A local font import emitted by the framework asset pipeline. Remote URLs are unsupported. */ + source: string; +} // Append export enum ContentScriptAppend { @@ -245,12 +265,16 @@ export type ContentScriptMainFunction = (context: ContentScriptContext, options: export interface ContentScriptNode extends ContentScriptMount { anchor: Element; container?: Element; + + /** Element used by the active adapter as the destination for rendered UI. */ + target?: Element; } export type ContentScriptNodeSet = Set; // Definition -export interface ContentScriptDefinition extends ContentScriptEntrypointOptions { +export interface ContentScriptDefinition extends Omit { + shadow?: ContentScriptShadow; marker?: ContentScriptMarkerType | ContentScriptMarkerGetter; anchor?: ContentScriptAnchor | ContentScriptAnchorGetter; mount?: ContentScriptMountFunction; @@ -261,7 +285,8 @@ export interface ContentScriptDefinition extends ContentScriptEntrypointOptions } // prettier-ignore -export interface ContentScriptResolvedDefinition extends Omit { +export interface ContentScriptResolvedDefinition extends Omit { + shadow?: ContentScriptShadowOptions; marker: ContentScriptMarkerResolver; anchor: ContentScriptAnchorGetter; mount: ContentScriptMountFunction; diff --git a/src/types/manifest.ts b/src/types/manifest.ts index a311001b..bd7dfe2c 100644 --- a/src/types/manifest.ts +++ b/src/types/manifest.ts @@ -198,7 +198,11 @@ export interface ManifestEntry { export type ManifestBackground = ManifestEntry & BackgroundConfig; -export type ManifestContentScript = ManifestEntry & ContentScriptConfig; +export type ManifestContentScript = ManifestEntry & + ContentScriptConfig & { + /** Internal build flag. It is never emitted to manifest.json. */ + shadow?: boolean; + }; export type ManifestContentScripts = Set; export type ManifestCommand = CommandConfig; diff --git a/src/types/relay.ts b/src/types/relay.ts index 6f8c6fad..beccd0f0 100644 --- a/src/types/relay.ts +++ b/src/types/relay.ts @@ -190,7 +190,7 @@ export type RelayMainHandler = ( export interface RelayDefinition extends Omit, "main">, - Omit, + Omit, RelayEntrypointOptions { main?: RelayMainHandler; } diff --git a/tests/integration/README.md b/tests/integration/README.md index 08dfd2bd..d785586f 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -14,7 +14,11 @@ tests/integration/ │ │ ├── entrypoint-assets.integration.test.ts │ │ ├── entrypoint-assets.firefox.integration.test.ts │ │ ├── utils.ts -│ │ └── entrypoint-assets/ +│ │ ├── entrypoint-assets/ +│ │ ├── shadow-styles.integration.test.ts +│ │ ├── shadow-styles.firefox.integration.test.ts +│ │ ├── shadow-styles-utils.ts +│ │ └── shadow-styles/ │ ├── offscreen/ │ │ ├── service.integration.test.ts │ │ └── service/ @@ -47,7 +51,7 @@ Install dependencies once at the repository root, then run: npm run prepare:integration ``` -This builds the framework, links each fixture's declared dependencies to the local framework or root `node_modules`, and builds all five applications. The generated `.adnbn` configuration and declarations remain beside their sources, so the editor can resolve `adnbn`, virtual imports such as `adnbn/browser`, CSS/SVG modules, and generated transport contracts. +This builds the framework, links each fixture's declared dependencies to the local framework or root `node_modules`, and builds all six applications. The generated `.adnbn` configuration and declarations remain beside their sources, so the editor can resolve `adnbn`, virtual imports such as `adnbn/browser`, CSS/SVG modules, and generated transport contracts. No separate install or lockfile is needed in each fixture. Dependency versions come from the root installation. Preparation can be repeated; matching links are reused, while conflicting existing dependency locations produce an error instead of being overwritten. @@ -82,6 +86,7 @@ Tests copy application inputs to unique directories under `.cache/integration`. - `browser/options`: two Chrome MV3 cases covering Vanilla and React rendering, CSS, state/events, opening Options from background, and a View chunk shared with a Page. - `browser/offscreen/service`: one Chrome MV3 round trip from background through Offscreen to a registered background service. - `browser/content/entrypoint-assets`: one Chrome MV3 case and two Firefox cases (MV2 and MV3) using the same application and probe assertions. They cover current asset getters, rejecting the full-map getter outside background, common chunks, dynamic imports, CSS/SVG resource URLs, world separation, and top/child frames. The Chrome case also reads the full-map readiness flag in background. +- `browser/content/shadow-styles`: production Shadow DOM coverage in Chrome MV3 and Firefox MV2/MV3. Two shadow entrypoints and one ordinary entrypoint verify file-backed initial/lazy/shared CSS, independent runtime registries, strict CSP, local `FontFace` registration, watch-driven remount, top documents, and child iframes. The shared CSS remains manifest CSS for the ordinary consumer and a web accessible resource for the shadow consumers. In MV3, ISOLATED retains physical async chunks and MAIN includes dynamic dependencies in its initial graph. In Addon Bone's MV2 pipeline, every content entry uses ISOLATED, including entries requesting MAIN. Such requests emit a build warning before entrypoint grouping; the runtime test verifies async JS/CSS and the absence of page-visible globals for the downgraded entries. This is a framework policy, not a claim that all Firefox versions lack native MAIN support in MV2. diff --git a/tests/integration/browser/content/shadow-styles-utils.ts b/tests/integration/browser/content/shadow-styles-utils.ts new file mode 100644 index 00000000..a3fd17fc --- /dev/null +++ b/tests/integration/browser/content/shadow-styles-utils.ts @@ -0,0 +1,290 @@ +import {spawn, type ChildProcess} from "child_process"; +import {mkdtemp, readFile, rm, stat, writeFile} from "fs/promises"; +import os from "os"; +import path from "path"; + +import {getFreePort, stop, waitFor} from "../utils/browser"; +import {browserVersion, CdpClient, findChromeBinary} from "../utils/chrome"; +import BidiClient from "../utils/BidiClient"; +import {findFirefoxBinary} from "../utils/firefox"; +import {startIntegrationSite, type IntegrationSite} from "../utils/site"; +import {createIntegrationFixture, type IntegrationFixture} from "../../utils/fixture"; + +interface ShadowProbe { + readonly anchor?: string; + readonly asyncCss?: string; + readonly error?: string; + readonly font?: string; + readonly frame?: string; + readonly initialCss?: string; + readonly instance?: string; + readonly kind?: string; + readonly links: string[]; + readonly ready?: string; + readonly sharedCss?: string; +} + +interface ShadowDocumentState { + readonly normalBorder: string; + readonly normalReady: boolean; + readonly outsideColor: string; + readonly pageUrl: string; + readonly probes: ShadowProbe[]; +} + +interface FrameState { + readonly child: ShadowDocumentState; + readonly top: ShadowDocumentState; +} + +const DocumentStateExpression = `(document => { + const view = document.defaultView; + const hosts = Array.from(document.querySelectorAll("[data-shadow-probe]")); + const probes = hosts.map(host => { + const root = host.shadowRoot; + const result = root && root.querySelector("[data-shadow-result]"); + return { + ...(result ? Object.fromEntries(Object.entries(result.dataset)) : {ready: "missing"}), + instance: host.getAttribute("data-instance") || undefined, + kind: host.getAttribute("data-shadow-probe") || undefined, + links: root ? Array.from(root.querySelectorAll('link[rel="stylesheet"]'), link => link.href) : [], + }; + }); + const primary = hosts.find(host => host.getAttribute("data-shadow-probe") === "primary"); + const primaryResult = primary && primary.shadowRoot && primary.shadowRoot.querySelector("[data-shadow-result]"); + const outside = document.querySelector("#outside"); + if (outside && primaryResult && outside.className !== primaryResult.className) { + outside.className = primaryResult.className; + } + const normal = document.querySelector("[data-normal-probe]"); + return { + pageUrl: document.location.href, + probes, + normalReady: normal?.getAttribute("data-normal-probe") === "ready", + normalBorder: normal ? view.getComputedStyle(normal).borderTopWidth : "missing", + outsideColor: outside ? view.getComputedStyle(outside).color : "missing", + }; +})`; + +const StrictCsp = + "default-src 'none'; style-src 'none'; style-src-elem 'none'; font-src 'none'; frame-src 'self'; img-src 'self'"; + +const isReady = (state: ShadowDocumentState | undefined, expected: number): state is ShadowDocumentState => { + return ( + state !== undefined && + state.probes.length === expected && + state.probes.every(probe => probe.ready === "true" && !probe.error) && + state.normalReady + ); +}; + +const expectDocument = (state: ShadowDocumentState, frame: "top" | "child", primaryCount: number): void => { + const primary = state.probes.filter(probe => probe.kind === "primary"); + const secondary = state.probes.filter(probe => probe.kind === "secondary"); + + expect(primary).toHaveLength(primaryCount); + expect(secondary).toHaveLength(1); + expect(state.normalReady).toBe(true); + expect(state.normalBorder).toBe("3px"); + expect(state.outsideColor).not.toBe("rgb(17, 85, 153)"); + + for (const probe of state.probes) { + expect(probe.frame).toBe(frame); + expect(probe.initialCss).toBe("applied"); + expect(probe.asyncCss).toBe("applied"); + expect(probe.sharedCss).toBe("applied"); + expect(probe.links.length).toBeGreaterThanOrEqual(3); + expect(probe.links.every(url => /^(chrome|moz)-extension:\/\//.test(url))).toBe(true); + } + + expect(primary.every(probe => probe.font === "applied")).toBe(true); +}; + +export const runShadowStylesIntegration = async (name: "chrome" | "firefox", manifestVersion: 2 | 3): Promise => { + const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); + const binary = name === "chrome" ? findChromeBinary(rootDir) : findFirefoxBinary(); + if (!binary || !path.isAbsolute(binary)) throw new Error("Install " + name + " or set its ADNBN_*_BIN path"); + + const profile = await mkdtemp(path.join(os.tmpdir(), "adnbn-shadow-integration-")); + let browserProcess: ChildProcess | undefined; + let chrome: CdpClient | undefined; + let firefox: BidiClient | undefined; + let fixture: IntegrationFixture | undefined; + let site: IntegrationSite | undefined; + let processOutput = ""; + let lastState: unknown; + + try { + fixture = await createIntegrationFixture(rootDir, path.join(__dirname, "shadow-styles")); + const extensionDir = await fixture.build({browser: name, manifestVersion}); + const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); + const scripts = manifest.content_scripts as Array<{css?: string[]; js: string[]}>; + const resources: string[] = + manifestVersion === 2 + ? manifest.web_accessible_resources + : manifest.web_accessible_resources.flatMap((entry: {resources: string[]}) => entry.resources); + const normal = scripts.find(script => (script.css?.length ?? 0) > 0); + const shadows = scripts.filter(script => (script.css?.length ?? 0) === 0); + + expect(scripts).toHaveLength(3); + expect(normal).toBeDefined(); + expect(shadows).toHaveLength(2); + expect(scripts.every(script => script.js.every(file => !/background/i.test(file)))).toBe(true); + const sharedCss = normal!.css!.find(file => resources.includes(file)); + expect(sharedCss).toMatch(/^css\/shared-styles\.[a-f0-9]{8}\.css$/); + expect(resources.some(file => /^assets\/probe\.[a-f0-9]{8}\.woff2$/.test(file))).toBe(true); + expect(resources.some(file => /^css\/.+\.[a-f0-9]{8}\.css$/.test(file))).toBe(true); + + for (const file of new Set([ + ...scripts.flatMap(script => [...script.js, ...(script.css ?? [])]), + ...resources, + ])) { + await expect(stat(path.join(extensionDir, file))).resolves.toBeDefined(); + } + + const port = await getFreePort(); + let navigate: (url: string) => Promise; + let evaluate: (expression: string) => Promise; + let version: string; + + if (name === "chrome") { + browserProcess = spawn( + binary, + [ + "--headless=new", + "--no-sandbox", + "--no-first-run", + "--no-default-browser-check", + "--remote-debugging-port=" + port, + "--user-data-dir=" + profile, + "about:blank", + ], + {stdio: ["ignore", "ignore", "pipe"]} + ); + browserProcess.stderr?.on("data", chunk => (processOutput += chunk)); + const {webSocketDebuggerUrl} = await waitFor(() => browserVersion(port)); + chrome = await CdpClient.connect(webSocketDebuggerUrl); + version = (await chrome.send("Browser.getVersion")).product; + const extension = await chrome.send("Extensions.loadUnpacked", {path: extensionDir}); + expect(typeof extension.id).toBe("string"); + const {targetId} = await chrome.send("Target.createTarget", {url: "about:blank"}); + const {sessionId} = await chrome.send("Target.attachToTarget", {targetId, flatten: true}); + await chrome.send("Runtime.enable", {}, sessionId); + await chrome.send("Page.enable", {}, sessionId); + navigate = url => chrome!.send("Page.navigate", {url}, sessionId); + evaluate = async expression => { + const result = await chrome!.send( + "Runtime.evaluate", + {expression, awaitPromise: true, returnByValue: true}, + sessionId + ); + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + } else { + browserProcess = spawn( + binary, + [ + "--headless", + "--no-remote", + "--profile", + profile, + "--remote-debugging-port", + String(port), + "about:blank", + ], + {stdio: ["ignore", "ignore", "pipe"]} + ); + browserProcess.stderr?.on("data", chunk => (processOutput += chunk)); + firefox = await waitFor(() => BidiClient.connect("ws://127.0.0.1:" + port + "/session")); + const session = await firefox.send("session.new", {capabilities: {alwaysMatch: {}}}); + version = "Firefox/" + session.capabilities.browserVersion; + const extension = await firefox.send("webExtension.install", { + extensionData: {path: extensionDir, type: "path"}, + }); + expect(typeof extension.extension).toBe("string"); + const {context} = await firefox.send("browsingContext.create", {type: "tab"}); + navigate = url => firefox!.send("browsingContext.navigate", {context, url, wait: "complete"}); + evaluate = expression => firefox!.evaluate(context, expression); + } + + const measurements: Array<{policy: string; top: ShadowDocumentState; frames: FrameState}> = []; + for (const policy of [null, StrictCsp]) { + site = await startIntegrationSite(path.join(fixture.directory, "site"), policy); + const topUrl = site.origin + "/top.html"; + const response = await fetch(topUrl); + expect(response.headers.get("content-security-policy")).toBe(policy); + await response.arrayBuffer(); + await navigate(topUrl); + const top = await waitFor(async () => { + const state = await evaluate(`${DocumentStateExpression}(document)`); + lastState = state; + return state?.pageUrl === topUrl && isReady(state, 3) ? state : undefined; + }, 30_000); + expectDocument(top, "top", 2); + + const oldInstance = Number(top.probes.find(probe => probe.anchor === "first")?.instance); + await evaluate(`(() => { + document.querySelector('[data-shadow-primary="first"]')?.remove(); + const replacement = document.createElement("div"); + replacement.setAttribute("data-shadow-primary", "first"); + document.body.append(replacement); + })()`); + const remounted = await waitFor(async () => { + const state = await evaluate(`${DocumentStateExpression}(document)`); + lastState = state; + const instance = Number(state?.probes.find(probe => probe.anchor === "first")?.instance); + return isReady(state, 3) && instance > oldInstance ? state : undefined; + }, 30_000); + expectDocument(remounted, "top", 2); + + const framesUrl = site.origin + "/frames.html"; + await navigate(framesUrl); + const frames = await waitFor(async () => { + const state = await evaluate(`(() => { + const child = document.querySelector('[data-testid="child-frame"]')?.contentDocument; + if (!child || child.readyState !== "complete") return undefined; + return {top: ${DocumentStateExpression}(document), child: ${DocumentStateExpression}(child)}; + })()`); + lastState = state; + return isReady(state?.top, 2) && isReady(state?.child, 2) ? state : undefined; + }, 30_000); + expectDocument(frames.top, "top", 1); + expectDocument(frames.child, "child", 1); + measurements.push({policy: policy === null ? "none" : "strict", top: remounted, frames}); + await site.close(); + site = undefined; + } + + const report = {browser: version, manifestVersion, measurements}; + const reportPath = path.join( + rootDir, + ".cache", + "integration", + "shadow-styles-" + name + "-mv" + manifestVersion + ".json" + ); + await writeFile(reportPath, JSON.stringify(report, null, 2) + "\n"); + console.info(JSON.stringify(report, null, 2)); + expect(version).toMatch(name === "chrome" ? /^Chrome\/155\./ : /^Firefox\/155\./); + expect(name === "chrome" ? chrome?.runtimeErrors : firefox?.runtimeErrors).toEqual([]); + } catch (error) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}; last state: ${JSON.stringify(lastState)}; runtime errors: ${JSON.stringify(chrome?.runtimeErrors ?? firefox?.runtimeErrors ?? [])}; browser output: ${processOutput}`, + {cause: error} + ); + } finally { + await chrome?.close(); + if (firefox) { + try { + await firefox.send("session.end", {}, 2_000); + } catch { + // Firefox may close before replying. + } + await firefox.close(); + } + if (browserProcess) await stop(browserProcess); + await site?.close(); + await fixture?.dispose(); + await rm(profile, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } +}; diff --git a/tests/integration/browser/content/shadow-styles.firefox.integration.test.ts b/tests/integration/browser/content/shadow-styles.firefox.integration.test.ts new file mode 100644 index 00000000..4743773f --- /dev/null +++ b/tests/integration/browser/content/shadow-styles.firefox.integration.test.ts @@ -0,0 +1,12 @@ +/** @jest-environment node */ + +import {runShadowStylesIntegration} from "./shadow-styles-utils"; + +jest.setTimeout(90_000); + +test.each([2, 3] as const)( + "Firefox MV%s renders production Shadow DOM styles and local fonts under strict page CSP", + async version => { + await runShadowStylesIntegration("firefox", version); + } +); diff --git a/tests/integration/browser/content/shadow-styles.integration.test.ts b/tests/integration/browser/content/shadow-styles.integration.test.ts new file mode 100644 index 00000000..e6538f43 --- /dev/null +++ b/tests/integration/browser/content/shadow-styles.integration.test.ts @@ -0,0 +1,9 @@ +/** @jest-environment node */ + +import {runShadowStylesIntegration} from "./shadow-styles-utils"; + +jest.setTimeout(90_000); + +test("Chrome MV3 renders production Shadow DOM styles and local fonts under strict page CSP", async () => { + await runShadowStylesIntegration("chrome", 3); +}); diff --git a/tests/integration/browser/content/shadow-styles/README.md b/tests/integration/browser/content/shadow-styles/README.md new file mode 100644 index 00000000..c9b846a3 --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/README.md @@ -0,0 +1,52 @@ +# Shadow DOM content integration + +This fixture exercises the production `shadow` content-entrypoint API with the native Vanilla +renderer. It contains two shadow entrypoints and one ordinary entrypoint. All three consume a forced +shared CSS-only chunk, while each shadow entrypoint also loads its own initial and lazy styles. + +The primary entrypoint mounts two roots, starts one `import()` before the first root exists, watches +for anchor replacement, and registers a local WOFF2 through `shadow.fonts`. The secondary entrypoint +has an independent runtime registry and lazy chunk. The ordinary entrypoint proves that shared CSS +continues to load through `content_scripts.css` outside Shadow DOM. + +## What the runner verifies + +- Shadow entrypoints have no manifest-level CSS; their initial, lazy and shared CSS files are in web + accessible resources. +- The ordinary entrypoint retains manifest-level CSS, including the shared chunk. +- No content entrypoint receives a background file. +- Every root contains file-backed extension `` elements and receives initial and requested lazy + styles without leaking selectors into the page. +- An import issued before the first root completes, and later roots receive already requested styles. +- Removing and recreating an anchor produces a new host, ShadowRoot, render target and stylesheet + links. +- Top documents and same-origin child iframes work with and without strict page CSP. +- Two shadow entrypoints sharing CSS keep independent root registries. +- The local font is really rendered: the synthetic `AAAA` sample is measured at 320px. Merely seeing + its `font-family` value is not accepted. + +The strict page response uses: + +```text +default-src 'none'; style-src 'none'; style-src-elem 'none'; font-src 'none'; frame-src 'self'; img-src 'self' +``` + +Passing results were reproduced on Chrome 155.0.8041.0 with Manifest V3 and Firefox 155.0 with +Manifest V2 and Manifest V3. Reports are written to +`.cache/integration/shadow-styles--mv.json`, outside the extension output. + +## Run + +From the repository root: + +```sh +npm run build +npx jest tests/integration/browser/content/shadow-styles.integration.test.ts --runInBand +npx jest tests/integration/browser/content/shadow-styles.firefox.integration.test.ts --runInBand +``` + +Set `ADNBN_CHROME_BIN` or `ADNBN_FIREFOX_BIN` when automatic browser discovery selects the wrong +binary. The source fixture keeps the local font beside the primary entrypoint. `font.py` documents how +the synthetic font was produced; FontTools and Brotli are not needed to build or run the test. + +The feature contract is documented in [`src/entry/content/README.md`](../../../../../src/entry/content/README.md). diff --git a/tests/integration/browser/content/shadow-styles/adnbn.config.ts b/tests/integration/browser/content/shadow-styles/adnbn.config.ts new file mode 100644 index 00000000..d1c883ae --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/adnbn.config.ts @@ -0,0 +1,27 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Shadow Content Integration", + description: "Exercises production Shadow DOM rendering, styles and local fonts.", + version: "1.0.0", + specific: {gecko: {id: "shadow-styles@adnbn.test"}}, + assetsFilename: "[name].[contenthash:8][ext]", + jsFilename: "[name].[chunkhash:8].js", + cssFilename: "[name].[contenthash:8].css", + bundler: { + optimization: { + splitChunks: { + cacheGroups: { + sharedStyles: { + name: "shared-styles", + test: /shared[\\/]styles\.module\.css$/, + type: "css/mini-extract", + chunks: "all", + minChunks: 2, + enforce: true, + }, + }, + }, + }, + }, +}); diff --git a/tests/integration/browser/content/shadow-styles/font.py b/tests/integration/browser/content/shadow-styles/font.py new file mode 100644 index 00000000..b47cb83e --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/font.py @@ -0,0 +1,37 @@ +"""Regenerate the original synthetic test font with fonttools[woff] (not needed to run tests).""" + +from pathlib import Path + +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen + +font = FontBuilder(1000, isTTF=True) +font.setupGlyphOrder([".notdef", "A"]) +font.setupCharacterMap({65: "A"}) + +pen = TTGlyphPen(None) +pen.moveTo((0, 0)) +pen.lineTo((1000, 0)) +pen.lineTo((1000, 800)) +pen.lineTo((0, 800)) +pen.closePath() +glyph = pen.glyph() +font.setupGlyf({".notdef": glyph, "A": glyph}) +# Each A is 2em wide: AAAA at 40px must measure 320px, independently of installed fonts. +font.setupHorizontalMetrics({".notdef": (2000, 0), "A": (2000, 0)}) +font.setupHorizontalHeader(ascent=800, descent=-200) +font.setupNameTable({ + "familyName": "Adnbn Shadow Probe", + "styleName": "Regular", + "uniqueFontIdentifier": "AdnbnShadowProbe-Regular", + "fullName": "Adnbn Shadow Probe Regular", + "psName": "AdnbnShadowProbe-Regular", + "version": "Version 1.0", +}) +font.setupOS2(sTypoAscender=800, sTypoDescender=-200, usWinAscent=800, usWinDescent=200) +font.setupPost() +font.setupMaxp() +font.font["head"].created = font.font["head"].modified = 3800000000 +font.font.recalcTimestamp = False +font.font.flavor = "woff2" +font.save(Path(__file__).parent / "src" / "probe.content" / "probe.woff2") diff --git a/tests/integration/browser/content/shadow-styles/package.json b/tests/integration/browser/content/shadow-styles/package.json new file mode 100644 index 00000000..044eb690 --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-shadow-styles", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../.." + } +} diff --git a/tests/integration/browser/content/shadow-styles/site/child.html b/tests/integration/browser/content/shadow-styles/site/child.html new file mode 100644 index 00000000..3f10d88d --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/site/child.html @@ -0,0 +1,11 @@ + + + + + Shadow content child frame + + +
+
+ + diff --git a/tests/integration/browser/content/shadow-styles/site/frames.html b/tests/integration/browser/content/shadow-styles/site/frames.html new file mode 100644 index 00000000..f3b82a32 --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/site/frames.html @@ -0,0 +1,12 @@ + + + + + Shadow content frames + + +
+
+ + + diff --git a/tests/integration/browser/content/shadow-styles/site/top.html b/tests/integration/browser/content/shadow-styles/site/top.html new file mode 100644 index 00000000..f35ebe14 --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/site/top.html @@ -0,0 +1,14 @@ + + + + + Shadow content integration + + +

Shadow content integration

+
+
+
+

Outside page content

+ + diff --git a/tests/integration/browser/content/shadow-styles/src/normal.content.ts b/tests/integration/browser/content/shadow-styles/src/normal.content.ts new file mode 100644 index 00000000..4c160a63 --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/src/normal.content.ts @@ -0,0 +1,15 @@ +import {defineContentScript} from "adnbn"; + +import sharedStyles from "./shared/styles.module.css"; + +export default defineContentScript({ + matches: ["http://127.0.0.1/*"], + allFrames: true, + render() { + const element = document.createElement("div"); + element.className = sharedStyles.shared; + element.dataset.normalProbe = "ready"; + + return element; + }, +}); diff --git a/tests/integration/browser/content/shadow-styles/src/probe.content/index.ts b/tests/integration/browser/content/shadow-styles/src/probe.content/index.ts new file mode 100644 index 00000000..95e298f1 --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/src/probe.content/index.ts @@ -0,0 +1,92 @@ +import {ContentScriptAppend, ContentScriptWorld, defineContentScriptAppend} from "adnbn"; + +import probeFont from "./probe.woff2"; +import sharedStyles from "../shared/styles.module.css"; +import styles from "./styles.module.css"; + +const AsyncModule = import("./lazy"); +const ExpectedWidth = 320; +let instance = 0; + +const width = (element: Element): number => { + const range = document.createRange(); + range.selectNodeContents(element); + + return range.getBoundingClientRect().width; +}; + +const waitForStyles = async (element: HTMLElement, predicate: () => boolean): Promise => { + const started = performance.now(); + + while (!predicate()) { + if (performance.now() - started > 10_000) { + throw new Error("Shadow styles did not apply within 10 seconds"); + } + + await new Promise(resolve => requestAnimationFrame(() => resolve())); + } + + element.dataset.ready = "true"; +}; + +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + world: ContentScriptWorld.Isolated, + runAt: "document_end", + allFrames: true, + anchor: "[data-shadow-primary]", + append: ContentScriptAppend.Last, + watch: true, + shadow: { + fonts: [{family: "AdnbnShadowProbe", source: probeFont, weight: "400", style: "normal"}], + }, + container: ({anchor}) => { + const host = document.createElement("section"); + host.dataset.shadowProbe = "primary"; + host.dataset.anchor = anchor.getAttribute("data-shadow-primary") ?? "unknown"; + host.dataset.instance = String(++instance); + + return host; + }, + render({anchor}) { + const root = document.createElement("div"); + root.classList.add(styles.root, sharedStyles.shared); + root.dataset.shadowResult = "primary"; + root.dataset.anchor = anchor.getAttribute("data-shadow-primary") ?? "unknown"; + root.dataset.frame = window === window.top ? "top" : "child"; + root.dataset.ready = "false"; + + const font = document.createElement("span"); + font.className = styles.font; + font.textContent = "AAAA"; + root.append(font); + + void AsyncModule.then(module => { + module.applyLazyStyle(root); + + return document.fonts.load('40px "AdnbnShadowProbe"', "AAAA"); + }) + .then(() => + waitForStyles( + root, + () => + getComputedStyle(root).color === "rgb(17, 85, 153)" && + getComputedStyle(root).backgroundColor === "rgb(34, 102, 68)" && + getComputedStyle(root).borderTopWidth === "3px" && + Math.abs(width(font) - ExpectedWidth) < 0.1 + ) + ) + .then(() => { + root.dataset.initialCss = "applied"; + root.dataset.asyncCss = "applied"; + root.dataset.sharedCss = "applied"; + root.dataset.font = "applied"; + }) + .catch(cause => { + root.dataset.error = cause instanceof Error ? cause.message : String(cause); + root.dataset.ready = "error"; + }); + + return root; + }, +}); diff --git a/tests/integration/browser/content/shadow-styles/src/probe.content/lazy.module.css b/tests/integration/browser/content/shadow-styles/src/probe.content/lazy.module.css new file mode 100644 index 00000000..3aa1605c --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/src/probe.content/lazy.module.css @@ -0,0 +1,3 @@ +.loaded { + background-color: rgb(34, 102, 68); +} diff --git a/tests/integration/browser/content/shadow-styles/src/probe.content/lazy.ts b/tests/integration/browser/content/shadow-styles/src/probe.content/lazy.ts new file mode 100644 index 00000000..469fe45a --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/src/probe.content/lazy.ts @@ -0,0 +1,5 @@ +import styles from "./lazy.module.css"; + +export const applyLazyStyle = (element: HTMLElement): void => { + element.classList.add(styles.loaded); +}; diff --git a/tests/integration/browser/content/shadow-styles/src/probe.content/probe.woff2 b/tests/integration/browser/content/shadow-styles/src/probe.content/probe.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..00ed2579f6f9b74bed1bd002ac0218cd7d48e854 GIT binary patch literal 404 zcmXT-cQayOWB>xjDGXd7nt2b9?F|tF@lgR=0)q*cfdiu%lL|8%HyZnGhSa}%GW6W;M4d;RV5a?cg9OEY)9*=5@O@vfnV$RTAJq0XKQE3z)e z-(9v+SH#;Or1|dW#Z7Mt1eq7e7o2(GbN9)9^?5(OEa<-@HRVNmV-K%bLK3ISmc~O2 z%U`YBSjz9If3o98OiWg@}gg_LlBDx1B1jTC5IUb4wnoB6=u)o571e|vO;o;dh@QGKXttJU)U?NIOT}x zscG{AHvVhNO8d9xm(14HBL5U+<=-dehrHxGT*A#=B-T>!nW_51sYjb)yI3BcG1B<{ pFrwg)Nm^B(;uMqIZ=zG~FXS$ip38at8HaZF>CP>CuCp>Q000^xr9=P# literal 0 HcmV?d00001 diff --git a/tests/integration/browser/content/shadow-styles/src/probe.content/styles.module.css b/tests/integration/browser/content/shadow-styles/src/probe.content/styles.module.css new file mode 100644 index 00000000..e2b9cebf --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/src/probe.content/styles.module.css @@ -0,0 +1,13 @@ +.root { + color: rgb(17, 85, 153); +} + +.font { + display: inline-block; + font-family: "AdnbnShadowProbe", monospace; + font-size: 40px; + font-kerning: none; + font-variant-ligatures: none; + letter-spacing: 0; + white-space: pre; +} diff --git a/tests/integration/browser/content/shadow-styles/src/secondary.content/index.ts b/tests/integration/browser/content/shadow-styles/src/secondary.content/index.ts new file mode 100644 index 00000000..a6164860 --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/src/secondary.content/index.ts @@ -0,0 +1,61 @@ +import {ContentScriptAppend, defineContentScriptAppend} from "adnbn"; + +import sharedStyles from "../shared/styles.module.css"; +import styles from "./styles.module.css"; + +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + allFrames: true, + anchor: "[data-shadow-secondary]", + append: ContentScriptAppend.Last, + shadow: true, + container: () => { + const host = document.createElement("section"); + host.dataset.shadowProbe = "secondary"; + + return host; + }, + render() { + const root = document.createElement("div"); + root.classList.add(styles.root, sharedStyles.shared); + root.dataset.shadowResult = "secondary"; + root.dataset.frame = window === window.top ? "top" : "child"; + root.dataset.ready = "false"; + + void import("./lazy") + .then(module => { + module.applyLazyStyle(root); + + return waitForStyles(root); + }) + .then(() => { + root.dataset.initialCss = "applied"; + root.dataset.asyncCss = "applied"; + root.dataset.sharedCss = "applied"; + }) + .catch(cause => { + root.dataset.error = cause instanceof Error ? cause.message : String(cause); + root.dataset.ready = "error"; + }); + + return root; + }, +}); + +const waitForStyles = async (element: HTMLElement): Promise => { + const started = performance.now(); + + while ( + getComputedStyle(element).color !== "rgb(119, 51, 34)" || + getComputedStyle(element).backgroundColor !== "rgb(85, 51, 119)" || + getComputedStyle(element).borderTopWidth !== "3px" + ) { + if (performance.now() - started > 10_000) { + throw new Error("Secondary shadow styles did not apply within 10 seconds"); + } + + await new Promise(resolve => requestAnimationFrame(() => resolve())); + } + + element.dataset.ready = "true"; +}; diff --git a/tests/integration/browser/content/shadow-styles/src/secondary.content/lazy.module.css b/tests/integration/browser/content/shadow-styles/src/secondary.content/lazy.module.css new file mode 100644 index 00000000..807915dc --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/src/secondary.content/lazy.module.css @@ -0,0 +1,3 @@ +.loaded { + background-color: rgb(85, 51, 119); +} diff --git a/tests/integration/browser/content/shadow-styles/src/secondary.content/lazy.ts b/tests/integration/browser/content/shadow-styles/src/secondary.content/lazy.ts new file mode 100644 index 00000000..469fe45a --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/src/secondary.content/lazy.ts @@ -0,0 +1,5 @@ +import styles from "./lazy.module.css"; + +export const applyLazyStyle = (element: HTMLElement): void => { + element.classList.add(styles.loaded); +}; diff --git a/tests/integration/browser/content/shadow-styles/src/secondary.content/styles.module.css b/tests/integration/browser/content/shadow-styles/src/secondary.content/styles.module.css new file mode 100644 index 00000000..14457a9f --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/src/secondary.content/styles.module.css @@ -0,0 +1,3 @@ +.root { + color: rgb(119, 51, 34); +} diff --git a/tests/integration/browser/content/shadow-styles/src/shared/styles.module.css b/tests/integration/browser/content/shadow-styles/src/shared/styles.module.css new file mode 100644 index 00000000..f67dce95 --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/src/shared/styles.module.css @@ -0,0 +1,3 @@ +.shared { + border-top: 3px solid rgb(68, 51, 170); +} diff --git a/tests/integration/browser/content/shadow-styles/tsconfig.json b/tests/integration/browser/content/shadow-styles/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/browser/content/shadow-styles/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/browser/utils/site.ts b/tests/integration/browser/utils/site.ts index 53397481..84830eb0 100644 --- a/tests/integration/browser/utils/site.ts +++ b/tests/integration/browser/utils/site.ts @@ -7,7 +7,10 @@ export interface IntegrationSite { close(): Promise; } -export const startIntegrationSite = async (directory: string): Promise => { +export const startIntegrationSite = async ( + directory: string, + contentSecurityPolicy: string | null = "default-src 'none'; frame-src 'self'; img-src 'self'" +): Promise => { const server = createServer(async (request, response) => { try { const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname; @@ -24,7 +27,7 @@ export const startIntegrationSite = async (directory: string): Promise { + const suffix = "?raw"; + const target = request.endsWith(suffix) ? request.slice(0, -suffix.length) : request; + + return options.defaultResolver(target, options); +}; diff --git a/tests/raw-module-transformer.cjs b/tests/raw-module-transformer.cjs new file mode 100644 index 00000000..3b9959fb --- /dev/null +++ b/tests/raw-module-transformer.cjs @@ -0,0 +1,8 @@ +module.exports = { + getCacheKey(source, filename) { + return require("crypto").createHash("sha256").update(source).update(filename).update("raw-v2").digest("hex"); + }, + process(source) { + return {code: `module.exports = ${JSON.stringify(source)};`}; + }, +}; diff --git a/tsup.config.ts b/tsup.config.ts index ea2953c1..2adeacbe 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -56,6 +56,11 @@ const fixVirtualIndexImportPlugin = (): Plugin => ({ }, }); +const runtimeTemplateEntries = [ + "src/cli/bundler/plugins/chunk-loader/templates.ts", + "src/cli/bundler/plugins/shadow-styles/templates.ts", +]; + export default defineConfig([ { entry: [ @@ -66,6 +71,7 @@ export default defineConfig([ "!src/**/*.{test,spec}.{ts,tsx}", "!src/**/*.d.ts", "!src/cli/virtual/**/*", + ...runtimeTemplateEntries.map(entry => `!${entry}`), "!src/cli/entrypoint/file/fixtures/**", ], bundle: false, @@ -100,4 +106,22 @@ export default defineConfig([ }, outExtension: () => ({js: ".js"}), }, + { + entry: runtimeTemplateEntries, + format: ["esm"], + bundle: true, + outDir: "dist", + target: "node14", + clean: false, + dts: false, + sourcemap: false, + splitting: false, + // Raw runtime templates stay colocated with their owning plugins and are embedded here. + // @ts-ignore + esbuildPlugins: [rawPlugin()], + esbuildOptions: options => { + options.outbase = "src"; + }, + outExtension: () => ({js: ".js"}), + }, ]); From 6bdd6d2f083b895e0bc00788420bc185e47a0591 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:29:54 +0300 Subject: [PATCH 09/38] refactor(bundler): organize plugins by directory --- src/cli/bundler/plugins/chunk-loader/templates.ts | 2 +- .../plugins/{ => entrypoint}/EntrypointPlugin.test.ts | 0 .../plugins/{ => entrypoint}/EntrypointPlugin.ts | 0 src/cli/bundler/plugins/entrypoint/index.ts | 10 ++++++++++ .../{ => generate-json}/GenerateJsonPlugin.test.ts | 0 .../plugins/{ => generate-json}/GenerateJsonPlugin.ts | 0 src/cli/bundler/plugins/generate-json/index.ts | 1 + src/cli/bundler/plugins/index.ts | 10 +++++----- .../plugins/{ => manifest}/ManifestPlugin.test.ts | 0 .../bundler/plugins/{ => manifest}/ManifestPlugin.ts | 0 src/cli/bundler/plugins/manifest/index.ts | 1 + src/cli/bundler/plugins/{ => replace}/ReplacePlugin.ts | 0 src/cli/bundler/plugins/replace/index.ts | 1 + src/cli/bundler/plugins/shadow-styles/templates.ts | 2 +- src/cli/bundler/plugins/utils/index.ts | 1 + .../bundler/{ => plugins}/utils/runtime-template.ts | 0 src/cli/bundler/plugins/{ => watch}/WatchPlugin.ts | 0 src/cli/bundler/plugins/watch/index.ts | 1 + src/cli/resolvers/bundler.ts | 4 ++-- 19 files changed, 24 insertions(+), 9 deletions(-) rename src/cli/bundler/plugins/{ => entrypoint}/EntrypointPlugin.test.ts (100%) rename src/cli/bundler/plugins/{ => entrypoint}/EntrypointPlugin.ts (100%) create mode 100644 src/cli/bundler/plugins/entrypoint/index.ts rename src/cli/bundler/plugins/{ => generate-json}/GenerateJsonPlugin.test.ts (100%) rename src/cli/bundler/plugins/{ => generate-json}/GenerateJsonPlugin.ts (100%) create mode 100644 src/cli/bundler/plugins/generate-json/index.ts rename src/cli/bundler/plugins/{ => manifest}/ManifestPlugin.test.ts (100%) rename src/cli/bundler/plugins/{ => manifest}/ManifestPlugin.ts (100%) create mode 100644 src/cli/bundler/plugins/manifest/index.ts rename src/cli/bundler/plugins/{ => replace}/ReplacePlugin.ts (100%) create mode 100644 src/cli/bundler/plugins/replace/index.ts create mode 100644 src/cli/bundler/plugins/utils/index.ts rename src/cli/bundler/{ => plugins}/utils/runtime-template.ts (100%) rename src/cli/bundler/plugins/{ => watch}/WatchPlugin.ts (100%) create mode 100644 src/cli/bundler/plugins/watch/index.ts diff --git a/src/cli/bundler/plugins/chunk-loader/templates.ts b/src/cli/bundler/plugins/chunk-loader/templates.ts index 8c40c96b..a2bab527 100644 --- a/src/cli/bundler/plugins/chunk-loader/templates.ts +++ b/src/cli/bundler/plugins/chunk-loader/templates.ts @@ -1,6 +1,6 @@ import runtimeTemplate from "./templates/runtime.template.js?raw"; -import {renderRuntimeTemplate} from "@cli/bundler/utils/runtime-template"; +import {renderRuntimeTemplate} from "@cli/bundler/plugins/utils"; interface ChunkLoaderRuntimeTemplateOptions { readonly ensureChunk: string; diff --git a/src/cli/bundler/plugins/EntrypointPlugin.test.ts b/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.test.ts similarity index 100% rename from src/cli/bundler/plugins/EntrypointPlugin.test.ts rename to src/cli/bundler/plugins/entrypoint/EntrypointPlugin.test.ts diff --git a/src/cli/bundler/plugins/EntrypointPlugin.ts b/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.ts similarity index 100% rename from src/cli/bundler/plugins/EntrypointPlugin.ts rename to src/cli/bundler/plugins/entrypoint/EntrypointPlugin.ts diff --git a/src/cli/bundler/plugins/entrypoint/index.ts b/src/cli/bundler/plugins/entrypoint/index.ts new file mode 100644 index 00000000..c4a28695 --- /dev/null +++ b/src/cli/bundler/plugins/entrypoint/index.ts @@ -0,0 +1,10 @@ +export { + default, + type EntrypointPluginEntryOptions, + type EntrypointPluginEntryOptionsResolver, + type EntrypointPluginModules, + type EntrypointPluginModule, + type EntrypointPluginEntryModules, + type EntrypointPluginTemplate, + type EntrypointPluginUpdate, +} from "./EntrypointPlugin"; diff --git a/src/cli/bundler/plugins/GenerateJsonPlugin.test.ts b/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.test.ts similarity index 100% rename from src/cli/bundler/plugins/GenerateJsonPlugin.test.ts rename to src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.test.ts diff --git a/src/cli/bundler/plugins/GenerateJsonPlugin.ts b/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.ts similarity index 100% rename from src/cli/bundler/plugins/GenerateJsonPlugin.ts rename to src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.ts diff --git a/src/cli/bundler/plugins/generate-json/index.ts b/src/cli/bundler/plugins/generate-json/index.ts new file mode 100644 index 00000000..4c45e88b --- /dev/null +++ b/src/cli/bundler/plugins/generate-json/index.ts @@ -0,0 +1 @@ +export {default, type GenerateJsonPluginData, type GenerateJsonPluginUpdate} from "./GenerateJsonPlugin"; diff --git a/src/cli/bundler/plugins/index.ts b/src/cli/bundler/plugins/index.ts index 3d21f545..796fb7dd 100644 --- a/src/cli/bundler/plugins/index.ts +++ b/src/cli/bundler/plugins/index.ts @@ -7,7 +7,7 @@ export { type EntrypointPluginEntryModules, type EntrypointPluginTemplate, type EntrypointPluginUpdate, -} from "./EntrypointPlugin"; +} from "./entrypoint"; export {default as ChunkLoaderPlugin, type ChunkLoaderPluginOptions} from "./chunk-loader"; @@ -19,10 +19,10 @@ export { default as GenerateJsonPlugin, type GenerateJsonPluginData, type GenerateJsonPluginUpdate, -} from "./GenerateJsonPlugin"; +} from "./generate-json"; -export {default as ManifestPlugin} from "./ManifestPlugin"; +export {default as ManifestPlugin} from "./manifest"; -export {default as ReplacePlugin} from "./ReplacePlugin"; +export {default as ReplacePlugin} from "./replace"; -export {default as WatchPlugin} from "./WatchPlugin"; +export {default as WatchPlugin} from "./watch"; diff --git a/src/cli/bundler/plugins/ManifestPlugin.test.ts b/src/cli/bundler/plugins/manifest/ManifestPlugin.test.ts similarity index 100% rename from src/cli/bundler/plugins/ManifestPlugin.test.ts rename to src/cli/bundler/plugins/manifest/ManifestPlugin.test.ts diff --git a/src/cli/bundler/plugins/ManifestPlugin.ts b/src/cli/bundler/plugins/manifest/ManifestPlugin.ts similarity index 100% rename from src/cli/bundler/plugins/ManifestPlugin.ts rename to src/cli/bundler/plugins/manifest/ManifestPlugin.ts diff --git a/src/cli/bundler/plugins/manifest/index.ts b/src/cli/bundler/plugins/manifest/index.ts new file mode 100644 index 00000000..082e6f00 --- /dev/null +++ b/src/cli/bundler/plugins/manifest/index.ts @@ -0,0 +1 @@ +export {createManifestDependencies, default} from "./ManifestPlugin"; diff --git a/src/cli/bundler/plugins/ReplacePlugin.ts b/src/cli/bundler/plugins/replace/ReplacePlugin.ts similarity index 100% rename from src/cli/bundler/plugins/ReplacePlugin.ts rename to src/cli/bundler/plugins/replace/ReplacePlugin.ts diff --git a/src/cli/bundler/plugins/replace/index.ts b/src/cli/bundler/plugins/replace/index.ts new file mode 100644 index 00000000..1762f930 --- /dev/null +++ b/src/cli/bundler/plugins/replace/index.ts @@ -0,0 +1 @@ +export {default} from "./ReplacePlugin"; diff --git a/src/cli/bundler/plugins/shadow-styles/templates.ts b/src/cli/bundler/plugins/shadow-styles/templates.ts index 96a0815b..896035f5 100644 --- a/src/cli/bundler/plugins/shadow-styles/templates.ts +++ b/src/cli/bundler/plugins/shadow-styles/templates.ts @@ -1,7 +1,7 @@ import cssLoaderTemplate from "./templates/css-loader.template.js?raw"; import runtimeTemplate from "./templates/runtime.template.js?raw"; -import {renderRuntimeTemplate} from "@cli/bundler/utils/runtime-template"; +import {renderRuntimeTemplate} from "@cli/bundler/plugins/utils"; interface ShadowStylesRuntimeTemplateOptions { readonly entry: string; diff --git a/src/cli/bundler/plugins/utils/index.ts b/src/cli/bundler/plugins/utils/index.ts new file mode 100644 index 00000000..cd9d7a3c --- /dev/null +++ b/src/cli/bundler/plugins/utils/index.ts @@ -0,0 +1 @@ +export {renderRuntimeTemplate} from "./runtime-template"; diff --git a/src/cli/bundler/utils/runtime-template.ts b/src/cli/bundler/plugins/utils/runtime-template.ts similarity index 100% rename from src/cli/bundler/utils/runtime-template.ts rename to src/cli/bundler/plugins/utils/runtime-template.ts diff --git a/src/cli/bundler/plugins/WatchPlugin.ts b/src/cli/bundler/plugins/watch/WatchPlugin.ts similarity index 100% rename from src/cli/bundler/plugins/WatchPlugin.ts rename to src/cli/bundler/plugins/watch/WatchPlugin.ts diff --git a/src/cli/bundler/plugins/watch/index.ts b/src/cli/bundler/plugins/watch/index.ts new file mode 100644 index 00000000..38598cce --- /dev/null +++ b/src/cli/bundler/plugins/watch/index.ts @@ -0,0 +1 @@ +export {default, type WatchPluginCallback} from "./WatchPlugin"; diff --git a/src/cli/resolvers/bundler.ts b/src/cli/resolvers/bundler.ts index 1f84ce86..2952125d 100644 --- a/src/cli/resolvers/bundler.ts +++ b/src/cli/resolvers/bundler.ts @@ -5,8 +5,8 @@ import {merge as mergeConfig} from "webpack-merge"; import manifestFactory from "../builders/manifest"; import {processPluginHandler} from "./plugin"; -import ManifestPlugin from "@cli/bundler/plugins/ManifestPlugin"; -import WatchPlugin from "@cli/bundler/plugins/WatchPlugin"; +import ManifestPlugin from "@cli/bundler/plugins/manifest"; +import WatchPlugin from "@cli/bundler/plugins/watch"; import {ReadonlyConfig} from "@typing/config"; import {Command} from "@typing/app"; From 04cbd6f918bc302ad51084a98db3c7cf7a00c5e3 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:40:31 +0300 Subject: [PATCH 10/38] fix(build): exclude tests from distribution --- package.json | 2 +- scripts/clean-dist.js | 7 +++++++ scripts/copy-dts.js | 12 +++++++++++- tests/build-output.test.ts | 37 +++++++++++++++++++++++++++++++++++++ tsconfig.build.json | 7 +++++-- 5 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 scripts/clean-dist.js create mode 100644 tests/build-output.test.ts diff --git a/package.json b/package.json index 908e196a..6b45ec37 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "prepare": "husky", "preinstall": "npm run check:node-version", "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", + "build": "node ./scripts/clean-dist.js && 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 && npm run typecheck:tests", "typecheck:tests": "tsc -p tsconfig.tests.json --noEmit", diff --git a/scripts/clean-dist.js b/scripts/clean-dist.js new file mode 100644 index 00000000..ed6c938c --- /dev/null +++ b/scripts/clean-dist.js @@ -0,0 +1,7 @@ +import {rm} from "fs/promises"; +import {dirname, join} from "path"; +import {fileURLToPath} from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +await rm(join(__dirname, "..", "dist"), {force: true, recursive: true}); diff --git a/scripts/copy-dts.js b/scripts/copy-dts.js index 84508b02..917ef919 100644 --- a/scripts/copy-dts.js +++ b/scripts/copy-dts.js @@ -7,7 +7,17 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, ".."); const distRoot = join(root, "dist"); -const files = await globby("src/**/*.d.ts", {cwd: root, dot: true}); +const files = await globby( + [ + "src/**/*.d.ts", + "!src/**/tests/**/*", + "!src/**/__tests__/**/*", + "!src/**/__mocks__/**/*", + "!src/**/*.test.d.ts", + "!src/**/*.spec.d.ts", + ], + {cwd: root, dot: true} +); for (const file of files) { const rel = relative("src", file); diff --git a/tests/build-output.test.ts b/tests/build-output.test.ts new file mode 100644 index 00000000..9b73bd78 --- /dev/null +++ b/tests/build-output.test.ts @@ -0,0 +1,37 @@ +import {readdir} from "fs/promises"; +import path from "path"; + +const TestPathSegments = new Set(["tests", "__tests__", "__mocks__"]); + +const collectPaths = async (directory: string): Promise => { + const entries = await readdir(directory, {withFileTypes: true}); + const paths: string[] = []; + + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + paths.push(entryPath); + + if (entry.isDirectory()) { + paths.push(...(await collectPaths(entryPath))); + } + } + + return paths; +}; + +describe("build output", () => { + test("excludes test files and directories", async () => { + const output = path.resolve(process.cwd(), "dist"); + const paths = await collectPaths(output); + const testPaths = paths.filter(outputPath => { + const relativePath = path.relative(output, outputPath); + const segments = relativePath.split(path.sep); + + return ( + segments.some(segment => TestPathSegments.has(segment)) || /\.(?:test|spec)\.[^.]+$/.test(relativePath) + ); + }); + + expect(testPaths).toEqual([]); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index 6c3afcf4..ff717dec 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -15,8 +15,11 @@ }, "include": ["./src/**/*"], "exclude": [ - "**/*.test.ts", - "**/tests/**/*.ts", + "**/*.test.*", + "**/*.spec.*", + "**/tests/**/*", + "**/__tests__/**/*", + "**/__mocks__/**/*", "dist", "src/cli/entrypoint/file/fixtures/**" ] From 3231ece02d0674457f241b619dc39af65b7b7e09 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:40:36 +0300 Subject: [PATCH 11/38] test(browser): allow stable browser versions --- tests/integration/browser/content/shadow-styles-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/browser/content/shadow-styles-utils.ts b/tests/integration/browser/content/shadow-styles-utils.ts index a3fd17fc..547ea270 100644 --- a/tests/integration/browser/content/shadow-styles-utils.ts +++ b/tests/integration/browser/content/shadow-styles-utils.ts @@ -265,7 +265,7 @@ export const runShadowStylesIntegration = async (name: "chrome" | "firefox", man ); await writeFile(reportPath, JSON.stringify(report, null, 2) + "\n"); console.info(JSON.stringify(report, null, 2)); - expect(version).toMatch(name === "chrome" ? /^Chrome\/155\./ : /^Firefox\/155\./); + expect(version).toMatch(new RegExp(`^${name === "chrome" ? "Chrome" : "Firefox"}\\/\\d+(?:\\.\\d+)+$`)); expect(name === "chrome" ? chrome?.runtimeErrors : firefox?.runtimeErrors).toEqual([]); } catch (error) { throw new Error( From 5319ef7cab04c5563a289b7eb75985d39ccd8302 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:19:48 +0300 Subject: [PATCH 12/38] refactor(manifest): normalize resource handling and validation Separate manifest utilities by responsibility and normalize merged WAR and host permission rules. Add compilation-scoped preparation and validation hooks with a generic resource access validator, and rebuild manifest state for each watch compilation. --- package-lock.json | 1 + package.json | 1 + src/cli/builders/manifest/ManifestBase.ts | 14 +- src/cli/builders/manifest/ManifestV2.test.ts | 67 +- src/cli/builders/manifest/ManifestV3.test.ts | 143 ++++- src/cli/builders/manifest/ManifestV3.ts | 18 +- src/cli/builders/manifest/utils.test.ts | 604 ------------------ src/cli/builders/manifest/utils.ts | 306 --------- .../utils/data-collection-permissions.test.ts | 118 ++++ .../utils/data-collection-permissions.ts | 44 ++ .../manifest/utils/host-patterns.test.ts | 132 ++++ .../builders/manifest/utils/host-patterns.ts | 67 ++ src/cli/builders/manifest/utils/index.ts | 9 + .../manifest/utils/permissions.test.ts | 56 ++ .../builders/manifest/utils/permissions.ts | 71 ++ .../utils/web-accessible-resources.test.ts | 543 ++++++++++++++++ .../utils/web-accessible-resources.ts | 113 ++++ src/cli/bundler/plugins/index.ts | 6 + .../plugins/manifest/ManifestPlugin.test.ts | 157 ++++- .../plugins/manifest/ManifestPlugin.ts | 34 +- .../plugins/manifest/tests/fixtures/entry.js | 1 + .../ResourceAccessPlugin.test.ts | 108 ++++ .../resource-access/ResourceAccessPlugin.ts | 38 ++ .../bundler/plugins/resource-access/index.ts | 1 + .../resource-access/tests/fixtures/client.js | 1 + .../plugins/resource-access/utils.test.ts | 58 ++ .../bundler/plugins/resource-access/utils.ts | 74 +++ src/cli/bundler/plugins/utils/index.ts | 1 + .../bundler/plugins/utils/manifest-hooks.ts | 30 + src/cli/resolvers/bundler.ts | 14 +- 30 files changed, 1884 insertions(+), 946 deletions(-) delete mode 100644 src/cli/builders/manifest/utils.test.ts delete mode 100644 src/cli/builders/manifest/utils.ts create mode 100644 src/cli/builders/manifest/utils/data-collection-permissions.test.ts create mode 100644 src/cli/builders/manifest/utils/data-collection-permissions.ts create mode 100644 src/cli/builders/manifest/utils/host-patterns.test.ts create mode 100644 src/cli/builders/manifest/utils/host-patterns.ts create mode 100644 src/cli/builders/manifest/utils/index.ts create mode 100644 src/cli/builders/manifest/utils/permissions.test.ts create mode 100644 src/cli/builders/manifest/utils/permissions.ts create mode 100644 src/cli/builders/manifest/utils/web-accessible-resources.test.ts create mode 100644 src/cli/builders/manifest/utils/web-accessible-resources.ts create mode 100644 src/cli/bundler/plugins/manifest/tests/fixtures/entry.js create mode 100644 src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.test.ts create mode 100644 src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.ts create mode 100644 src/cli/bundler/plugins/resource-access/index.ts create mode 100644 src/cli/bundler/plugins/resource-access/tests/fixtures/client.js create mode 100644 src/cli/bundler/plugins/resource-access/utils.test.ts create mode 100644 src/cli/bundler/plugins/resource-access/utils.ts create mode 100644 src/cli/bundler/plugins/utils/manifest-hooks.ts diff --git a/package-lock.json b/package-lock.json index 2ffffd50..60a81a26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@rsdoctor/rspack-plugin": "^1.5.1", "@rspack/cli": "^1.7.5", "@rspack/core": "^1.7.5", + "@rspack/lite-tapable": "^1.1.0", "@svgr/webpack": "^8.1.0", "await-lock": "^3.0.0", "c12": "^3.3.2", diff --git a/package.json b/package.json index 6b45ec37..4594f26e 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "@rsdoctor/rspack-plugin": "^1.5.1", "@rspack/cli": "^1.7.5", "@rspack/core": "^1.7.5", + "@rspack/lite-tapable": "^1.1.0", "@svgr/webpack": "^8.1.0", "await-lock": "^3.0.0", "c12": "^3.3.2", diff --git a/src/cli/builders/manifest/ManifestBase.ts b/src/cli/builders/manifest/ManifestBase.ts index e0e6751e..5c9e7ec9 100644 --- a/src/cli/builders/manifest/ManifestBase.ts +++ b/src/cli/builders/manifest/ManifestBase.ts @@ -828,8 +828,18 @@ export default abstract class implements ManifestBuilder } } - if (this.combinedRaws.web_accessible_resources) { - resources.push(...this.combinedRaws.web_accessible_resources); + for (const resource of this.combinedRaws.web_accessible_resources || []) { + if (typeof resource === "string") { + // MV2 resource lists grant access to all sites and extensions. + resources.push({resources: [resource], matches: [""], extensionIds: ["*"]}); + } else { + resources.push({ + resources: resource.resources, + matches: resource.matches, + extensionIds: resource.extension_ids, + useDynamicUrl: resource.use_dynamic_url, + }); + } } return mergeWebAccessibleResources(resources); diff --git a/src/cli/builders/manifest/ManifestV2.test.ts b/src/cli/builders/manifest/ManifestV2.test.ts index 302b2f87..1f0707f0 100644 --- a/src/cli/builders/manifest/ManifestV2.test.ts +++ b/src/cli/builders/manifest/ManifestV2.test.ts @@ -144,6 +144,63 @@ describe("ManifestV2", () => { expect(manifest.optional_permissions).not.toEqual(expect.arrayContaining(["https://required.example.com/*"])); }); + it("removes specific URLs covered by a domain wildcard from combined sources", () => { + const manifest = new ManifestV2(Browser.Chrome) + .addHostPermission("https://sub.example.com/file.json") + .raw({host_permissions: ["https://*.example.com/*"]}) + .build(); + + expect(manifest.permissions).toEqual(["https://*.example.com/*"]); + }); + + it("removes optional URLs already covered by required host permissions", () => { + const manifest = new ManifestV2(Browser.Chrome) + .addHostPermission("https://*.example.com/*") + .addOptionalHostPermission("https://sub.example.com/file.json") + .build(); + + expect(manifest.permissions).toEqual(["https://*.example.com/*"]); + expect(manifest.optional_permissions).toBeUndefined(); + }); + + it("simplifies optional hosts while retaining unrelated hosts and schemes", () => { + const manifest = new ManifestV2(Browser.Chrome) + .addOptionalHostPermission("https://sub.example.com/file.json") + .raw({optional_host_permissions: ["https://*.example.com/*"]}) + .addOptionalHostPermission("http://sub.example.com/file.json") + .addOptionalHostPermission("https://example.org/*") + .build(); + + expect(new Set(manifest.optional_permissions)).toEqual( + new Set(["https://*.example.com/*", "http://sub.example.com/file.json", "https://example.org/*"]) + ); + }); + + it("keeps required access when a broader host permission is optional", () => { + const manifest = new ManifestV2(Browser.Chrome) + .addHostPermission("https://sub.example.com/file.json") + .addOptionalHostPermission("https://*.example.com/*") + .build(); + + expect(manifest.permissions).toEqual(["https://sub.example.com/file.json"]); + expect(manifest.optional_permissions).toEqual(["https://*.example.com/*"]); + }); + + it.each([Browser.Chrome, Browser.Firefox])( + "preserves raw MV2 resource lists alongside generated resources in %s", + browser => { + const manifest = new ManifestV2(browser) + .setDependencies(new Map([["entry", dependency(["entry.js"], [], ["a.js"])]])) + .setContentScripts(new Set([{entry: "entry", matches: ["https://example.com/path/*"]}])) + .addAccessibleResource({resources: ["b.js"], matches: ["https://example.org/*"]}) + .raw({web_accessible_resources: ["a.js", "c.js", "nested/*", "c.js"]}) + .raw({web_accessible_resources: [{resources: ["d.js"], extension_ids: ["*"], use_dynamic_url: true}]}) + .build(); + + expect(manifest.web_accessible_resources?.sort()).toEqual(["a.js", "b.js", "c.js", "d.js", "nested/*"]); + } + ); + it("flattens web accessible resources", () => { const manifest: any = new ManifestV2(Browser.Chrome) .setDependencies( @@ -154,15 +211,15 @@ describe("ManifestV2", () => { ) .setContentScripts( new Set([ - {matches: ["https://site.com/*"], entry: "entry"}, - {matches: ["https://other.com/*"], entry: "entry2"}, + {matches: ["https://example.com/*"], entry: "entry"}, + {matches: ["https://example.org/*"], entry: "entry2"}, ]) ) - .addAccessibleResource({resources: ["img/common.png"], matches: ["https://site.com/*"]}) + .addAccessibleResource({resources: ["img/common.png"], matches: ["https://example.com/*"]}) .raw({ web_accessible_resources: [ - {resources: ["img/raw.png", "img/a.png"], matches: ["https://site.com/*"]}, - {resources: ["img/onlyraw.png"], matches: ["https://other.com/*"]}, + {resources: ["img/raw.png", "img/a.png"], matches: ["https://example.com/*"]}, + {resources: ["img/onlyraw.png"], matches: ["https://example.org/*"]}, ], }) .build(); diff --git a/src/cli/builders/manifest/ManifestV3.test.ts b/src/cli/builders/manifest/ManifestV3.test.ts index d94acbe8..9c51d02d 100644 --- a/src/cli/builders/manifest/ManifestV3.test.ts +++ b/src/cli/builders/manifest/ManifestV3.test.ts @@ -1,10 +1,11 @@ import ManifestV3 from "./ManifestV3"; import {Browser} from "@typing/browser"; import {CommandExecuteActionName} from "@typing/command"; +import type {ManifestDependency} from "@typing/manifest"; const unique = (arr: string[]) => Array.from(new Set(arr)).length === arr.length; -const dependency = (js: string[] = [], css: string[] = [], assets: string[] = []) => ({ +const dependency = (js: string[] = [], css: string[] = [], assets: string[] = []): ManifestDependency => ({ js: new Set(js), css: new Set(css), assets: new Set(assets), @@ -185,6 +186,132 @@ describe("ManifestV3", () => { ); }); + it("removes specific URLs covered by a domain wildcard from combined sources", () => { + const manifest = new ManifestV3(Browser.Chrome) + .addHostPermission("https://sub.example.com/file.json") + .raw({host_permissions: ["https://*.example.com/*"]}) + .build(); + + expect(manifest.host_permissions).toEqual(["https://*.example.com/*"]); + }); + + it("removes optional URLs already covered by required host permissions", () => { + const manifest = new ManifestV3(Browser.Chrome) + .addHostPermission("https://*.example.com/*") + .addOptionalHostPermission("https://sub.example.com/file.json") + .build(); + + expect(manifest.host_permissions).toEqual(["https://*.example.com/*"]); + expect(manifest.optional_host_permissions).toBeUndefined(); + }); + + it("simplifies optional hosts while retaining unrelated hosts and schemes", () => { + const manifest = new ManifestV3(Browser.Chrome) + .addOptionalHostPermission("https://sub.example.com/file.json") + .raw({optional_host_permissions: ["https://*.example.com/*"]}) + .addOptionalHostPermission("http://sub.example.com/file.json") + .addOptionalHostPermission("https://example.org/*") + .build(); + + expect(new Set(manifest.optional_host_permissions)).toEqual( + new Set(["https://*.example.com/*", "http://sub.example.com/file.json", "https://example.org/*"]) + ); + }); + + it("keeps required access when a broader host permission is optional", () => { + const manifest = new ManifestV3(Browser.Chrome) + .addHostPermission("https://sub.example.com/file.json") + .addOptionalHostPermission("https://*.example.com/*") + .build(); + + expect(manifest.host_permissions).toEqual(["https://sub.example.com/file.json"]); + expect(manifest.optional_host_permissions).toEqual(["https://*.example.com/*"]); + }); + + it.each([Browser.Chrome, Browser.Firefox])( + "normalizes WAR origins without changing content script paths in %s", + browser => { + const matches = ["https://example.com/path/*", "https://sub.example.com/nested/*"]; + const manifest = new ManifestV3(browser) + .setDependencies(new Map([["entry", dependency(["entry.js"], [], ["file.json"])]])) + .setContentScripts(new Set([{entry: "entry", matches}])) + .addAccessibleResource({resources: ["file.json"], matches: ["https://*.example.com/other/*"]}) + .build(); + + expect(manifest.content_scripts?.[0].matches).toEqual(matches); + expect(manifest.web_accessible_resources).toEqual([ + {resources: ["file.json"], matches: ["https://*.example.com/*"]}, + ]); + } + ); + + it.each([Browser.Chrome, Browser.Firefox])( + "merges typed and raw WAR fields using native manifest keys in %s", + browser => { + const manifest = new ManifestV3(browser) + .addAccessibleResource({ + resources: ["a.js"], + matches: ["https://example.com/path/*"], + extensionIds: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + useDynamicUrl: true, + }) + .raw({ + web_accessible_resources: [ + { + resources: ["b.js"], + matches: ["https://example.com/nested/*"], + extension_ids: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + use_dynamic_url: true, + }, + {resources: ["c.js"], matches: ["https://example.org/*"], use_dynamic_url: false}, + ], + }) + .build(); + + expect(manifest.web_accessible_resources).toEqual([ + { + resources: ["a.js", "b.js"], + matches: ["https://example.com/*"], + extension_ids: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + use_dynamic_url: true, + }, + {resources: ["c.js"], matches: ["https://example.org/*"], use_dynamic_url: false}, + ]); + } + ); + + it("preserves extension-only access and normalizes wildcard extension IDs", () => { + const manifest = new ManifestV3(Browser.Chrome) + .addAccessibleResource({resources: ["a.js"], extensionIds: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}) + .raw({web_accessible_resources: [{resources: ["a.js"], extension_ids: ["*"]}]}) + .build(); + + expect(manifest.web_accessible_resources).toEqual([{resources: ["a.js"], extension_ids: ["*"]}]); + }); + + it("converts legacy resource lists to global access when building MV3", () => { + const manifest = new ManifestV3(Browser.Chrome) + .raw({web_accessible_resources: ["a.js", "a.js", "nested/*"]}) + .build(); + + expect(manifest.web_accessible_resources).toEqual([ + {resources: ["a.js", "nested/*"], matches: [""], extension_ids: ["*"]}, + ]); + }); + + it("preserves every origin when raw and generated resources only partially overlap", () => { + const manifest = new ManifestV3(Browser.Chrome) + .setDependencies(new Map([["entry", dependency(["entry.js"], [], ["a.js", "b.js"])]])) + .setContentScripts(new Set([{entry: "entry", matches: ["https://example.com/*", "http://example.org/*"]}])) + .raw({web_accessible_resources: [{resources: ["a.js"], matches: ["https://*/*"]}]}) + .build(); + + expect(manifest.web_accessible_resources).toEqual([ + {resources: ["a.js", "b.js"], matches: ["http://example.org/*", "https://example.com/*"]}, + {resources: ["a.js"], matches: ["https://*/*"]}, + ]); + }); + it("groups web accessible resources by match patterns", () => { const manifest: any = new ManifestV3(Browser.Chrome) .setDependencies( @@ -195,23 +322,23 @@ describe("ManifestV3", () => { ) .setContentScripts( new Set([ - {matches: ["https://site.com/*"], entry: "entry"}, - {matches: ["https://other.com/*"], entry: "entry2"}, + {matches: ["https://example.com/*"], entry: "entry"}, + {matches: ["https://example.org/*"], entry: "entry2"}, ]) ) - .addAccessibleResource({resources: ["img/common.png"], matches: ["https://site.com/*"]}) + .addAccessibleResource({resources: ["img/common.png"], matches: ["https://example.com/*"]}) .raw({ web_accessible_resources: [ - {resources: ["img/raw.png", "img/a.png"], matches: ["https://site.com/*"]}, - {resources: ["img/onlyraw.png"], matches: ["https://other.com/*"]}, + {resources: ["img/raw.png", "img/a.png"], matches: ["https://example.com/*"]}, + {resources: ["img/onlyraw.png"], matches: ["https://example.org/*"]}, ], }) .build(); const resources: any[] = manifest.web_accessible_resources; const byMatches = (pattern: string) => resources.find(r => (r.matches || []).includes(pattern)); - const site = byMatches("https://site.com/*"); - const other = byMatches("https://other.com/*"); + const site = byMatches("https://example.com/*"); + const other = byMatches("https://example.org/*"); expect(site.resources).toEqual( expect.arrayContaining(["img/a.png", "img/b.png", "img/common.png", "img/raw.png"]) diff --git a/src/cli/builders/manifest/ManifestV3.ts b/src/cli/builders/manifest/ManifestV3.ts index 2dc6ab75..0482a80f 100644 --- a/src/cli/builders/manifest/ManifestV3.ts +++ b/src/cli/builders/manifest/ManifestV3.ts @@ -111,10 +111,20 @@ export default class extends ManifestBase { protected buildWebAccessibleResources(): Partial | undefined { const resources: ManifestAccessibleResource[] = this.getWebAccessibleResources(); - const transformedResources = resources.map(resource => ({ - resources: resource.resources, - matches: resource.matches || [], - })); + const transformedResources = resources.map(resource => { + const entry = { + resources: resource.resources, + ...(resource.useDynamicUrl !== undefined ? {use_dynamic_url: resource.useDynamicUrl} : {}), + }; + + return resource.extensionIds + ? { + ...entry, + extension_ids: resource.extensionIds, + ...(resource.matches ? {matches: resource.matches} : {}), + } + : {...entry, matches: resource.matches || []}; + }); if (resources.length > 0) { return {web_accessible_resources: transformedResources}; diff --git a/src/cli/builders/manifest/utils.test.ts b/src/cli/builders/manifest/utils.test.ts deleted file mode 100644 index dfd6c529..00000000 --- a/src/cli/builders/manifest/utils.test.ts +++ /dev/null @@ -1,604 +0,0 @@ -import { - filterHostPatterns, - filterOptionalPermissions, - mergeWebAccessibleResources, - normalizeDataCollectionPermissions, -} from "./utils"; -import {DataCollectionPermission} from "@typing/browser"; - -import {ManifestAccessibleResource} from "@typing/manifest"; - -type ManifestPermission = chrome.runtime.ManifestPermission; -type ManifestOptionalPermission = chrome.runtime.ManifestOptionalPermission; - -const toSet = (arr: string[]) => new Set(arr); -const setToArray = (set: Set) => Array.from(set); -const sortResources = (resources: ManifestAccessibleResource[]): ManifestAccessibleResource[] => { - return resources.map(r => { - const result: ManifestAccessibleResource = {resources: r.resources}; - - if (r.matches) result.matches = r.matches.sort(); - if (r.extensionIds) result.extensionIds = r.extensionIds.sort(); - if (r.useDynamicUrl !== undefined) result.useDynamicUrl = r.useDynamicUrl; - - return result; - }); -}; - -describe("filterHostPatterns", () => { - test("returns only when present", () => { - const input = toSet(["", "https://*/*", "http://example.com/*", "chrome-extension://*/*"]); - - const result = filterHostPatterns(input); - - expect(setToArray(result).sort()).toEqual([""]); - }); - - test("handles *://*/* covering http and https but not special or other schemes", () => { - const input = toSet([ - "*://*/*", - "http://*/*", - "https://*/*", - "https://example.com/*", - "http://another.example/*", - "file://*/*", - "file://Downloads/*", - "chrome-extension://*/*", - ]); - - const result = filterHostPatterns(input); - - expect(new Set(result)).toEqual(toSet(["*://*/*", "file://*/*", "chrome-extension://*/*"])); - }); - - test("http wildcard covers only http; https specifics remain", () => { - const input = toSet(["http://*/*", "http://example.com/*", "https://example.com/*"]); - - const result = filterHostPatterns(input); - - expect(new Set(result)).toEqual(toSet(["http://*/*", "https://example.com/*"])); - }); - - test("both http and https wildcards present; ftp specific remains", () => { - const input = toSet([ - "http://*/*", - "https://*/*", - "https://site.com/*", - "http://site.com/*", - "ftp://example.com/*", - ]); - - const result = filterHostPatterns(input); - - expect(new Set(result)).toEqual(toSet(["http://*/*", "https://*/*", "ftp://example.com/*"])); - }); - - test("special schemes are never considered covered by *://*/*", () => { - const input = toSet([ - "*://*/*", - "chrome-extension://*/*", - "moz-extension://*/*", - "data://*/*", - "blob://*/*", - "filesystem://*/*", - "about://*/*", - "chrome://*/*", - "resource://*/*", - ]); - - const result = filterHostPatterns(input); - - expect(new Set(result)).toEqual( - toSet([ - "*://*/*", - "chrome-extension://*/*", - "moz-extension://*/*", - "data://*/*", - "blob://*/*", - "filesystem://*/*", - "about://*/*", - "chrome://*/*", - "resource://*/*", - ]) - ); - }); -}); - -describe("filterOptionalPermissions", () => { - test("removes permissions that are already required", () => { - const required = new Set(["storage"]); - const optional = new Set(["storage", "tabs"]); - - const result = filterOptionalPermissions(optional, required); - - expect(result).toEqual(expect.arrayContaining(["tabs"])); - expect(result).not.toEqual(expect.arrayContaining(["storage"])); - expect(result.length).toBe(1); - }); - - test("drops activeTab from optional when tabs is present in optional", () => { - const optional = new Set(["activeTab", "tabs"]); - const required = new Set(); - - const result = filterOptionalPermissions(optional, required); - - // filterPermissions removes activeTab when tabs is present in the union - expect(result).toEqual(["tabs"]); - }); - - test("drops activeTab from optional when tabs is present in required", () => { - const optional = new Set(["activeTab"]); - const required = new Set(["tabs"]); - - const result = filterOptionalPermissions(optional, required); - - // Union contains tabs and activeTab; filterPermissions removes activeTab, then diff removes tabs as required -> empty - expect(result).toEqual([]); - }); - - test("keeps activeTab when tabs is absent from both optional and required", () => { - const optional = new Set(["activeTab"]); - const required = new Set(); - - const result = filterOptionalPermissions(optional, required); - - expect(result).toEqual(["activeTab"]); - }); - - test("deduplicates and filters correctly when mixing optional and required", () => { - const optional = new Set(["tabs", "storage", "activeTab"]); - const required = new Set(["storage"]); - - const result = filterOptionalPermissions(optional, required); - - // activeTab should be removed because tabs is present; storage removed because it's required - expect(result).toEqual(["tabs"]); - }); -}); - -describe("mergeWebAccessibleResources", () => { - test("merge resources with same matches without duplicates", () => { - const input = [ - { - resources: ["a.js", "b.js"], - matches: ["https://example.com/*"], - }, - { - resources: ["b.js", "c.js"], - matches: ["https://example.com/*"], - }, - { - resources: ["c.js", "d.js"], - matches: ["https://example.com/*"], - }, - ]; - - expect(sortResources(mergeWebAccessibleResources(input))).toEqual( - sortResources([ - { - resources: ["a.js", "b.js", "c.js", "d.js"], - matches: ["https://example.com/*"], - }, - ]) - ); - }); - - test("remove unnecessary matches in one element", () => { - const input_1 = [ - { - resources: ["a.js"], - matches: ["", "https://example.com/*"], - }, - ]; - - const input_2 = [ - { - resources: ["a.js"], - matches: ["*://*/*", "https://example.com/*"], - }, - ]; - - expect(sortResources(mergeWebAccessibleResources(input_1))).toEqual( - sortResources([ - { - resources: ["a.js"], - matches: [""], - }, - ]) - ); - - expect(sortResources(mergeWebAccessibleResources(input_2))).toEqual( - sortResources([ - { - resources: ["a.js"], - matches: ["*://*/*"], - }, - ]) - ); - }); - - test("remove resources from other elements if they exist in the element with or *://*/* pattern", () => { - const input = [ - { - resources: ["a.js"], - matches: [""], - }, - { - resources: ["b.js"], - matches: ["*://*/*"], - }, - { - resources: ["a.js", "b.js"], - matches: ["https://example.com/*"], - }, - { - resources: ["a.js", "b.js", "c.js"], - matches: ["https://google.com/*", "https://google.com/*"], - }, - ]; - - expect(sortResources(mergeWebAccessibleResources(input))).toEqual( - sortResources([ - { - resources: ["a.js"], - matches: [""], - }, - { - resources: ["b.js"], - matches: ["*://*/*"], - }, - { - resources: ["c.js"], - matches: ["https://google.com/*"], - }, - ]) - ); - }); - - test("remove resources from other elements if they exist in elements with some common protocol pattern", () => { - const input = [ - { - resources: ["a.js"], - matches: ["https://*/*", "http://*/*"], - }, - { - resources: ["a.js", "b.js"], - matches: ["https://example.com/*"], - }, - { - resources: ["a.js", "b.js"], - matches: ["http://google.com/*"], - }, - ]; - - expect(sortResources(mergeWebAccessibleResources(input))).toEqual( - sortResources([ - { - resources: ["a.js"], - matches: ["https://*/*", "http://*/*"], - }, - { - resources: ["b.js"], - matches: ["https://example.com/*", "http://google.com/*"], - }, - ]) - ); - }); - - test("сomplete example", () => { - const input = [ - // Case 1: Elements with the same matches - should be merged - { - resources: ["common.js", "shared.css"], - matches: ["https://example.com/*"], - }, - { - resources: ["shared.css", "extra.js"], - matches: ["https://example.com/*"], - }, - - // Case 2: Element with - should clean its matches and remove duplicate resources from other elements - { - resources: ["global.js"], - matches: ["", "https://redundant.com/*", "*://*/*"], - }, - - // Case 3: Element with *://*/* - should clean matches and remove duplicate resources - { - resources: ["universal.js"], - matches: ["*://*/*", "https://also-redundant.com/*"], - }, - - // Case 4: Elements with protocol-specific wildcards - { - resources: ["https-only.js"], - matches: ["https://*/*"], - }, - { - resources: ["http-only.js"], - matches: ["http://*/*"], - }, - - // Case 5: Elements that will be cleaned of resources due to and *://*/* - { - resources: ["global.js", "local1.js"], - matches: ["https://site1.com/*"], - }, - { - resources: ["universal.js", "local2.js"], - matches: ["http://site2.com/*"], - }, - - // Case 6: Elements that will be partially cleaned due to protocol-specific wildcards - { - resources: ["https-only.js", "specific1.js"], - matches: ["https://specific-site.com/*"], - }, - { - resources: ["http-only.js", "specific2.js"], - matches: ["http://another-site.com/*"], - }, - - // Case 7: Elements with special schemes - should not be covered by wildcards - { - resources: ["extension.js"], - matches: ["chrome-extension://*/*"], - }, - { - resources: ["file-access.js"], - matches: ["file://*/*"], - }, - - // Case 8: Elements that remain unchanged - { - resources: ["unique.js"], - matches: ["ftp://special.com/*"], - }, - ]; - - expect(sortResources(mergeWebAccessibleResources(input))).toEqual( - sortResources([ - // Merged elements with the same matches - { - resources: ["common.js", "extra.js", "shared.css"], - matches: ["https://example.com/*"], - }, - - // Element with (cleaned matches, unique resources) - { - resources: ["global.js"], - matches: [""], - }, - - // Element with *://*/* (cleaned matches) - { - resources: ["universal.js"], - matches: ["*://*/*"], - }, - - // Protocol-specific wildcards - { - resources: ["https-only.js"], - matches: ["https://*/*"], - }, - { - resources: ["http-only.js"], - matches: ["http://*/*"], - }, - - // Elements with local resources (global.js removed due to ) - { - resources: ["local1.js"], - matches: ["https://site1.com/*"], - }, - { - resources: ["local2.js"], - matches: ["http://site2.com/*"], - }, - - // Specific elements (partially cleaned due to protocol wildcards) - { - resources: ["specific1.js"], - matches: ["https://specific-site.com/*"], - }, - { - resources: ["specific2.js"], - matches: ["http://another-site.com/*"], - }, - - // Special schemes (unchanged) - { - resources: ["extension.js"], - matches: ["chrome-extension://*/*"], - }, - { - resources: ["file-access.js"], - matches: ["file://*/*"], - }, - - // Unique element - { - resources: ["unique.js"], - matches: ["ftp://special.com/*"], - }, - ]) - ); - }); - - test("performs multi-iteration merging across keys (regression for changed accumulation)", () => { - const input: ManifestAccessibleResource[] = [ - {resources: ["a.js"], matches: ["https://m1/*"]}, - {resources: ["b.js"], matches: ["https://m1/*"]}, - {resources: ["a.js", "b.js"], matches: ["https://m2/*"]}, - ]; - - const output = sortResources(mergeWebAccessibleResources(input)); - - expect(output).toEqual( - sortResources([{resources: ["a.js", "b.js"], matches: ["https://m1/*", "https://m2/*"]}]) - ); - }); - - test("merges by extensionIds when other fields are equal", () => { - const input: ManifestAccessibleResource[] = [ - {resources: ["r.js"], matches: ["https://site/*"], extensionIds: ["id1"]}, - {resources: ["r.js"], matches: ["https://site/*"], extensionIds: ["id2"]}, - ]; - - const output = sortResources(mergeWebAccessibleResources(input)); - - expect(output).toEqual( - sortResources([{resources: ["r.js"], matches: ["https://site/*"], extensionIds: ["id1", "id2"]}]) - ); - }); - - test("cleanup with respects extensionIds and useDynamicUrl", () => { - const input: ManifestAccessibleResource[] = [ - // Global entry for id1, useDynamicUrl true - {resources: ["g.js"], matches: [""], extensionIds: ["id1"], useDynamicUrl: true}, - // Same id and useDynamic -> removal should happen - {resources: ["g.js", "keep1.js"], matches: ["https://site/*"], extensionIds: ["id1"], useDynamicUrl: true}, - // Different extensionIds -> should NOT be removed - {resources: ["g.js", "keep2.js"], matches: ["https://site/*"], extensionIds: ["id2"], useDynamicUrl: true}, - // Different useDynamicUrl -> should NOT be removed - {resources: ["g.js", "keep3.js"], matches: ["https://site/*"], extensionIds: ["id1"], useDynamicUrl: false}, - ]; - - const output = sortResources(mergeWebAccessibleResources(input)); - - expect(output).toEqual( - sortResources([ - {resources: ["g.js"], matches: [""], extensionIds: ["id1"], useDynamicUrl: true}, - {resources: ["keep1.js"], matches: ["https://site/*"], extensionIds: ["id1"], useDynamicUrl: true}, - { - resources: ["g.js", "keep2.js"], - matches: ["https://site/*"], - extensionIds: ["id2"], - useDynamicUrl: true, - }, - { - resources: ["g.js", "keep3.js"], - matches: ["https://site/*"], - extensionIds: ["id1"], - useDynamicUrl: false, - }, - ]) - ); - }); -}); - -describe("normalizeDataCollectionPermissions", () => { - it("should return default required 'none' if input is empty", () => { - const result = normalizeDataCollectionPermissions(); - - expect(result).toEqual({ - required: ["none"], - optional: undefined, - }); - }); - - it("should remove 'none' if real permissions are provided in required", () => { - const input = { - required: ["none" as any, DataCollectionPermission.WebsiteActivity], - }; - - const result = normalizeDataCollectionPermissions(input); - - expect(result.required).toEqual([DataCollectionPermission.WebsiteActivity]); - expect(result.required).not.toContain("none"); - }); - - it("should keep 'none' if it is the only permission in required", () => { - const input = { - required: ["none" as any], - }; - - const result = normalizeDataCollectionPermissions(input); - - expect(result.required).toEqual(["none"]); - expect(result.optional).toBeUndefined(); - }); - - it("should deduplicate permissions in required and optional", () => { - const input = { - required: [DataCollectionPermission.WebsiteActivity, DataCollectionPermission.WebsiteActivity], - optional: [DataCollectionPermission.SearchTerms, DataCollectionPermission.SearchTerms], - }; - - const result = normalizeDataCollectionPermissions(input); - - expect(result.required).toEqual([DataCollectionPermission.WebsiteActivity]); - expect(result.optional).toEqual([DataCollectionPermission.SearchTerms]); - }); - - it("should remove permissions from optional if they are in required", () => { - const input = { - required: [DataCollectionPermission.WebsiteActivity], - optional: [DataCollectionPermission.WebsiteActivity, DataCollectionPermission.SearchTerms], - }; - - const result = normalizeDataCollectionPermissions(input); - - expect(result.required).toEqual([DataCollectionPermission.WebsiteActivity]); - expect(result.optional).toEqual([DataCollectionPermission.SearchTerms]); - }); - - it("should handle mixed string and enum permissions including new BookmarksInfo", () => { - const input = { - required: ["websiteActivity" as any, DataCollectionPermission.SearchTerms], - optional: ["searchTerms" as any, DataCollectionPermission.BookmarksInfo], - }; - - // "searchTerms" is in both, so it should be removed from optional - const result = normalizeDataCollectionPermissions(input); - - expect(result.required).toContain("websiteActivity"); - expect(result.required).toContain(DataCollectionPermission.SearchTerms); - expect(result.optional).toEqual([DataCollectionPermission.BookmarksInfo]); - }); - - it("should filter out invalid permissions not present in the enum", () => { - const input = { - required: ["invalid-permission" as any, DataCollectionPermission.WebsiteActivity], - optional: [DataCollectionPermission.SearchTerms, "another-garbage" as any], - }; - - const result = normalizeDataCollectionPermissions(input); - - expect(result.required).toEqual([DataCollectionPermission.WebsiteActivity]); - expect(result.optional).toEqual([DataCollectionPermission.SearchTerms]); - }); - - it("should remove 'none' from optional permissions", () => { - const input = { - required: [DataCollectionPermission.WebsiteActivity], - optional: ["none" as any, DataCollectionPermission.SearchTerms], - }; - - const result = normalizeDataCollectionPermissions(input); - - expect(result.optional).toEqual([DataCollectionPermission.SearchTerms]); - expect(result.optional).not.toContain("none"); - }); - - it("should sort permissions alphabetically in both arrays", () => { - const input = { - required: [DataCollectionPermission.WebsiteActivity, DataCollectionPermission.SearchTerms], - optional: [DataCollectionPermission.LocationInfo, DataCollectionPermission.AuthenticationInfo], - }; - - const result = normalizeDataCollectionPermissions(input); - - // Sorting check: searchTerms < websiteActivity - expect(result.required).toEqual([ - DataCollectionPermission.SearchTerms, - DataCollectionPermission.WebsiteActivity, - ]); - - // Sorting check: authenticationInfo < locationInfo - expect(result.optional).toEqual([ - DataCollectionPermission.AuthenticationInfo, - DataCollectionPermission.LocationInfo, - ]); - }); -}); diff --git a/src/cli/builders/manifest/utils.ts b/src/cli/builders/manifest/utils.ts deleted file mode 100644 index d02d32a2..00000000 --- a/src/cli/builders/manifest/utils.ts +++ /dev/null @@ -1,306 +0,0 @@ -import _ from "lodash"; -import {DataCollectionPermission, DataCollectionPermissions} from "@typing/browser"; - -import {ManifestAccessibleResource, ManifestMatchSchemes, ManifestSpecialSchemes} from "@typing/manifest"; - -type ManifestPermissions = chrome.runtime.ManifestPermission; -type ManifestOptionalPermissions = chrome.runtime.ManifestOptionalPermission; - -type Permission = ManifestPermissions | ManifestOptionalPermissions; -/** - * Filters and adapts permissions for Manifest V2 compatibility. - * - * @param permissions - Set of permissions to filter - * @returns New set of permissions adapted for Manifest V2 - */ -export const filterPermissions = (permissions: Set): Set => { - if (permissions.has("tabs" as T)) { - permissions.delete("activeTab" as T); - } - return permissions; -}; - -export const filterPermissionsForMV2 = (permissions: Set): Set => { - const filteredPermissions = new Set(permissions); - - /** - * Manifest V3 introduced a new 'scripting' permission for working with the Chrome Scripting API, - * but Manifest V2 uses the 'tabs' permission for executing scripts on web pages. - * This function automatically replaces 'scripting' with 'tabs' to ensure backward compatibility. - * - * @example - * ```typescript - * const mv3Permissions = new Set(['scripting', 'storage', 'activeTab']); - * const mv2Permissions = filterPermissionsForMV2(mv3Permissions); - * // Result: Set(['tabs', 'storage', 'activeTab']) - * ``` - */ - if (filteredPermissions.has("scripting" as T)) { - filteredPermissions.delete("scripting" as T); - filteredPermissions.add("tabs" as T); - } - - filteredPermissions.delete("offscreen" as T); - - return filterPermissions(filteredPermissions); -}; - -export const filterPermissionsForMV3 = (permissions: Set): Set => { - const filteredPermissions = new Set(permissions); - - /** - * @permissions webRequestBlocking, webAuthenticationProxy, webRequestAuthProvider - * @description These permissions were removed in Manifest V3 due to concerns related to security, performance, and transparency. - * - `webRequestBlocking`: Allowed real-time interception and modification of network requests, which introduced latency and potential security risks. Replaced by the safer, declarative `declarativeNetRequest` API. - * - `webAuthenticationProxy`: Provided proxy authentication control, but was rarely used and posed privacy risks. - * - `webRequestAuthProvider`: Enabled handling of HTTP authentication (onAuthRequired), but was considered unsafe in the new MV3 architecture due to its ability to interfere with authentication flows. - * @manifestV3 Removed. These APIs are no longer available in extensions using Manifest V3. - */ - filteredPermissions.delete("webAuthenticationProxy" as T); - filteredPermissions.delete("webRequestAuthProvider" as T); - filteredPermissions.delete("webRequestBlocking" as T); - - return filterPermissions(filteredPermissions); -}; - -export const filterOptionalPermissions = ( - optional: Set, - required: Set -): O[] => { - const allPermissions = filterPermissions(new Set([...optional, ...required])); - return _.difference(Array.from(allPermissions), Array.from(required)) as O[]; -}; - -export const filterHostPatterns = (patterns: Set): Set => { - if (patterns.has("")) { - return new Set([""]); - } - - const result = new Set(); - - const activeWildcards = new Set(); - - if (patterns.has("*://*/*")) { - activeWildcards.add("*://*/*"); - result.add("*://*/*"); - } - - for (const scheme of ManifestMatchSchemes) { - const wildcard = `${scheme}://*/*`; - - if (patterns.has(wildcard)) { - if (scheme === "http" || scheme === "https") { - if (!activeWildcards.has("*://*/*")) { - activeWildcards.add(wildcard); - result.add(wildcard); - } - } else { - activeWildcards.add(wildcard); - result.add(wildcard); - } - } - } - - const isCoveredByWildcard = (pattern: string): boolean => { - const schemeIndex = pattern.indexOf("://"); - if (schemeIndex === -1) return false; - - const scheme = pattern.substring(0, schemeIndex); - - if (ManifestSpecialSchemes.has(scheme)) { - return false; - } - - if (activeWildcards.has("*://*/*") && (scheme === "http" || scheme === "https")) { - return true; - } - - return activeWildcards.has(`${scheme}://*/*`); - }; - - for (const pattern of patterns) { - if (activeWildcards.has(pattern)) { - continue; - } - - if (!isCoveredByWildcard(pattern)) { - result.add(pattern); - } - } - - return result; -}; - -export const mergeWebAccessibleResources = (resources: ManifestAccessibleResource[]): ManifestAccessibleResource[] => { - if (resources.length === 0) return []; - - const simplifiedMatches: ManifestAccessibleResource[] = resources.map(r => { - const {resources, matches, extensionIds, useDynamicUrl} = r; - return { - resources, - extensionIds, - useDynamicUrl, - matches: matches ? Array.from(filterHostPatterns(new Set(matches))) : matches, - }; - }); - - const normalize = (arr?: string[]) => Array.from(new Set(arr || [])).sort(); - - const makeKey = (r: ManifestAccessibleResource, exclude: (keyof ManifestAccessibleResource)[]): string => { - const obj: ManifestAccessibleResource = { - resources: normalize(r.resources), - matches: normalize(r.matches), - extensionIds: normalize(r.extensionIds), - useDynamicUrl: r.useDynamicUrl, - }; - - for (const field of exclude) { - delete obj[field]; - } - - return JSON.stringify(obj); - }; - - const mergeTwo = (a: ManifestAccessibleResource, b: ManifestAccessibleResource): ManifestAccessibleResource => ({ - resources: normalize([...a.resources, ...b.resources]), - matches: normalize([...(a.matches || []), ...(b.matches || [])]), - extensionIds: normalize([...(a.extensionIds || []), ...(b.extensionIds || [])]), - useDynamicUrl: a.useDynamicUrl, - }); - - const merge = (resources: ManifestAccessibleResource[], mergeBy: keyof ManifestAccessibleResource) => { - const map = new Map(); - let changed = false; - for (const r of resources) { - const key = makeKey(r, [mergeBy]); - if (map.has(key)) { - const merged = mergeTwo(map.get(key)!, r); - map.set(key, merged); - changed = true; - } else { - map.set(key, r); - } - } - - return { - changed, - result: Array.from(map.values()), - }; - }; - - let changed = true; - - let result: ManifestAccessibleResource[] = simplifiedMatches.map(r => ({ - resources: normalize(r.resources), - matches: normalize(r.matches), - extensionIds: normalize(r.extensionIds), - useDynamicUrl: r.useDynamicUrl, - })); - - while (changed) { - changed = false; - - const mergeByResources = merge(result, "resources"); - const afterResources = mergeByResources.result; - - const mergeByMatches = merge(afterResources, "matches"); - const afterMatches = mergeByMatches.result; - - const mergeByExtensionIds = merge(afterMatches, "extensionIds"); - const afterExtensionIds = mergeByExtensionIds.result; - - changed = mergeByResources.changed || mergeByMatches.changed || mergeByExtensionIds.changed; - result = afterExtensionIds; - } - - for (const entry of result) { - if (!entry.matches) continue; - if (entry.matches.includes("") || entry.matches.includes("*://*/*")) { - for (const other of result) { - if (other === entry) continue; - - if ( - _.isEqual(other.matches, entry.matches) || - other.useDynamicUrl !== entry.useDynamicUrl || - !_.isEqual(other.extensionIds, entry.extensionIds) - ) { - continue; - } - other.resources = other.resources.filter(r => !entry.resources.includes(r)); - } - } - } - - for (const entry of result) { - if (!entry.matches) continue; - for (const scheme of ManifestMatchSchemes) { - if (entry.matches.includes(`${scheme}://*/*`)) { - for (const other of result) { - if (other === entry) continue; - if ( - other.useDynamicUrl !== entry.useDynamicUrl || - !_.isEqual(other.extensionIds, entry.extensionIds) - ) { - continue; - } - if (other.matches?.some(m => m.startsWith(`${scheme}://`))) { - other.resources = other.resources.filter(r => !entry.resources.includes(r)); - } - } - } - } - } - - result = result - .filter(r => r.resources.length > 0) - .map(r => { - if (r.matches?.length === 0) delete r.matches; - if (r.extensionIds?.length === 0) delete r.extensionIds; - if (r.useDynamicUrl === undefined) delete r.useDynamicUrl; - return r; - }); - - return result; -}; - -/** - * Normalizes data collection permissions for the manifest. - * - * - Removes duplicate permissions. - * - Filters out invalid permissions not present in DataCollectionPermission enum. - * - Sets "none" as default if required permissions are empty. - * - Removes "none" if real permissions are added to required. - * - Removes "none" from optional permissions. - * - Removes any permission from optional if it's already in required. - * - Sorts both required and optional permissions alphabetically. - * - * @param permissions - The permissions object to normalize. - */ -export const normalizeDataCollectionPermissions = ( - permissions?: DataCollectionPermissions -): DataCollectionPermissions => { - const validPermissions = Object.values(DataCollectionPermission) as string[]; - - let required = _.uniq(permissions?.required || []).filter( - p => (p as string) === "none" || validPermissions.includes(p as string) - ); - - let optional = _.uniq(permissions?.optional || []).filter( - p => (p as string) !== "none" && validPermissions.includes(p as string) - ); - - if (required.length === 0) { - required = ["none" as any]; - } - - if (required.length > 1 && required.includes("none" as any)) { - required = _.without(required, "none" as any); - } - - optional = _.difference(optional, required); - - return { - required: required.toSorted() as DataCollectionPermissions["required"], - optional: optional.length > 0 ? (optional.toSorted() as DataCollectionPermissions["optional"]) : undefined, - }; -}; diff --git a/src/cli/builders/manifest/utils/data-collection-permissions.test.ts b/src/cli/builders/manifest/utils/data-collection-permissions.test.ts new file mode 100644 index 00000000..8ad03e3c --- /dev/null +++ b/src/cli/builders/manifest/utils/data-collection-permissions.test.ts @@ -0,0 +1,118 @@ +import {normalizeDataCollectionPermissions} from "./data-collection-permissions"; +import {DataCollectionPermission} from "@typing/browser"; + +describe("normalizeDataCollectionPermissions", () => { + it("should return default required 'none' if input is empty", () => { + const result = normalizeDataCollectionPermissions(); + + expect(result).toEqual({ + required: ["none"], + optional: undefined, + }); + }); + + it("should remove 'none' if real permissions are provided in required", () => { + const input = { + required: ["none" as any, DataCollectionPermission.WebsiteActivity], + }; + + const result = normalizeDataCollectionPermissions(input); + + expect(result.required).toEqual([DataCollectionPermission.WebsiteActivity]); + expect(result.required).not.toContain("none"); + }); + + it("should keep 'none' if it is the only permission in required", () => { + const input = { + required: ["none" as any], + }; + + const result = normalizeDataCollectionPermissions(input); + + expect(result.required).toEqual(["none"]); + expect(result.optional).toBeUndefined(); + }); + + it("should deduplicate permissions in required and optional", () => { + const input = { + required: [DataCollectionPermission.WebsiteActivity, DataCollectionPermission.WebsiteActivity], + optional: [DataCollectionPermission.SearchTerms, DataCollectionPermission.SearchTerms], + }; + + const result = normalizeDataCollectionPermissions(input); + + expect(result.required).toEqual([DataCollectionPermission.WebsiteActivity]); + expect(result.optional).toEqual([DataCollectionPermission.SearchTerms]); + }); + + it("should remove permissions from optional if they are in required", () => { + const input = { + required: [DataCollectionPermission.WebsiteActivity], + optional: [DataCollectionPermission.WebsiteActivity, DataCollectionPermission.SearchTerms], + }; + + const result = normalizeDataCollectionPermissions(input); + + expect(result.required).toEqual([DataCollectionPermission.WebsiteActivity]); + expect(result.optional).toEqual([DataCollectionPermission.SearchTerms]); + }); + + it("should handle mixed string and enum permissions including new BookmarksInfo", () => { + const input = { + required: ["websiteActivity" as any, DataCollectionPermission.SearchTerms], + optional: ["searchTerms" as any, DataCollectionPermission.BookmarksInfo], + }; + + // "searchTerms" is in both, so it should be removed from optional + const result = normalizeDataCollectionPermissions(input); + + expect(result.required).toContain("websiteActivity"); + expect(result.required).toContain(DataCollectionPermission.SearchTerms); + expect(result.optional).toEqual([DataCollectionPermission.BookmarksInfo]); + }); + + it("should filter out invalid permissions not present in the enum", () => { + const input = { + required: ["invalid-permission" as any, DataCollectionPermission.WebsiteActivity], + optional: [DataCollectionPermission.SearchTerms, "another-garbage" as any], + }; + + const result = normalizeDataCollectionPermissions(input); + + expect(result.required).toEqual([DataCollectionPermission.WebsiteActivity]); + expect(result.optional).toEqual([DataCollectionPermission.SearchTerms]); + }); + + it("should remove 'none' from optional permissions", () => { + const input = { + required: [DataCollectionPermission.WebsiteActivity], + optional: ["none" as any, DataCollectionPermission.SearchTerms], + }; + + const result = normalizeDataCollectionPermissions(input); + + expect(result.optional).toEqual([DataCollectionPermission.SearchTerms]); + expect(result.optional).not.toContain("none"); + }); + + it("should sort permissions alphabetically in both arrays", () => { + const input = { + required: [DataCollectionPermission.WebsiteActivity, DataCollectionPermission.SearchTerms], + optional: [DataCollectionPermission.LocationInfo, DataCollectionPermission.AuthenticationInfo], + }; + + const result = normalizeDataCollectionPermissions(input); + + // Sorting check: searchTerms < websiteActivity + expect(result.required).toEqual([ + DataCollectionPermission.SearchTerms, + DataCollectionPermission.WebsiteActivity, + ]); + + // Sorting check: authenticationInfo < locationInfo + expect(result.optional).toEqual([ + DataCollectionPermission.AuthenticationInfo, + DataCollectionPermission.LocationInfo, + ]); + }); +}); diff --git a/src/cli/builders/manifest/utils/data-collection-permissions.ts b/src/cli/builders/manifest/utils/data-collection-permissions.ts new file mode 100644 index 00000000..5235977e --- /dev/null +++ b/src/cli/builders/manifest/utils/data-collection-permissions.ts @@ -0,0 +1,44 @@ +import _ from "lodash"; +import {DataCollectionPermission, DataCollectionPermissions} from "@typing/browser"; + +/** + * Normalizes data collection permissions for the manifest. + * + * - Removes duplicate permissions. + * - Filters out invalid permissions not present in DataCollectionPermission enum. + * - Sets "none" as default if required permissions are empty. + * - Removes "none" if real permissions are added to required. + * - Removes "none" from optional permissions. + * - Removes any permission from optional if it's already in required. + * - Sorts both required and optional permissions alphabetically. + * + * @param permissions - The permissions object to normalize. + */ +export const normalizeDataCollectionPermissions = ( + permissions?: DataCollectionPermissions +): DataCollectionPermissions => { + const validPermissions = Object.values(DataCollectionPermission) as string[]; + + let required = _.uniq(permissions?.required || []).filter( + p => (p as string) === "none" || validPermissions.includes(p as string) + ); + + let optional = _.uniq(permissions?.optional || []).filter( + p => (p as string) !== "none" && validPermissions.includes(p as string) + ); + + if (required.length === 0) { + required = ["none" as any]; + } + + if (required.length > 1 && required.includes("none" as any)) { + required = _.without(required, "none" as any); + } + + optional = _.difference(optional, required); + + return { + required: required.toSorted() as DataCollectionPermissions["required"], + optional: optional.length > 0 ? (optional.toSorted() as DataCollectionPermissions["optional"]) : undefined, + }; +}; diff --git a/src/cli/builders/manifest/utils/host-patterns.test.ts b/src/cli/builders/manifest/utils/host-patterns.test.ts new file mode 100644 index 00000000..895b7e4a --- /dev/null +++ b/src/cli/builders/manifest/utils/host-patterns.test.ts @@ -0,0 +1,132 @@ +import {filterHostPatterns} from "./host-patterns"; + +const toSet = (arr: string[]) => new Set(arr); +const setToArray = (set: Set) => Array.from(set); + +describe("filterHostPatterns", () => { + test.each([ + ["https://*.example.com/*", "https://sub.example.com/file.json"], + ["https://*.example.com/*", "https://example.com/file.json"], + ["https://*.example.com/*", "https://nested.sub.example.com/file.json"], + ["https://*.example.com/*", "https://*.sub.example.com/path/*"], + ["https://sub.example.com/*", "https://sub.example.com/file.json"], + ["https://*.example.com/path/*", "https://sub.example.com/path/file.json"], + ["https://*.example.com/path/*", "https://sub.example.com/path/nested/*"], + ["https://*.example.com/*/file*.json", "https://sub.example.com/nested/file-a.json"], + ["*://*.example.com/*", "http://sub.example.com/file.json"], + ["*://*.example.com/*", "https://sub.example.com/file.json"], + ["*://*.example.com/*", "*://sub.example.com/path/*"], + ["https://*/path/*", "https://sub.example.com/path/file.json"], + ["file:///*", "file:///path/file.json"], + ])("%s covers %s regardless of input order", (covering, covered) => { + expect(filterHostPatterns(toSet([covered, covering]))).toEqual(toSet([covering])); + expect(filterHostPatterns(toSet([covering, covered]))).toEqual(toSet([covering])); + }); + + test.each([ + ["https://*.sub.example.com/*", "https://notsub.example.com/file.json"], + ["https://*.example.com/*", "https://example.com.example.org/file.json"], + ["https://sub.example.com/*", "https://example.com/file.json"], + ["https://sub.example.com/*", "https://nested.sub.example.com/file.json"], + ["https://*.example.com/*", "http://sub.example.com/file.json"], + ["https://*.example.com/*", "*://sub.example.com/file.json"], + ["*://*.example.com/*", "ftp://sub.example.com/file.json"], + ["*://*.example.com/*", "ws://sub.example.com/*"], + ["*://*.example.com/*", "wss://sub.example.com/*"], + ["https://*.example.com/path/*", "https://sub.example.com/other/file.json"], + ["https://*.example.com/path/*", "https://sub.example.com/*"], + ["https://*.example.com/file.json?key=*", "https://sub.example.com/fileXjson?key=value"], + ["https://*.example.com/*/file.json", "https://sub.example.com/*/file.json*"], + ["chrome-extension://*/*", "chrome-extension://extension-id/page.html"], + ["custom://*/*", "custom://sub.example.com/file.json"], + ])("preserves patterns without full coverage: %s and %s", (first, second) => { + const input = toSet([first, second]); + + expect(filterHostPatterns(input)).toEqual(input); + }); + + test("keeps one equivalent pattern and does not mutate the input", () => { + const input = toSet(["https://example.com/**", "https://example.com/*"]); + + expect(filterHostPatterns(input)).toEqual(toSet(["https://example.com/**"])); + expect(setToArray(input)).toEqual(["https://example.com/**", "https://example.com/*"]); + expect(filterHostPatterns(new Set())).toEqual(new Set()); + }); + + test("returns only when present", () => { + const input = toSet(["", "https://*/*", "http://example.com/*", "chrome-extension://*/*"]); + + const result = filterHostPatterns(input); + + expect(setToArray(result).sort()).toEqual([""]); + }); + + test("handles *://*/* covering http and https but not special or other schemes", () => { + const input = toSet([ + "*://*/*", + "http://*/*", + "https://*/*", + "https://example.com/*", + "http://another.example/*", + "file://*/*", + "file://Downloads/*", + "chrome-extension://*/*", + ]); + + const result = filterHostPatterns(input); + + expect(new Set(result)).toEqual(toSet(["*://*/*", "file://*/*", "chrome-extension://*/*"])); + }); + + test("http wildcard covers only http; https specifics remain", () => { + const input = toSet(["http://*/*", "http://example.com/*", "https://example.com/*"]); + + const result = filterHostPatterns(input); + + expect(new Set(result)).toEqual(toSet(["http://*/*", "https://example.com/*"])); + }); + + test("both http and https wildcards present; ftp specific remains", () => { + const input = toSet([ + "http://*/*", + "https://*/*", + "https://site.example.com/*", + "http://site.example.com/*", + "ftp://example.com/*", + ]); + + const result = filterHostPatterns(input); + + expect(new Set(result)).toEqual(toSet(["http://*/*", "https://*/*", "ftp://example.com/*"])); + }); + + test("special schemes are never considered covered by *://*/*", () => { + const input = toSet([ + "*://*/*", + "chrome-extension://*/*", + "moz-extension://*/*", + "data://*/*", + "blob://*/*", + "filesystem://*/*", + "about://*/*", + "chrome://*/*", + "resource://*/*", + ]); + + const result = filterHostPatterns(input); + + expect(new Set(result)).toEqual( + toSet([ + "*://*/*", + "chrome-extension://*/*", + "moz-extension://*/*", + "data://*/*", + "blob://*/*", + "filesystem://*/*", + "about://*/*", + "chrome://*/*", + "resource://*/*", + ]) + ); + }); +}); diff --git a/src/cli/builders/manifest/utils/host-patterns.ts b/src/cli/builders/manifest/utils/host-patterns.ts new file mode 100644 index 00000000..656d5586 --- /dev/null +++ b/src/cli/builders/manifest/utils/host-patterns.ts @@ -0,0 +1,67 @@ +import _ from "lodash"; +import {ManifestMatchSchemes} from "@typing/manifest"; + +interface HostPattern { + scheme: string; + host: string; + path: string; + pathMatcher: RegExp; +} + +const parseHostPattern = (pattern: string): HostPattern | undefined => { + const match = pattern.match(/^([^:]+):\/\/([^/]*)(\/.*)$/); + if (!match) return; + + const [, scheme, host, path] = match; + if (scheme !== "*" && !ManifestMatchSchemes.has(scheme)) return; + + return { + scheme, + host, + path, + pathMatcher: new RegExp(`^${path.split("*").map(_.escapeRegExp).join(".*")}$`), + }; +}; + +const isHostPatternCovered = (pattern?: HostPattern, covering?: HostPattern): boolean => { + if (!pattern || !covering) return false; + + const schemeCovered = + covering.scheme === pattern.scheme || + (covering.scheme === "*" && (pattern.scheme === "http" || pattern.scheme === "https")); + if (!schemeCovered) return false; + + const hostCovered = + covering.host === "*" || + covering.host === pattern.host || + (covering.host.startsWith("*.") && + (pattern.host === covering.host.slice(2) || pattern.host.endsWith(covering.host.slice(1)))); + if (!hostCovered) return false; + + // Keep path constraints: this utility also filters web-accessible resource matches. + // Any '*' in the candidate must be consumed by a wildcard in the covering path. + return covering.pathMatcher.test(pattern.path); +}; + +export const filterHostPatterns = (patterns: Set): Set => { + if (patterns.has("")) { + return new Set([""]); + } + + const entries = Array.from(patterns, pattern => ({pattern, parsed: parseHostPattern(pattern)})); + + return new Set( + entries + .filter( + (entry, index) => + !entries.some( + (other, otherIndex) => + index !== otherIndex && + isHostPatternCovered(entry.parsed, other.parsed) && + // Equivalent patterns keep their first occurrence instead of removing each other. + (otherIndex < index || !isHostPatternCovered(other.parsed, entry.parsed)) + ) + ) + .map(({pattern}) => pattern) + ); +}; diff --git a/src/cli/builders/manifest/utils/index.ts b/src/cli/builders/manifest/utils/index.ts new file mode 100644 index 00000000..67fd27fd --- /dev/null +++ b/src/cli/builders/manifest/utils/index.ts @@ -0,0 +1,9 @@ +export { + filterPermissions, + filterPermissionsForMV2, + filterPermissionsForMV3, + filterOptionalPermissions, +} from "./permissions"; +export {filterHostPatterns} from "./host-patterns"; +export {mergeWebAccessibleResources} from "./web-accessible-resources"; +export {normalizeDataCollectionPermissions} from "./data-collection-permissions"; diff --git a/src/cli/builders/manifest/utils/permissions.test.ts b/src/cli/builders/manifest/utils/permissions.test.ts new file mode 100644 index 00000000..cd706820 --- /dev/null +++ b/src/cli/builders/manifest/utils/permissions.test.ts @@ -0,0 +1,56 @@ +import {filterOptionalPermissions} from "./permissions"; + +type ManifestPermission = chrome.runtime.ManifestPermission; +type ManifestOptionalPermission = chrome.runtime.ManifestOptionalPermission; + +describe("filterOptionalPermissions", () => { + test("removes permissions that are already required", () => { + const required = new Set(["storage"]); + const optional = new Set(["storage", "tabs"]); + + const result = filterOptionalPermissions(optional, required); + + expect(result).toEqual(expect.arrayContaining(["tabs"])); + expect(result).not.toEqual(expect.arrayContaining(["storage"])); + expect(result.length).toBe(1); + }); + + test("drops activeTab from optional when tabs is present in optional", () => { + const optional = new Set(["activeTab", "tabs"]); + const required = new Set(); + + const result = filterOptionalPermissions(optional, required); + + // filterPermissions removes activeTab when tabs is present in the union + expect(result).toEqual(["tabs"]); + }); + + test("drops activeTab from optional when tabs is present in required", () => { + const optional = new Set(["activeTab"]); + const required = new Set(["tabs"]); + + const result = filterOptionalPermissions(optional, required); + + // Union contains tabs and activeTab; filterPermissions removes activeTab, then diff removes tabs as required -> empty + expect(result).toEqual([]); + }); + + test("keeps activeTab when tabs is absent from both optional and required", () => { + const optional = new Set(["activeTab"]); + const required = new Set(); + + const result = filterOptionalPermissions(optional, required); + + expect(result).toEqual(["activeTab"]); + }); + + test("deduplicates and filters correctly when mixing optional and required", () => { + const optional = new Set(["tabs", "storage", "activeTab"]); + const required = new Set(["storage"]); + + const result = filterOptionalPermissions(optional, required); + + // activeTab should be removed because tabs is present; storage removed because it's required + expect(result).toEqual(["tabs"]); + }); +}); diff --git a/src/cli/builders/manifest/utils/permissions.ts b/src/cli/builders/manifest/utils/permissions.ts new file mode 100644 index 00000000..20469a88 --- /dev/null +++ b/src/cli/builders/manifest/utils/permissions.ts @@ -0,0 +1,71 @@ +import _ from "lodash"; + +type ManifestPermissions = chrome.runtime.ManifestPermission; +type ManifestOptionalPermissions = chrome.runtime.ManifestOptionalPermission; + +type Permission = ManifestPermissions | ManifestOptionalPermissions; +/** + * Filters and adapts permissions for Manifest V2 compatibility. + * + * @param permissions - Set of permissions to filter + * @returns New set of permissions adapted for Manifest V2 + */ +export const filterPermissions = (permissions: Set): Set => { + if (permissions.has("tabs" as T)) { + permissions.delete("activeTab" as T); + } + + return permissions; +}; + +export const filterPermissionsForMV2 = (permissions: Set): Set => { + const filteredPermissions = new Set(permissions); + + /** + * Manifest V3 introduced a new 'scripting' permission for working with the Chrome Scripting API, + * but Manifest V2 uses the 'tabs' permission for executing scripts on web pages. + * This function automatically replaces 'scripting' with 'tabs' to ensure backward compatibility. + * + * @example + * ```typescript + * const mv3Permissions = new Set(['scripting', 'storage', 'activeTab']); + * const mv2Permissions = filterPermissionsForMV2(mv3Permissions); + * // Result: Set(['tabs', 'storage', 'activeTab']) + * ``` + */ + if (filteredPermissions.has("scripting" as T)) { + filteredPermissions.delete("scripting" as T); + filteredPermissions.add("tabs" as T); + } + + filteredPermissions.delete("offscreen" as T); + + return filterPermissions(filteredPermissions); +}; + +export const filterPermissionsForMV3 = (permissions: Set): Set => { + const filteredPermissions = new Set(permissions); + + /** + * @permissions webRequestBlocking, webAuthenticationProxy, webRequestAuthProvider + * @description These permissions were removed in Manifest V3 due to concerns related to security, performance, and transparency. + * - `webRequestBlocking`: Allowed real-time interception and modification of network requests, which introduced latency and potential security risks. Replaced by the safer, declarative `declarativeNetRequest` API. + * - `webAuthenticationProxy`: Provided proxy authentication control, but was rarely used and posed privacy risks. + * - `webRequestAuthProvider`: Enabled handling of HTTP authentication (onAuthRequired), but was considered unsafe in the new MV3 architecture due to its ability to interfere with authentication flows. + * @manifestV3 Removed. These APIs are no longer available in extensions using Manifest V3. + */ + filteredPermissions.delete("webAuthenticationProxy" as T); + filteredPermissions.delete("webRequestAuthProvider" as T); + filteredPermissions.delete("webRequestBlocking" as T); + + return filterPermissions(filteredPermissions); +}; + +export const filterOptionalPermissions = ( + optional: Set, + required: Set +): O[] => { + const allPermissions = filterPermissions(new Set([...optional, ...required])); + + return _.difference(Array.from(allPermissions), Array.from(required)) as O[]; +}; diff --git a/src/cli/builders/manifest/utils/web-accessible-resources.test.ts b/src/cli/builders/manifest/utils/web-accessible-resources.test.ts new file mode 100644 index 00000000..b628610f --- /dev/null +++ b/src/cli/builders/manifest/utils/web-accessible-resources.test.ts @@ -0,0 +1,543 @@ +import {mergeWebAccessibleResources} from "./web-accessible-resources"; +import {ManifestAccessibleResource} from "@typing/manifest"; + +const sortResources = (resources: ManifestAccessibleResource[]): ManifestAccessibleResource[] => { + return resources.map(r => { + const result: ManifestAccessibleResource = {resources: r.resources}; + + if (r.matches) result.matches = r.matches.sort(); + if (r.extensionIds) result.extensionIds = r.extensionIds.sort(); + if (r.useDynamicUrl !== undefined) result.useDynamicUrl = r.useDynamicUrl; + + return result; + }); +}; + +describe("mergeWebAccessibleResources", () => { + test("normalizes matches to origins before simplifying domain wildcards", () => { + const result = mergeWebAccessibleResources([ + { + resources: ["resource.js"], + matches: [ + "https://example.com/path/file.json?key=value", + "https://*.example.com/path/*", + "https://sub.example.com/other/*", + ], + }, + ]); + + expect(result).toEqual([ + { + resources: ["resource.js"], + matches: ["https://*.example.com/*"], + }, + ]); + }); + + test("simplifies matches after merging separate rules", () => { + expect( + mergeWebAccessibleResources([ + {resources: ["a.js"], matches: ["https://example.com/path/*"]}, + {resources: ["a.js"], matches: ["https://sub.example.com/nested/*"]}, + {resources: ["a.js"], matches: ["https://*.example.com/*"]}, + ]) + ).toEqual([{resources: ["a.js"], matches: ["https://*.example.com/*"]}]); + }); + + test("keeps both the root domain and a subdomain when neither has a host wildcard", () => { + expect( + mergeWebAccessibleResources([ + {resources: ["a.js"], matches: ["https://example.com/path/*"]}, + {resources: ["a.js"], matches: ["https://sub.example.com/nested/*"]}, + ]) + ).toEqual([{resources: ["a.js"], matches: ["https://example.com/*", "https://sub.example.com/*"]}]); + }); + + test.each([ + ["https://*/*", "http://example.org/*"], + ["*://*/*", "file:///*"], + ["*://*/*", "ftp://example.org/*"], + ["*://*/*", "ws://example.org/*"], + ["*://*/*", "wss://example.org/*"], + ["https://*.example.com/*", "https://example.org/*"], + ["https://*.example.com/*", "https://otherexample.com/*"], + ])("keeps resources when %s does not cover every match, including %s", (covering, uncovered) => { + const input = [ + {resources: ["a.js"], matches: [covering]}, + {resources: ["a.js", "b.js"], matches: ["https://sub.example.com/*", uncovered]}, + ]; + + expect(sortResources(mergeWebAccessibleResources(input))).toEqual(sortResources(input)); + }); + + test("removes a duplicate covered by a domain wildcard and merges again after cleanup", () => { + expect( + mergeWebAccessibleResources([ + {resources: ["a.js"], matches: ["https://*.example.com/*"]}, + {resources: ["a.js", "b.js"], matches: ["https://example.com/*", "https://sub.example.com/*"]}, + {resources: ["b.js"], matches: ["https://example.org/*"]}, + ]) + ).toEqual([ + {resources: ["a.js"], matches: ["https://*.example.com/*"]}, + { + resources: ["b.js"], + matches: ["https://example.com/*", "https://example.org/*", "https://sub.example.com/*"], + }, + ]); + }); + + test("does not create access for a different resource and origin pair", () => { + const input = [ + {resources: ["a.js"], matches: ["https://example.com/*"]}, + {resources: ["b.js"], matches: ["https://example.org/*"]}, + ]; + + expect(mergeWebAccessibleResources(input)).toEqual(input); + }); + + test("keeps resource wildcards and paths independent from origin normalization", () => { + expect( + mergeWebAccessibleResources([ + {resources: ["/nested/*", "*.json"], matches: ["https://example.com/path/file.json?key=value"]}, + {resources: ["*.json"], matches: ["https://example.com/nested/*"]}, + ]) + ).toEqual([{resources: ["*.json", "/nested/*"], matches: ["https://example.com/*"]}]); + }); + + test("normalizes the all-extensions wildcard before and after merging", () => { + expect( + mergeWebAccessibleResources([ + {resources: ["a.js"], extensionIds: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}, + {resources: ["a.js"], extensionIds: ["*", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]}, + ]) + ).toEqual([{resources: ["a.js"], extensionIds: ["*"]}]); + }); + + test("requires coverage of both web origins and extension IDs before removing resources", () => { + const input = [ + {resources: ["a.js"], matches: [""]}, + {resources: ["b.js"], extensionIds: ["*"]}, + { + resources: ["a.js", "b.js", "c.js"], + matches: ["https://example.com/*"], + extensionIds: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + }, + ]; + + expect(mergeWebAccessibleResources(input)).toEqual(input); + }); + + test("removes duplicates when every extension ID is covered", () => { + expect( + mergeWebAccessibleResources([ + {resources: ["a.js"], extensionIds: ["*"]}, + {resources: ["a.js", "b.js"], extensionIds: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}, + ]) + ).toEqual([ + {resources: ["a.js"], extensionIds: ["*"]}, + {resources: ["b.js"], extensionIds: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}, + ]); + }); + + test("merges explicit and default static URLs while keeping dynamic rules separate", () => { + expect( + mergeWebAccessibleResources([ + {resources: ["a.js"], matches: ["https://example.com/*"]}, + {resources: ["b.js"], matches: ["https://example.com/*"], useDynamicUrl: false}, + {resources: ["a.js"], matches: ["https://example.com/*"], useDynamicUrl: true}, + ]) + ).toEqual([ + {resources: ["a.js", "b.js"], matches: ["https://example.com/*"], useDynamicUrl: false}, + {resources: ["a.js"], matches: ["https://example.com/*"], useDynamicUrl: true}, + ]); + }); + + test("is idempotent and does not mutate input rules", () => { + const input = [ + {resources: ["a.js"], matches: ["https://*.example.com/path/*"]}, + {resources: ["b.js", "a.js", "b.js"], matches: ["https://sub.example.com/nested/*"]}, + {resources: ["b.js"], matches: ["https://example.org/*"]}, + ]; + const original = JSON.parse(JSON.stringify(input)); + const output = mergeWebAccessibleResources(input); + + expect(input).toEqual(original); + expect(mergeWebAccessibleResources(output)).toEqual(output); + expect(mergeWebAccessibleResources([])).toEqual([]); + }); + + test("merge resources with same matches without duplicates", () => { + const input = [ + { + resources: ["a.js", "b.js"], + matches: ["https://example.com/*"], + }, + { + resources: ["b.js", "c.js"], + matches: ["https://example.com/*"], + }, + { + resources: ["c.js", "d.js"], + matches: ["https://example.com/*"], + }, + ]; + + expect(sortResources(mergeWebAccessibleResources(input))).toEqual( + sortResources([ + { + resources: ["a.js", "b.js", "c.js", "d.js"], + matches: ["https://example.com/*"], + }, + ]) + ); + }); + + test("remove unnecessary matches in one element", () => { + const input_1 = [ + { + resources: ["a.js"], + matches: ["", "https://example.com/*"], + }, + ]; + + const input_2 = [ + { + resources: ["a.js"], + matches: ["*://*/*", "https://example.com/*"], + }, + ]; + + expect(sortResources(mergeWebAccessibleResources(input_1))).toEqual( + sortResources([ + { + resources: ["a.js"], + matches: [""], + }, + ]) + ); + + expect(sortResources(mergeWebAccessibleResources(input_2))).toEqual( + sortResources([ + { + resources: ["a.js"], + matches: ["*://*/*"], + }, + ]) + ); + }); + + test("remove resources from other elements if they exist in the element with or *://*/* pattern", () => { + const input = [ + { + resources: ["a.js"], + matches: [""], + }, + { + resources: ["b.js"], + matches: ["*://*/*"], + }, + { + resources: ["a.js", "b.js"], + matches: ["https://example.com/*"], + }, + { + resources: ["a.js", "b.js", "c.js"], + matches: ["https://example.org/*", "https://example.org/*"], + }, + ]; + + expect(sortResources(mergeWebAccessibleResources(input))).toEqual( + sortResources([ + { + resources: ["a.js"], + matches: [""], + }, + { + resources: ["b.js"], + matches: ["*://*/*"], + }, + { + resources: ["c.js"], + matches: ["https://example.org/*"], + }, + ]) + ); + }); + + test("remove resources from other elements if they exist in elements with some common protocol pattern", () => { + const input = [ + { + resources: ["a.js"], + matches: ["https://*/*", "http://*/*"], + }, + { + resources: ["a.js", "b.js"], + matches: ["https://example.com/*"], + }, + { + resources: ["a.js", "b.js"], + matches: ["http://example.org/*"], + }, + ]; + + expect(sortResources(mergeWebAccessibleResources(input))).toEqual( + sortResources([ + { + resources: ["a.js"], + matches: ["https://*/*", "http://*/*"], + }, + { + resources: ["b.js"], + matches: ["https://example.com/*", "http://example.org/*"], + }, + ]) + ); + }); + + test("сomplete example", () => { + const input = [ + // Case 1: Elements with the same matches - should be merged + { + resources: ["common.js", "shared.css"], + matches: ["https://example.com/*"], + }, + { + resources: ["shared.css", "extra.js"], + matches: ["https://example.com/*"], + }, + + // Case 2: Element with - should clean its matches and remove duplicate resources from other elements + { + resources: ["global.js"], + matches: ["", "https://redundant.example.com/*", "*://*/*"], + }, + + // Case 3: Element with *://*/* - should clean matches and remove duplicate resources + { + resources: ["universal.js"], + matches: ["*://*/*", "https://also-redundant.example.com/*"], + }, + + // Case 4: Elements with protocol-specific wildcards + { + resources: ["https-only.js"], + matches: ["https://*/*"], + }, + { + resources: ["http-only.js"], + matches: ["http://*/*"], + }, + + // Case 5: Elements that will be cleaned of resources due to and *://*/* + { + resources: ["global.js", "local1.js"], + matches: ["https://site1.example.com/*"], + }, + { + resources: ["universal.js", "local2.js"], + matches: ["http://site2.example.com/*"], + }, + + // Case 6: Elements that will be partially cleaned due to protocol-specific wildcards + { + resources: ["https-only.js", "specific1.js"], + matches: ["https://specific.example.com/*"], + }, + { + resources: ["http-only.js", "specific2.js"], + matches: ["http://another.example.com/*"], + }, + + // Case 7: Extension-only and file access + { + resources: ["extension.js"], + extensionIds: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + }, + { + resources: ["file-access.js"], + matches: ["file:///*"], + }, + + // Case 8: Elements that remain unchanged + { + resources: ["unique.js"], + matches: ["ftp://special.example.com/*"], + }, + ]; + + expect(sortResources(mergeWebAccessibleResources(input))).toEqual( + sortResources([ + // Merged elements with the same matches + { + resources: ["common.js", "extra.js", "shared.css"], + matches: ["https://example.com/*"], + }, + + // Element with (cleaned matches, unique resources) + { + resources: ["global.js"], + matches: [""], + }, + + // Element with *://*/* (cleaned matches) + { + resources: ["universal.js"], + matches: ["*://*/*"], + }, + + // Protocol-specific wildcards + { + resources: ["https-only.js"], + matches: ["https://*/*"], + }, + { + resources: ["http-only.js"], + matches: ["http://*/*"], + }, + + // Elements with local resources (global.js removed due to ) + { + resources: ["local1.js"], + matches: ["https://site1.example.com/*"], + }, + { + resources: ["local2.js"], + matches: ["http://site2.example.com/*"], + }, + + // Specific elements (partially cleaned due to protocol wildcards) + { + resources: ["specific1.js"], + matches: ["https://specific.example.com/*"], + }, + { + resources: ["specific2.js"], + matches: ["http://another.example.com/*"], + }, + + // Extension-only and file access (unchanged) + { + resources: ["extension.js"], + extensionIds: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + }, + { + resources: ["file-access.js"], + matches: ["file:///*"], + }, + + // Unique element + { + resources: ["unique.js"], + matches: ["ftp://special.example.com/*"], + }, + ]) + ); + }); + + test("performs multi-iteration merging across keys (regression for changed accumulation)", () => { + const input: ManifestAccessibleResource[] = [ + {resources: ["a.js"], matches: ["https://m1.example.com/*"]}, + {resources: ["b.js"], matches: ["https://m1.example.com/*"]}, + {resources: ["a.js", "b.js"], matches: ["https://m2.example.com/*"]}, + ]; + + const output = sortResources(mergeWebAccessibleResources(input)); + + expect(output).toEqual( + sortResources([ + {resources: ["a.js", "b.js"], matches: ["https://m1.example.com/*", "https://m2.example.com/*"]}, + ]) + ); + }); + + test("merges by extensionIds when other fields are equal", () => { + const input: ManifestAccessibleResource[] = [ + { + resources: ["r.js"], + matches: ["https://site.example.com/*"], + extensionIds: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + }, + { + resources: ["r.js"], + matches: ["https://site.example.com/*"], + extensionIds: ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + }, + ]; + + const output = sortResources(mergeWebAccessibleResources(input)); + + expect(output).toEqual( + sortResources([ + { + resources: ["r.js"], + matches: ["https://site.example.com/*"], + extensionIds: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + }, + ]) + ); + }); + + test("cleanup with respects extensionIds and useDynamicUrl", () => { + const input: ManifestAccessibleResource[] = [ + // Global entry for id1, useDynamicUrl true + { + resources: ["g.js"], + matches: [""], + extensionIds: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + useDynamicUrl: true, + }, + // Same id and useDynamic -> removal should happen + { + resources: ["g.js", "keep1.js"], + matches: ["https://site.example.com/*"], + extensionIds: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + useDynamicUrl: true, + }, + // Different extensionIds -> should NOT be removed + { + resources: ["g.js", "keep2.js"], + matches: ["https://site.example.com/*"], + extensionIds: ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + useDynamicUrl: true, + }, + // Different useDynamicUrl -> should NOT be removed + { + resources: ["g.js", "keep3.js"], + matches: ["https://site.example.com/*"], + extensionIds: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + useDynamicUrl: false, + }, + ]; + + const output = sortResources(mergeWebAccessibleResources(input)); + + expect(output).toEqual( + sortResources([ + { + resources: ["g.js"], + matches: [""], + extensionIds: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + useDynamicUrl: true, + }, + { + resources: ["keep1.js"], + matches: ["https://site.example.com/*"], + extensionIds: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + useDynamicUrl: true, + }, + { + resources: ["g.js", "keep2.js"], + matches: ["https://site.example.com/*"], + extensionIds: ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + useDynamicUrl: true, + }, + { + resources: ["g.js", "keep3.js"], + matches: ["https://site.example.com/*"], + extensionIds: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + useDynamicUrl: false, + }, + ]) + ); + }); +}); diff --git a/src/cli/builders/manifest/utils/web-accessible-resources.ts b/src/cli/builders/manifest/utils/web-accessible-resources.ts new file mode 100644 index 00000000..99e42e6a --- /dev/null +++ b/src/cli/builders/manifest/utils/web-accessible-resources.ts @@ -0,0 +1,113 @@ +import {ManifestAccessibleResource} from "@typing/manifest"; + +import {filterHostPatterns} from "./host-patterns"; + +export const mergeWebAccessibleResources = (resources: ManifestAccessibleResource[]): ManifestAccessibleResource[] => { + if (resources.length === 0) return []; + + const normalize = (arr?: string[]) => Array.from(new Set(arr || [])).sort(); + + const normalizeRule = (r: ManifestAccessibleResource): ManifestAccessibleResource => ({ + resources: normalize(r.resources), + // WAR matches use origins only; Chrome requires the path to be exactly /*. + matches: normalize( + Array.from( + filterHostPatterns(new Set(r.matches?.map(match => match.replace(/^([^:]+:\/\/[^/]*)(\/.*)$/, "$1/*")))) + ) + ), + // Chrome requires the all-extensions wildcard to be the only ID in the array. + extensionIds: r.extensionIds?.includes("*") ? ["*"] : normalize(r.extensionIds), + useDynamicUrl: r.useDynamicUrl, + }); + + const makeKey = (r: ManifestAccessibleResource, exclude: keyof ManifestAccessibleResource): string => { + const obj: Partial = {...r, useDynamicUrl: r.useDynamicUrl ?? false}; + delete obj[exclude]; + + return JSON.stringify(obj); + }; + + const mergeTwo = (a: ManifestAccessibleResource, b: ManifestAccessibleResource): ManifestAccessibleResource => + normalizeRule({ + resources: [...a.resources, ...b.resources], + matches: [...(a.matches || []), ...(b.matches || [])], + extensionIds: [...(a.extensionIds || []), ...(b.extensionIds || [])], + useDynamicUrl: a.useDynamicUrl ?? b.useDynamicUrl, + }); + + const coversAudience = (covering: ManifestAccessibleResource, candidate: ManifestAccessibleResource): boolean => { + if ((covering.useDynamicUrl ?? false) !== (candidate.useDynamicUrl ?? false)) return false; + + const matches = covering.matches || []; + const extensionIds = covering.extensionIds || []; + + return ( + (candidate.matches || []).every( + match => matches.includes(match) || !filterHostPatterns(new Set([...matches, match])).has(match) + ) && (candidate.extensionIds || []).every(id => extensionIds.includes("*") || extensionIds.includes(id)) + ); + }; + + const merge = (resources: ManifestAccessibleResource[], mergeBy: keyof ManifestAccessibleResource) => { + const map = new Map(); + let changed = false; + for (const r of resources) { + const key = makeKey(r, mergeBy); + if (map.has(key)) { + const merged = mergeTwo(map.get(key)!, r); + map.set(key, merged); + changed = true; + } else { + map.set(key, r); + } + } + + return { + changed, + result: Array.from(map.values()), + }; + }; + + let changed = true; + + let result = resources.map(normalizeRule).filter(r => r.resources.length > 0); + + while (changed) { + changed = false; + + const mergeByResources = merge(result, "resources"); + const afterResources = mergeByResources.result; + + const mergeByMatches = merge(afterResources, "matches"); + const afterMatches = mergeByMatches.result; + + const mergeByExtensionIds = merge(afterMatches, "extensionIds"); + const afterExtensionIds = mergeByExtensionIds.result; + + changed = mergeByResources.changed || mergeByMatches.changed || mergeByExtensionIds.changed; + result = afterExtensionIds; + + // A duplicate is removable only if every origin and extension ID remains covered. + // Mutate the normalized copies in order so equivalent rules cannot remove each other. + for (const entry of result) { + for (const other of result) { + if (other === entry || !coversAudience(entry, other)) continue; + + const remaining = other.resources.filter(resource => !entry.resources.includes(resource)); + if (remaining.length < other.resources.length) { + other.resources = remaining; + changed = true; + } + } + } + + result = result.filter(r => r.resources.length > 0); + } + + return result.map(r => { + if (r.matches?.length === 0) delete r.matches; + if (r.extensionIds?.length === 0) delete r.extensionIds; + if (r.useDynamicUrl === undefined) delete r.useDynamicUrl; + return r; + }); +}; diff --git a/src/cli/bundler/plugins/index.ts b/src/cli/bundler/plugins/index.ts index 796fb7dd..2a039f46 100644 --- a/src/cli/bundler/plugins/index.ts +++ b/src/cli/bundler/plugins/index.ts @@ -23,6 +23,12 @@ export { export {default as ManifestPlugin} from "./manifest"; +export { + default as ResourceAccessPlugin, + type ResourceAccessPluginOptions, + type ResourceAccessPluginRequirement, +} from "./resource-access"; + export {default as ReplacePlugin} from "./replace"; export {default as WatchPlugin} from "./watch"; diff --git a/src/cli/bundler/plugins/manifest/ManifestPlugin.test.ts b/src/cli/bundler/plugins/manifest/ManifestPlugin.test.ts index ca661f3d..62fc377f 100644 --- a/src/cli/bundler/plugins/manifest/ManifestPlugin.test.ts +++ b/src/cli/bundler/plugins/manifest/ManifestPlugin.test.ts @@ -1,6 +1,17 @@ +/** @jest-environment node */ +import fs from "fs"; +import os from "os"; +import path from "path"; +import {rspack, type Compiler, type RspackPluginInstance, type Stats} from "@rspack/core"; + +import ManifestV3 from "@cli/builders/manifest/ManifestV3"; +import BuildAssetsMapPlugin from "@cli/bundler/plugins/build-assets-map"; +import {getCompilationBuildAssets} from "@cli/bundler/utils/output"; +import {Browser} from "@typing/browser"; import type {EntrypointAssetsMap} from "@typing/entrypoint"; -import {createManifestDependencies} from "./ManifestPlugin"; +import {getManifestHooks, type ManifestHooks} from "../utils/manifest-hooks"; +import ManifestPlugin, {createManifestDependencies} from "./ManifestPlugin"; describe("createManifestDependencies", () => { it("keeps only initial content files static and exposes every async file as a runtime resource", () => { @@ -66,4 +77,148 @@ describe("createManifestDependencies", () => { expect(Array.from(content!.js)).not.toContain("js/background.js"); expect(Array.from(content!.assets)).not.toContain("assets/background.svg"); }); + + it("leaves CSS routing to its consumer instead of adding isolation fields to manifest dependencies", () => { + const map: EntrypointAssetsMap = { + content: { + initial: {js: ["content.js"], css: ["page.css", "panel.css"]}, + async: {js: [], css: []}, + assets: [], + }, + }; + + expect(createManifestDependencies(map).get("content")).toEqual({ + js: new Set(["content.js"]), + css: new Set(["page.css", "panel.css"]), + assets: new Set(), + }); + }); +}); + +const run = (compiler: Compiler): Promise => + new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error || !stats || stats.hasErrors()) { + reject(error ?? new Error(stats?.toString({all: false, errors: true}))); + } else { + resolve(stats); + } + }); + }); + +describe("ManifestPlugin compilation hooks", () => { + let directory: string; + let compiler: Compiler; + + const build = (plugin: RspackPluginInstance, reversed = false): Compiler => { + const manifest = new ManifestPlugin(() => + new ManifestV3(Browser.Chrome) + .setContentScripts(new Set([{entry: "content", matches: ["https://example.com/*"]}])) + .addAccessibleResource({resources: ["pages/*"], matches: ["http://example.com/*"]}) + .raw({web_accessible_resources: [{resources: ["pages/*"], matches: ["https://example.com/*"]}]}) + ); + return rspack({ + context: path.join(__dirname, "tests/fixtures"), + entry: {background: "./entry.js", content: "./entry.js"}, + mode: "production", + devtool: false, + output: {path: directory, filename: "[name].[contenthash:8].js"}, + plugins: [ + new BuildAssetsMapPlugin({ + fullMapEntrypoint: "background", + cssFilename: "[name].css", + cssChunkFilename: "[name].css", + }), + ...(reversed ? [manifest, plugin] : [plugin, manifest]), + ], + }); + }; + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-manifest-hooks-")); + }); + + afterEach(async () => { + await new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); + fs.rmSync(directory, {recursive: true, force: true}); + }); + + it.each([false, true])( + "awaits preparation and validates merged output before emit (reverse plugin order: %s)", + async reversed => { + let prepared = false; + let validated = false; + compiler = build( + { + apply(compiler) { + compiler.hooks.compilation.tap("ManifestTest", compilation => { + const hooks = getManifestHooks(compilation); + expect(getManifestHooks(compilation)).toBe(hooks); + hooks.prepareDependencies.tapPromise("ManifestTest", async (dependencies, assets) => { + await Promise.resolve(); + const file = assets.content.initial.js[0]; + expect(file).toMatch(/^content\.[a-f0-9]{8}\.js$/); + expect(compilation.getAsset(file)).toBeDefined(); + dependencies.get("content")!.assets.add(file); + prepared = true; + }); + hooks.validate.tapPromise("ManifestTest", async manifest => { + await Promise.resolve(); + expect(prepared).toBe(true); + expect(compilation.getAsset("manifest.json")).toBeUndefined(); + const rules = + manifest.web_accessible_resources as chrome.runtime.ManifestV3["web_accessible_resources"]; + expect( + rules + ?.filter(rule => rule.resources.includes("pages/*")) + .flatMap(rule => rule.matches ?? []) + ).toEqual(expect.arrayContaining(["http://example.com/*", "https://example.com/*"])); + expect(rules?.flatMap(rule => rule.resources)).toEqual( + expect.arrayContaining(manifest.content_scripts![0].js!) + ); + validated = true; + }); + }); + }, + }, + reversed + ); + + const stats = await run(compiler); + expect(validated).toBe(true); + expect(fs.existsSync(path.join(directory, "manifest.json"))).toBe(true); + expect(getCompilationBuildAssets(stats.compilation)!.content.assets).toEqual([]); + } + ); + + it("does not emit an invalid manifest and starts the next compilation with fresh hooks and dependencies", async () => { + let attempt = 0; + const hooksSeen = new Set(); + compiler = build({ + apply(compiler) { + compiler.hooks.compilation.tap("ManifestTest", compilation => { + const hooks = getManifestHooks(compilation); + hooksSeen.add(hooks); + hooks.prepareDependencies.tapPromise("ManifestTest", async dependencies => { + expect(dependencies.get("content")!.assets.size).toBe(0); + if (attempt === 0) { + dependencies.get("content")!.assets.add("first-attempt.txt"); + } + }); + hooks.validate.tapPromise("ManifestTest", async () => { + if (attempt === 0) throw new Error("Manifest validation rejected this compilation"); + }); + }); + }, + }); + + await expect(run(compiler)).rejects.toThrow("Manifest validation rejected this compilation"); + expect(fs.existsSync(path.join(directory, "manifest.json"))).toBe(false); + attempt++; + const stats = await run(compiler); + expect(hooksSeen.size).toBe(2); + expect(stats.compilation.getAsset("manifest.json")!.source.source().toString()).not.toContain( + "first-attempt.txt" + ); + }); }); diff --git a/src/cli/bundler/plugins/manifest/ManifestPlugin.ts b/src/cli/bundler/plugins/manifest/ManifestPlugin.ts index b4a1a0f4..3417d5b7 100644 --- a/src/cli/bundler/plugins/manifest/ManifestPlugin.ts +++ b/src/cli/bundler/plugins/manifest/ManifestPlugin.ts @@ -1,6 +1,7 @@ import rspack, {Compilation, Compiler} from "@rspack/core"; import {getCompilationBuildAssets} from "@cli/bundler/utils/output"; +import {getManifestHooks} from "../utils/manifest-hooks"; import {ManifestBuilder, ManifestDependencies, ManifestDependency} from "@typing/manifest"; import {EntrypointAssetsMap} from "@typing/entrypoint"; @@ -22,26 +23,39 @@ export const createManifestDependencies = (buildAssets: EntrypointAssetsMap): Ma }; class ManifestPlugin { - constructor(private readonly manifest: ManifestBuilder) {} + constructor(private readonly manifest: ManifestBuilder | (() => ManifestBuilder)) {} apply(compiler: Compiler): void { compiler.hooks.compilation.tap("ManifestPlugin", compilation => { - compilation.hooks.processAssets.tap( + const hooks = getManifestHooks(compilation); + + compilation.hooks.processAssets.tapPromise( { name: "ManifestPlugin", stage: Compilation.PROCESS_ASSETS_STAGE_REPORT, }, - () => { - const buildAssets = getCompilationBuildAssets(compilation); + async () => { + try { + const buildAssets = getCompilationBuildAssets(compilation); - if (!buildAssets) { - throw new Error("Build assets are unavailable before manifest generation"); - } + if (!buildAssets) { + throw new Error("Build assets are unavailable before manifest generation"); + } + + const dependencies = createManifestDependencies(buildAssets); + await hooks.prepareDependencies.promise(dependencies, buildAssets); - const manifest = this.manifest.setDependencies(createManifestDependencies(buildAssets)).get(); - const json = JSON.stringify(manifest, null, 2); + const builder = typeof this.manifest === "function" ? this.manifest() : this.manifest; + const manifest = builder.setDependencies(dependencies).get(); + await hooks.validate.promise(manifest); - compilation.emitAsset("manifest.json", new rspack.sources.RawSource(json)); + const json = JSON.stringify(manifest, null, 2); + + compilation.emitAsset("manifest.json", new rspack.sources.RawSource(json)); + } catch (error) { + // A manifest failure rejects this build without terminating the watch compiler. + compilation.errors.push(error instanceof Error ? error : new Error(String(error))); + } } ); }); diff --git a/src/cli/bundler/plugins/manifest/tests/fixtures/entry.js b/src/cli/bundler/plugins/manifest/tests/fixtures/entry.js new file mode 100644 index 00000000..b2ebefbf --- /dev/null +++ b/src/cli/bundler/plugins/manifest/tests/fixtures/entry.js @@ -0,0 +1 @@ +console.log("Manifest hooks fixture"); diff --git a/src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.test.ts b/src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.test.ts new file mode 100644 index 00000000..eab0da33 --- /dev/null +++ b/src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.test.ts @@ -0,0 +1,108 @@ +/** @jest-environment node */ +import fs from "fs"; +import os from "os"; +import path from "path"; +import {rspack, type Compiler, type Stats} from "@rspack/core"; + +import ManifestV2 from "@cli/builders/manifest/ManifestV2"; +import ManifestV3 from "@cli/builders/manifest/ManifestV3"; +import {Browser} from "@typing/browser"; +import type {ManifestVersion} from "@typing/manifest"; + +import BuildAssetsMapPlugin from "../build-assets-map"; +import ManifestPlugin from "../manifest"; +import ResourceAccessPlugin, {type ResourceAccessPluginOptions} from "./ResourceAccessPlugin"; + +const run = (compiler: Compiler): Promise => + new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error || !stats || stats.hasErrors()) { + reject(error ?? new Error(stats?.toString({all: false, errors: true}))); + } else { + resolve(stats); + } + }); + }); + +describe("ResourceAccessPlugin", () => { + let directory: string; + let compiler: Compiler; + + const build = (version: ManifestVersion, options: ResourceAccessPluginOptions): Compiler => { + const builder = version === 2 ? new ManifestV2(Browser.Chrome) : new ManifestV3(Browser.Chrome); + builder + .setContentScripts(new Set([{entry: "client", matches: ["*://*.example.com/*"]}])) + .addAccessibleResource({resources: ["client.js"], matches: ["http://*.example.com/*"]}) + .raw({web_accessible_resources: [{resources: ["client.js"], matches: ["https://*.example.com/*"]}]}); + + return rspack({ + context: path.join(__dirname, "tests/fixtures"), + entry: {background: "./client.js", client: "./client.js"}, + mode: "production", + devtool: false, + output: {path: directory, filename: "[name].js"}, + plugins: [ + new BuildAssetsMapPlugin({ + fullMapEntrypoint: "background", + cssFilename: "[name].css", + cssChunkFilename: "[name].css", + }), + new ManifestPlugin(builder), + new ResourceAccessPlugin(options), + ], + }); + }; + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-resource-access-")); + }); + + afterEach(async () => { + await new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); + fs.rmSync(directory, {recursive: true, force: true}); + }); + + test.each([2, 3] as const)("validates supplied file requirements against final merged MV%s WAR", async version => { + compiler = build(version, { + requirements: [{resource: "client.js", matches: ["*://*.example.com/*"], issuer: "Client loader"}], + }); + await run(compiler); + const manifest = JSON.parse(fs.readFileSync(path.join(directory, "manifest.json"), "utf8")); + expect(manifest.web_accessible_resources).toEqual( + version === 2 + ? ["client.js"] + : [ + { + resources: ["client.js"], + matches: ["http://*.example.com/*", "https://*.example.com/*"], + }, + ] + ); + }); + + test("refreshes async requirements after a rejected build without expanding access", async () => { + let resource = "missing.js"; + const resolved: string[] = []; + compiler = build(3, { + requirements: async () => { + await Promise.resolve(); + resolved.push(resource); + return [ + { + resource, + matches: ["https://*.example.com/*"], + issuer: "Client loader", + hint: "check the client registration", + }, + ]; + }, + }); + await expect(run(compiler)).rejects.toThrow(/Client loader.*missing.js.*check the client registration/); + expect(fs.existsSync(path.join(directory, "manifest.json"))).toBe(false); + + resource = "client.js"; + await run(compiler); + expect(resolved).toEqual(["missing.js", "client.js"]); + expect(fs.readFileSync(path.join(directory, "manifest.json"), "utf8")).not.toContain("missing.js"); + }); +}); diff --git a/src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.ts b/src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.ts new file mode 100644 index 00000000..ca7c5733 --- /dev/null +++ b/src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.ts @@ -0,0 +1,38 @@ +import type {Compiler} from "@rspack/core"; + +import {getManifestHooks} from "../utils/manifest-hooks"; +import {validateResourceAccess} from "./utils"; + +export interface ResourceAccessPluginRequirement { + resource: string; + matches: readonly string[]; + /** Identifies the caller in build diagnostics; it has no effect on access rules. */ + issuer: string; + /** Optional caller-specific guidance when access is insufficient. */ + hint?: string; +} + +export interface ResourceAccessPluginOptions { + /** Resolvers run against the final manifest on every compilation, including watch rebuilds. */ + requirements: + | readonly ResourceAccessPluginRequirement[] + | (() => readonly ResourceAccessPluginRequirement[] | Promise); +} + +/** Validates requested access without granting permissions or modifying the manifest. */ +export default class ResourceAccessPlugin { + public constructor(private readonly options: ResourceAccessPluginOptions) {} + + public apply(compiler: Compiler): void { + compiler.hooks.compilation.tap("ResourceAccessPlugin", compilation => { + getManifestHooks(compilation).validate.tapPromise("ResourceAccessPlugin", async manifest => { + const requirements = + typeof this.options.requirements === "function" + ? await this.options.requirements() + : this.options.requirements; + + validateResourceAccess(requirements, manifest); + }); + }); + } +} diff --git a/src/cli/bundler/plugins/resource-access/index.ts b/src/cli/bundler/plugins/resource-access/index.ts new file mode 100644 index 00000000..cc95b5f9 --- /dev/null +++ b/src/cli/bundler/plugins/resource-access/index.ts @@ -0,0 +1 @@ +export {default, type ResourceAccessPluginOptions, type ResourceAccessPluginRequirement} from "./ResourceAccessPlugin"; diff --git a/src/cli/bundler/plugins/resource-access/tests/fixtures/client.js b/src/cli/bundler/plugins/resource-access/tests/fixtures/client.js new file mode 100644 index 00000000..d6c51f1e --- /dev/null +++ b/src/cli/bundler/plugins/resource-access/tests/fixtures/client.js @@ -0,0 +1 @@ +console.log("Resource access fixture"); diff --git a/src/cli/bundler/plugins/resource-access/utils.test.ts b/src/cli/bundler/plugins/resource-access/utils.test.ts new file mode 100644 index 00000000..f6f5a352 --- /dev/null +++ b/src/cli/bundler/plugins/resource-access/utils.test.ts @@ -0,0 +1,58 @@ +import {validateResourceAccess} from "./utils"; + +const page = { + issuer: "Panel client", + resource: "pages/panel.html", + matches: ["*://*.example.com/*"], +}; + +test("resource access requires full origin coverage, including the union of WAR rules", () => { + expect(() => + validateResourceAccess([page], { + manifest_version: 3, + web_accessible_resources: [ + {resources: ["pages/*"], matches: ["http://*.example.com/*"]}, + {resources: ["pages/panel.html"], matches: ["https://*.example.com/*"]}, + ], + }) + ).not.toThrow(); + expect(() => + validateResourceAccess([page], { + manifest_version: 3, + web_accessible_resources: [{resources: ["pages/*"], matches: ["https://example.com/*"]}], + }) + ).toThrow(/Panel client.*pages\/panel.html.*web_accessible_resources/); + expect(() => + validateResourceAccess([page], {manifest_version: 2, web_accessible_resources: ["pages/*"]}) + ).not.toThrow(); + expect(() => validateResourceAccess([page], {manifest_version: 2, web_accessible_resources: []})).toThrow(/panel/); +}); + +test("resource access uses origins rather than paths and leaves final WAR untouched", () => { + const rule = Object.freeze({ + resources: Object.freeze(["/assets/*.bin"]), + matches: Object.freeze(["https://*.example.com/only-this-path"]), + }); + const manifest = Object.freeze({manifest_version: 3, web_accessible_resources: Object.freeze([rule])}); + expect(() => + validateResourceAccess( + [{issuer: "Data client", resource: "assets/data.bin", matches: ["https://api.example.com/different-path"]}], + manifest + ) + ).not.toThrow(); + expect(() => + validateResourceAccess( + [{issuer: "Data client", resource: "assets/data.bin", matches: ["https://otherexample.com/*"]}], + manifest + ) + ).toThrow(/Data client.*otherexample.com/); +}); + +test("a resource accessible only by extension id does not grant access to web origins", () => { + expect(() => + validateResourceAccess([page], { + manifest_version: 3, + web_accessible_resources: [{resources: [page.resource], extension_ids: ["*"]}], + }) + ).toThrow(/requested matches|example.com/); +}); diff --git a/src/cli/bundler/plugins/resource-access/utils.ts b/src/cli/bundler/plugins/resource-access/utils.ts new file mode 100644 index 00000000..5fa45a85 --- /dev/null +++ b/src/cli/bundler/plugins/resource-access/utils.ts @@ -0,0 +1,74 @@ +import type {ResourceAccessPluginRequirement} from "./ResourceAccessPlugin"; + +interface AccessManifest { + manifest_version: number; + web_accessible_resources?: unknown; +} + +const resourceMatches = (pattern: string, filename: string): boolean => + new RegExp( + "^" + + pattern + .replace(/^\//, "") + .split("*") + .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join(".*") + + "$" + ).test(filename); + +const origins = (pattern: string): Array<{scheme: string; host: string}> => { + if (pattern === "") + return ["http", "https", "file", "ftp", "ws", "wss"].map(scheme => ({scheme, host: "*"})); + const match = /^(\*|https?|file|ftp|wss?):\/\/([^/]*)(\/.*)$/.exec(pattern); + if (!match) throw new Error(`Cannot verify resource access for match pattern "${pattern}"`); + return (match[1] === "*" ? ["http", "https"] : [match[1]]).map(scheme => ({scheme, host: match[2].toLowerCase()})); +}; + +const coversHost = (allowed: string, requested: string): boolean => { + if (allowed === "*" || allowed === requested) return true; + if (!allowed.startsWith("*.")) return false; + const suffix = allowed.slice(2); + const host = requested.startsWith("*.") ? requested.slice(2) : requested; + return host === suffix || host.endsWith("." + suffix); +}; + +/** Proves origin-set containment; checking sample URLs cannot prove wildcard coverage. */ +export const validateResourceAccess = ( + requirements: Iterable, + manifest: AccessManifest +): void => { + const rules = Array.isArray(manifest.web_accessible_resources) ? manifest.web_accessible_resources : []; + for (const {resource, matches, issuer, hint} of requirements) { + const applicable = + manifest.manifest_version === 2 + ? rules.filter(rule => typeof rule === "string" && resourceMatches(rule, resource)) + : rules.filter( + rule => + Array.isArray(rule?.resources) && + rule.resources.some((pattern: string) => resourceMatches(pattern, resource)) + ); + const allowed = + manifest.manifest_version === 2 + ? [] + : applicable.flatMap(rule => (rule.matches ?? []).flatMap((pattern: string) => origins(pattern))); + const missing = + manifest.manifest_version === 2 + ? applicable.length + ? [] + : matches + : matches.filter( + pattern => + !origins(pattern).every(requested => + allowed.some( + origin => + origin.scheme === requested.scheme && coversHost(origin.host, requested.host) + ) + ) + ); + if (!applicable.length || missing.length) { + throw new Error( + `${issuer} cannot access resource "${resource}" for ${missing.join(", ") || "the requested matches"}; ${hint ?? "ensure web_accessible_resources covers the resource and requested matches"}` + ); + } + } +}; diff --git a/src/cli/bundler/plugins/utils/index.ts b/src/cli/bundler/plugins/utils/index.ts index cd9d7a3c..4a67b9b9 100644 --- a/src/cli/bundler/plugins/utils/index.ts +++ b/src/cli/bundler/plugins/utils/index.ts @@ -1 +1,2 @@ +export {getManifestHooks, type ManifestHooks} from "./manifest-hooks"; export {renderRuntimeTemplate} from "./runtime-template"; diff --git a/src/cli/bundler/plugins/utils/manifest-hooks.ts b/src/cli/bundler/plugins/utils/manifest-hooks.ts new file mode 100644 index 00000000..a61ef459 --- /dev/null +++ b/src/cli/bundler/plugins/utils/manifest-hooks.ts @@ -0,0 +1,30 @@ +import type {Compilation} from "@rspack/core"; +import {AsyncSeriesHook} from "@rspack/lite-tapable"; +import type {ReadonlyDeep} from "type-fest"; + +import type {EntrypointAssetsMap} from "@typing/entrypoint"; +import type {Manifest, ManifestDependencies} from "@typing/manifest"; + +export interface ManifestHooks { + /** Prepare manifest-only file lists after output names are final; do not alter the runtime assets map. */ + prepareDependencies: AsyncSeriesHook<[ManifestDependencies, ReadonlyDeep]>; + /** Validate the final merged manifest before serialization. This hook must not modify it. */ + validate: AsyncSeriesHook<[ReadonlyDeep]>; +} + +const compilationHooks = new WeakMap(); + +/** Share hooks within one compilation without retaining subscriptions across watch rebuilds. */ +export const getManifestHooks = (compilation: Compilation): ManifestHooks => { + let hooks = compilationHooks.get(compilation); + + if (!hooks) { + hooks = { + prepareDependencies: new AsyncSeriesHook(["dependencies", "buildAssets"]), + validate: new AsyncSeriesHook(["manifest"]), + }; + compilationHooks.set(compilation, hooks); + } + + return hooks; +}; diff --git a/src/cli/resolvers/bundler.ts b/src/cli/resolvers/bundler.ts index 2952125d..8e3b12ff 100644 --- a/src/cli/resolvers/bundler.ts +++ b/src/cli/resolvers/bundler.ts @@ -25,12 +25,14 @@ const getConfigFromPlugins = async (rspack: RspackConfig, config: ReadonlyConfig }; const getConfigForManifest = async (config: ReadonlyConfig): Promise => { - const manifest = manifestFactory(config); + let manifest = manifestFactory(config); - // prettier-ignore - const update = () => Array.fromAsync( - processPluginHandler(config.plugins, "manifest", {manifest, config}) - ); + const update = async () => { + // Each compilation describes current entries; removed WAR/CSP rules must not accumulate. + const next = manifestFactory(config); + await Array.fromAsync(processPluginHandler(config.plugins, "manifest", {manifest: next, config})); + manifest = next; + }; await update(); @@ -44,7 +46,7 @@ const getConfigForManifest = async (config: ReadonlyConfig): Promise manifest)); return {plugins}; }; From e3777f23a1b9b5f66c87266ebb4c7e9a870dee71 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:21:28 +0300 Subject: [PATCH 13/38] refactor(runtime): centralize page and relay metadata Embed serialized metadata with RuntimeDataPlugin and keep property contracts and augmentable Page/Relay registries in the shared type layer. Preserve public exports and generated declarations, refresh Relay data in watch, and substitute runtime template values once without reinterpreting data. --- jest.config.ts | 1 + src/cli/bundler/plugins/index.ts | 4 + .../bundler/plugins/runtime-data/README.md | 13 ++ .../runtime-data/RuntimeDataPlugin.test.ts | 217 ++++++++++++++++++ .../plugins/runtime-data/RuntimeDataPlugin.ts | 79 +++++++ src/cli/bundler/plugins/runtime-data/index.ts | 1 + .../bundler/plugins/runtime-data/templates.ts | 17 ++ .../templates/runtime.template.js | 6 + .../runtime-data/tests/fixtures/entry.js | 1 + src/cli/bundler/plugins/runtime-data/utils.ts | 39 ++++ src/cli/bundler/plugins/types.ts | 4 + .../plugins/utils/runtime-template.test.ts | 20 ++ .../bundler/plugins/utils/runtime-template.ts | 24 +- src/cli/plugins/content/index.ts | 22 +- .../page/declaration/PageDeclaration.ts | 9 +- src/cli/plugins/page/declaration/page.d.ts | 6 +- src/cli/plugins/page/index.ts | 29 +-- src/main/page.ts | 13 +- src/main/relay.test.ts | 51 ++++ src/main/relay.ts | 10 +- src/relay/index.ts | 8 +- src/types/page.ts | 10 + src/types/relay.ts | 13 +- tsup.config.ts | 1 + 24 files changed, 534 insertions(+), 64 deletions(-) create mode 100644 src/cli/bundler/plugins/runtime-data/README.md create mode 100644 src/cli/bundler/plugins/runtime-data/RuntimeDataPlugin.test.ts create mode 100644 src/cli/bundler/plugins/runtime-data/RuntimeDataPlugin.ts create mode 100644 src/cli/bundler/plugins/runtime-data/index.ts create mode 100644 src/cli/bundler/plugins/runtime-data/templates.ts create mode 100644 src/cli/bundler/plugins/runtime-data/templates/runtime.template.js create mode 100644 src/cli/bundler/plugins/runtime-data/tests/fixtures/entry.js create mode 100644 src/cli/bundler/plugins/runtime-data/utils.ts create mode 100644 src/cli/bundler/plugins/types.ts create mode 100644 src/cli/bundler/plugins/utils/runtime-template.test.ts create mode 100644 src/main/relay.test.ts diff --git a/jest.config.ts b/jest.config.ts index 1b2e3498..7dd46e95 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -12,6 +12,7 @@ const config: Config = { "^@locale/(.*)$": "/src/locale/$1", "^@offscreen/(.*)$": "/src/offscreen/$1", "^@message/(.*)$": "/src/message/$1", + "^@relay/(.*)$": "/src/relay/$1", "^@sandbox/(.*)$": "/src/sandbox/$1", "^@service/(.*)$": "/src/service/$1", "^@storage/(.*)$": "/src/storage/$1", diff --git a/src/cli/bundler/plugins/index.ts b/src/cli/bundler/plugins/index.ts index 2a039f46..6fcb42da 100644 --- a/src/cli/bundler/plugins/index.ts +++ b/src/cli/bundler/plugins/index.ts @@ -32,3 +32,7 @@ export { export {default as ReplacePlugin} from "./replace"; export {default as WatchPlugin} from "./watch"; + +export {default as RuntimeDataPlugin, type RuntimeDataPluginData, type RuntimeDataPluginOptions} from "./runtime-data"; + +export type {RuntimePropertyOptions} from "./types"; diff --git a/src/cli/bundler/plugins/runtime-data/README.md b/src/cli/bundler/plugins/runtime-data/README.md new file mode 100644 index 00000000..447001f0 --- /dev/null +++ b/src/cli/bundler/plugins/runtime-data/README.md @@ -0,0 +1,13 @@ +# Runtime data + +`RuntimeDataPlugin` embeds a serializable snapshot known before filename hashing. It does not emit +a carrier file, fetch metadata or connect another entrypoint's JavaScript. `update(data)` changes +the next compilation's snapshot, including watch rebuilds. + +The optional `test(entry)` selects entrypoints by name. Without it, every runtime receives the data, +preserving the page and Relay getters' current availability. With a shared runtime chunk, matching +any consuming entrypoint selects that runtime. The selector does not inspect API usage or tree-shake +individual map entries. Excluding a runtime that calls the corresponding getter is the caller's error. + +Insertion is guarded once per runtime and plugin instance within each compilation. Distinct properties +can coexist. Guard and snapshot state are refreshed per compilation, not retained across rebuilds. diff --git a/src/cli/bundler/plugins/runtime-data/RuntimeDataPlugin.test.ts b/src/cli/bundler/plugins/runtime-data/RuntimeDataPlugin.test.ts new file mode 100644 index 00000000..4cea1cbb --- /dev/null +++ b/src/cli/bundler/plugins/runtime-data/RuntimeDataPlugin.test.ts @@ -0,0 +1,217 @@ +/** @jest-environment node */ +import fs from "fs"; +import os from "os"; +import path from "path"; +import vm from "vm"; +import { + rspack, + type Compiler, + type Configuration, + type Filename, + type RspackPluginInstance, + type Stats, +} from "@rspack/core"; + +import RuntimeDataPlugin, {type RuntimeDataPluginData} from "./RuntimeDataPlugin"; + +const run = (compiler: Compiler): Promise => + new Promise((resolve, reject) => + compiler.run((error, stats) => { + if (error || !stats || stats.hasErrors()) + reject(error ?? new Error(stats?.toString({all: false, errors: true}))); + else resolve(stats); + }) + ); + +const filenames = (stats: Stats): string[] => + stats.compilation + .getAssets() + .map(asset => asset.name) + .sort(); + +const readData = (stats: Stats, file: string, property: string): unknown => { + const sandbox: {readRuntimeData?: (property: string) => unknown} = {}; + vm.runInNewContext(stats.compilation.getAsset(file)!.source.source().toString(), sandbox); + return sandbox.readRuntimeData!(property); +}; + +describe("RuntimeDataPlugin", () => { + let directory: string; + let compiler: Compiler; + + const build = ( + plugins: RspackPluginInstance[], + filename: Filename = "[name].js", + optimization?: Configuration["optimization"] + ): Compiler => + rspack({ + context: path.join(__dirname, "tests/fixtures"), + mode: "production", + devtool: false, + entry: {first: "./entry.js", second: "./entry.js"}, + output: {path: directory, filename, clean: true}, + optimization, + plugins, + }); + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-runtime-data-")); + }); + + afterEach(async () => { + await new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); + fs.rmSync(directory, {recursive: true, force: true}); + }); + + test.each([ + "[name].js", + "[name].[contenthash:8].js", + "[name].[chunkhash:8].js", + "[name].[fullhash:8].js", + data => `custom/${data.chunk?.name}.[contenthash:8].js`, + ])("updates every runtime without extra files using filename %s", async filename => { + const property = "fixtureData"; + const plugin = new RuntimeDataPlugin({property, data: {panel: "panel.html"}}); + compiler = build([plugin], filename); + + const first = await run(compiler); + const firstFiles = filenames(first); + expect(firstFiles).toHaveLength(2); + for (const file of firstFiles) expect(readData(first, file, property)).toEqual({panel: "panel.html"}); + + plugin.update({renamed: "panel.html"}); + const second = await run(compiler); + const secondFiles = filenames(second); + expect(secondFiles).toHaveLength(2); + for (const file of secondFiles) expect(readData(second, file, property)).toEqual({renamed: "panel.html"}); + if (filename !== "[name].js") expect(secondFiles).not.toEqual(firstFiles); + + const repeated = await run(compiler); + // Rspack's fullhash changes on repeated compilations even without a data plugin. + if (filename !== "[name].[fullhash:8].js") expect(filenames(repeated)).toEqual(secondFiles); + for (const file of filenames(repeated)) + expect(readData(repeated, file, property)).toEqual({renamed: "panel.html"}); + }); + + test("selects entries and drops stale data when selection changes between compilations", async () => { + let selected = "first"; + const plugin = new RuntimeDataPlugin({ + property: "fixture", + data: {value: 1}, + test: entry => entry === selected, + }); + compiler = build([plugin]); + for (const entry of ["first", "second", "first"]) { + selected = entry; + plugin.update({value: entry}); + const stats = await run(compiler); + expect(readData(stats, `${entry}.js`, "fixture")).toEqual({value: entry}); + const excluded = entry === "first" ? "second" : "first"; + expect(readData(stats, `${excluded}.js`, "fixture")).toBeUndefined(); + } + }); + + test("selects a shared runtime by its consuming entries and adds each property only once", async () => { + const selected: string[] = []; + compiler = build( + [ + new RuntimeDataPlugin({ + property: "fixture", + data: {shared: true}, + test: entry => { + selected.push(entry); + return entry === "second"; + }, + }), + ], + "[name].js", + {runtimeChunk: {name: "shared-runtime"}} + ); + const attached: string[] = []; + compiler.hooks.thisCompilation.tap("CountRuntimeDataFixture", compilation => { + compilation.hooks.runtimeModule.tap("CountRuntimeDataFixture", (module, chunk) => { + if (module.identifier().endsWith('runtime data "fixture"')) attached.push(chunk.name!); + }); + }); + const stats = await run(compiler); + const runtime = stats.compilation.entrypoints.get("first")!.getRuntimeChunk()!; + expect(runtime).toBe(stats.compilation.entrypoints.get("second")!.getRuntimeChunk()); + expect(selected).toEqual(["first", "second"]); + expect(attached).toEqual(["shared-runtime"]); + + // Repeating the hook must not add a second runtime module for the same property. + stats.compilation.hooks.additionalTreeRuntimeRequirements.call(runtime, new Set()); + expect(attached).toEqual(["shared-runtime"]); + expect(filenames(stats)).toEqual(["first.js", "second.js", "shared-runtime.js"]); + }); + + test("preserves literal strings and object keys without treating them as code or template markers", async () => { + const property = 'slot"]["__ADNBN_DATA__$&\\\n'; + const data = { + text: "$& $` $' __ADNBN_REQUIRE__ __ADNBN_DATA__ \" \\ \n \u2028", + nested: [null, true, false, 12.5, {value: "text"}], + object: JSON.parse('{"__proto__":{"value":"own property"}}'), + }; + compiler = build([ + new RuntimeDataPlugin({property, data}), + new RuntimeDataPlugin({property: "__proto__", data: "second slot"}), + ]); + const stats = await run(compiler); + for (const file of filenames(stats)) { + expect(readData(stats, file, property)).toEqual(data); + expect(readData(stats, file, "__proto__")).toBe("second slot"); + } + }); + + test("freezes data for one compilation even if update runs before its runtime modules are generated", async () => { + const data = {value: "first"}; + const plugin = new RuntimeDataPlugin({property: "snapshot", data}); + compiler = build([ + plugin, + { + apply(compiler) { + compiler.hooks.thisCompilation.tap("UpdateDataFixture", () => { + data.value = "mutated"; + plugin.update({value: "next"}); + }); + }, + }, + ]); + const first = await run(compiler); + for (const file of filenames(first)) expect(readData(first, file, "snapshot")).toEqual({value: "first"}); + const next = await run(compiler); + for (const file of filenames(next)) expect(readData(next, file, "snapshot")).toEqual({value: "next"}); + }); + + const cyclic: Record = {}; + cyclic.self = cyclic; + test.each([ + ["undefined", {nested: undefined}], + ["function", {nested: () => 1}], + ["symbol", Symbol("data")], + ["symbol key", {[Symbol("key")]: 1}], + ["bigint", BigInt(1)], + ["NaN", NaN], + ["infinity", Infinity], + ["class instance", new Date(0)], + ["cycle", cyclic], + ["sparse array", Array(2)], + ["named array property", Object.assign([1], {extra: true})], + [ + "accessor", + { + get value() { + throw new Error("must not evaluate the getter"); + }, + }, + ], + ["toJSON", {toJSON: () => "changed"}], + ])("rejects %s and recovers on the next compilation", async (_label, data) => { + const plugin = new RuntimeDataPlugin({property: "fixture", data: data as RuntimeDataPluginData}); + compiler = build([plugin]); + await expect(run(compiler)).rejects.toThrow('RuntimeDataPlugin property "fixture" has invalid data'); + plugin.update(null); + const recovered = await run(compiler); + for (const file of filenames(recovered)) expect(readData(recovered, file, "fixture")).toBeNull(); + }); +}); diff --git a/src/cli/bundler/plugins/runtime-data/RuntimeDataPlugin.ts b/src/cli/bundler/plugins/runtime-data/RuntimeDataPlugin.ts new file mode 100644 index 00000000..3d65f913 --- /dev/null +++ b/src/cli/bundler/plugins/runtime-data/RuntimeDataPlugin.ts @@ -0,0 +1,79 @@ +import {RuntimeGlobals, RuntimeModule, type Chunk, type Compiler} from "@rspack/core"; + +import type {RuntimePropertyOptions} from "../types"; +import {renderRuntimeData} from "./templates"; +import {serializeRuntimeData} from "./utils"; + +export type RuntimeDataPluginData = + | string + | number + | boolean + | null + | readonly RuntimeDataPluginData[] + | {readonly [key: string]: RuntimeDataPluginData}; + +export interface RuntimeDataPluginOptions extends RuntimePropertyOptions { + data: RuntimeDataPluginData; + /** Select entrypoints by name. A shared runtime receives data if any of its entries matches. Defaults to all runtimes. */ + test?: (entry: string) => boolean; +} + +class RuntimeDataRuntimeModule extends RuntimeModule { + public constructor( + private readonly property: string, + private readonly data: string + ) { + super(`runtime data ${JSON.stringify(property)}`, RuntimeModule.STAGE_BASIC); + } + + public generate(): string { + return renderRuntimeData({property: this.property, data: this.data, require: RuntimeGlobals.require}); + } +} + +/** Embeds data known before chunk hashing; it does not resolve output filenames or emit a separate file. */ +export default class RuntimeDataPlugin { + private readonly property: string; + private data: RuntimeDataPluginData; + private readonly test?: RuntimeDataPluginOptions["test"]; + + public constructor(options: RuntimeDataPluginOptions) { + this.property = options.property; + this.data = options.data; + this.test = options.test; + } + + public apply(compiler: Compiler): void { + compiler.hooks.thisCompilation.tap("RuntimeDataPlugin", compilation => { + // Selected runtimes share an immutable snapshot for this compilation only. + const injected = new Set(); + let data: string; + try { + data = serializeRuntimeData(this.data, this.property); + } catch (error) { + compilation.errors.push(error instanceof Error ? error : new Error(String(error))); + return; + } + + compilation.hooks.additionalTreeRuntimeRequirements.tap("RuntimeDataPlugin", (chunk, requirements) => { + if (injected.has(chunk)) return; + if ( + this.test && + !Array.from(compilation.entrypoints).some( + ([name, entrypoint]) => entrypoint.getRuntimeChunk() === chunk && this.test!(name) + ) + ) + return; + + injected.add(chunk); + requirements.add(RuntimeGlobals.require); + compilation.addRuntimeModule(chunk, new RuntimeDataRuntimeModule(this.property, data)); + }); + }); + } + + /** Updates the data used by the next compilation, including watch rebuilds. */ + public update(data: RuntimeDataPluginData): void { + this.data = data; + } +} diff --git a/src/cli/bundler/plugins/runtime-data/index.ts b/src/cli/bundler/plugins/runtime-data/index.ts new file mode 100644 index 00000000..034b7fc2 --- /dev/null +++ b/src/cli/bundler/plugins/runtime-data/index.ts @@ -0,0 +1 @@ +export {default, type RuntimeDataPluginData, type RuntimeDataPluginOptions} from "./RuntimeDataPlugin"; diff --git a/src/cli/bundler/plugins/runtime-data/templates.ts b/src/cli/bundler/plugins/runtime-data/templates.ts new file mode 100644 index 00000000..e8ff6b6e --- /dev/null +++ b/src/cli/bundler/plugins/runtime-data/templates.ts @@ -0,0 +1,17 @@ +import runtimeTemplate from "./templates/runtime.template.js?raw"; + +import type {RuntimePropertyOptions} from "../types"; +import {renderRuntimeTemplate} from "../utils/runtime-template"; + +interface RuntimeDataTemplateOptions extends RuntimePropertyOptions { + readonly require: string; + /** Validated and serialized JSON, parsed in the generated runtime to preserve keys such as __proto__. */ + readonly data: string; +} + +export const renderRuntimeData = (options: RuntimeDataTemplateOptions): string => + renderRuntimeTemplate(runtimeTemplate, { + __ADNBN_REQUIRE__: options.require, + __ADNBN_PROPERTY__: JSON.stringify(options.property), + __ADNBN_DATA__: JSON.stringify(options.data), + }); diff --git a/src/cli/bundler/plugins/runtime-data/templates/runtime.template.js b/src/cli/bundler/plugins/runtime-data/templates/runtime.template.js new file mode 100644 index 00000000..8908eb60 --- /dev/null +++ b/src/cli/bundler/plugins/runtime-data/templates/runtime.template.js @@ -0,0 +1,6 @@ +Object.defineProperty(__ADNBN_REQUIRE__, __ADNBN_PROPERTY__, { + value: JSON.parse(__ADNBN_DATA__), + configurable: true, + enumerable: true, + writable: true, +}); diff --git a/src/cli/bundler/plugins/runtime-data/tests/fixtures/entry.js b/src/cli/bundler/plugins/runtime-data/tests/fixtures/entry.js new file mode 100644 index 00000000..5289a755 --- /dev/null +++ b/src/cli/bundler/plugins/runtime-data/tests/fixtures/entry.js @@ -0,0 +1 @@ +globalThis.readRuntimeData = property => __webpack_require__[property]; diff --git a/src/cli/bundler/plugins/runtime-data/utils.ts b/src/cli/bundler/plugins/runtime-data/utils.ts new file mode 100644 index 00000000..d8bd6308 --- /dev/null +++ b/src/cli/bundler/plugins/runtime-data/utils.ts @@ -0,0 +1,39 @@ +/** Reject values JSON.stringify would silently omit, coerce, or evaluate through accessors/toJSON. */ +export const serializeRuntimeData = (data: unknown, property: string): string => { + const ancestors = new Set(); + const invalid = (location: string, reason: string): never => { + throw new Error( + `RuntimeDataPlugin property ${JSON.stringify(property)} has invalid data at ${location}: ${reason}` + ); + }; + + const validate = (value: unknown, location: string): void => { + if (value === null || typeof value === "string" || typeof value === "boolean") return; + if (typeof value === "number" && Number.isFinite(value)) return; + if (typeof value !== "object" || value === null) return invalid(location, "expected a JSON-compatible value"); + if (ancestors.has(value)) invalid(location, "circular reference"); + + const array = Array.isArray(value); + const prototype = Object.getPrototypeOf(value); + if (array ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) { + invalid(location, "expected a plain object or array"); + } + + ancestors.add(value); + for (const key of Reflect.ownKeys(value)) { + if (array && key === "length") continue; + if (typeof key !== "string") return invalid(location, "symbol keys are not supported"); + if (array && !/^(0|[1-9]\d*)$/.test(key)) invalid(location, "named array properties are not supported"); + const descriptor = Object.getOwnPropertyDescriptor(value, key)!; + if (!descriptor.enumerable || !("value" in descriptor)) { + invalid(location, "only enumerable data properties are supported"); + } + validate(descriptor.value, `${location}[${JSON.stringify(key)}]`); + } + if (array && Object.keys(value).length !== value.length) invalid(location, "sparse arrays are not supported"); + ancestors.delete(value); + }; + + validate(data, "data"); + return JSON.stringify(data); +}; diff --git a/src/cli/bundler/plugins/types.ts b/src/cli/bundler/plugins/types.ts new file mode 100644 index 00000000..3f06c78f --- /dev/null +++ b/src/cli/bundler/plugins/types.ts @@ -0,0 +1,4 @@ +/** Identifies the property a plugin installs on each selected bundler runtime. */ +export interface RuntimePropertyOptions { + readonly property: string; +} diff --git a/src/cli/bundler/plugins/utils/runtime-template.test.ts b/src/cli/bundler/plugins/utils/runtime-template.test.ts new file mode 100644 index 00000000..ab6b0fb0 --- /dev/null +++ b/src/cli/bundler/plugins/utils/runtime-template.test.ts @@ -0,0 +1,20 @@ +import {renderRuntimeTemplate} from "./runtime-template"; + +test("inserts values literally without recursively replacing template-like content", () => { + const literal = "$& $` $' __ADNBN_OTHER__"; + expect( + renderRuntimeTemplate(" __ADNBN_VALUE__ __ADNBN_OTHER__ __ADNBN_VALUE__ ", { + __ADNBN_VALUE__: literal, + __ADNBN_OTHER__: "other", + }) + ).toBe(`${literal} other ${literal}`); +}); + +test("reports unused values and missing substitutions in the original template", () => { + expect(() => renderRuntimeTemplate("__ADNBN_VALUE__", {__ADNBN_OTHER__: "unused"})).toThrow( + 'Runtime template placeholder "__ADNBN_OTHER__" is unavailable' + ); + expect(() => renderRuntimeTemplate("__ADNBN_VALUE__", {})).toThrow( + 'Runtime template placeholder "__ADNBN_VALUE__" was not replaced' + ); +}); diff --git a/src/cli/bundler/plugins/utils/runtime-template.ts b/src/cli/bundler/plugins/utils/runtime-template.ts index 967b34fa..a3db5fc9 100644 --- a/src/cli/bundler/plugins/utils/runtime-template.ts +++ b/src/cli/bundler/plugins/utils/runtime-template.ts @@ -1,21 +1,19 @@ const PlaceholderPattern = /__ADNBN_[A-Z0-9_]+__/g; export const renderRuntimeTemplate = (template: string, values: Readonly>): string => { - let runtime = template; - - for (const [placeholder, value] of Object.entries(values)) { - if (!runtime.includes(placeholder)) { + for (const placeholder of Object.keys(values)) { + if (!template.includes(placeholder)) { throw new Error(`Runtime template placeholder "${placeholder}" is unavailable`); } - - runtime = runtime.replaceAll(placeholder, value); - } - - const unresolved = runtime.match(PlaceholderPattern); - - if (unresolved) { - throw new Error(`Runtime template placeholder "${unresolved[0]}" was not replaced`); } - return runtime.trim(); + // Substitute once: inserted data is literal, not another template or a replacement pattern. + return template + .replace(PlaceholderPattern, placeholder => { + if (!Object.hasOwn(values, placeholder)) { + throw new Error(`Runtime template placeholder "${placeholder}" was not replaced`); + } + return values[placeholder]; + }) + .trim(); }; diff --git a/src/cli/plugins/content/index.ts b/src/cli/plugins/content/index.ts index 288cd215..5d078780 100644 --- a/src/cli/plugins/content/index.ts +++ b/src/cli/plugins/content/index.ts @@ -1,4 +1,4 @@ -import {Chunk, Configuration as RspackConfig, DefinePlugin, NormalModule} from "@rspack/core"; +import {Chunk, Configuration as RspackConfig, NormalModule} from "@rspack/core"; import {merge as mergeConfig} from "webpack-merge"; import ContentManager from "./ContentManager"; @@ -9,11 +9,11 @@ import {getContentChunkName, getContentLayer} from "./utils"; import {definePlugin} from "@main/plugin"; -import {ChunkLoaderPlugin, EntrypointPlugin, onlyViaTopLevelEntry, ShadowStylesPlugin} from "@cli/bundler"; +import {ChunkLoaderPlugin, EntrypointPlugin, onlyViaTopLevelEntry, ShadowStylesPlugin, RuntimeDataPlugin, type RuntimeDataPluginData} from "@cli/bundler"; import {Command} from "@typing/app"; import {ContentScriptWorld} from "@typing/content"; -import {RelayOptions} from "@typing/relay"; +import {RelayOptionsRuntimeProperty} from "@typing/relay"; export default definePlugin(() => { let content: Content; @@ -40,15 +40,18 @@ export default definePlugin(() => { relayDeclaration.dictionary(await relay.dictionary()).build(); let rspack: RspackConfig = {}; - let options: Record = {}; + const getRelayData = async (): Promise => + JSON.parse(JSON.stringify(await relay.getOptionsMap())); + const relayDataPlugin = new RuntimeDataPlugin({ + property: RelayOptionsRuntimeProperty, + data: await getRelayData(), + }); if (await manager.empty()) { if (config.debug) { console.warn("Content script or relay entries not found"); } } else { - options = await relay.getOptionsMap(); - const entries = await manager.entries(); let entryWorlds = await manager.entryWorlds(); let entryShadows = await manager.entryShadows(); @@ -82,6 +85,7 @@ export default definePlugin(() => { manager.clear(); relayDeclaration.dictionary(await relay.dictionary()).build(); + relayDataPlugin.update(await getRelayData()); const entries = await manager.entries(); entryWorlds = await manager.entryWorlds(); @@ -139,11 +143,7 @@ export default definePlugin(() => { } return mergeConfig(rspack, { - plugins: [ - new DefinePlugin({ - __ADNBN_RELAY_OPTIONS__: JSON.stringify(options), - }), - ], + plugins: [relayDataPlugin], }); }, manifest: async ({manifest}) => { diff --git a/src/cli/plugins/page/declaration/PageDeclaration.ts b/src/cli/plugins/page/declaration/PageDeclaration.ts index 335b1e01..70b51344 100644 --- a/src/cli/plugins/page/declaration/PageDeclaration.ts +++ b/src/cli/plugins/page/declaration/PageDeclaration.ts @@ -20,11 +20,10 @@ export default class extends FileBuilder { protected template(): string { let content = this.readFile(); - if (this.alias.size > 0) { - const type = '"' + Array.from(this.alias).join('" | "') + '"'; - - content = content.replace("type PageAlias = string", `type PageAlias = ${type}`); - } + content = content.replace( + "// :page-aliases", + Array.from(this.alias, alias => `${JSON.stringify(alias)}: true;`).join("\n ") + ); return content; } diff --git a/src/cli/plugins/page/declaration/page.d.ts b/src/cli/plugins/page/declaration/page.d.ts index f9cb6bec..a01b0a36 100644 --- a/src/cli/plugins/page/declaration/page.d.ts +++ b/src/cli/plugins/page/declaration/page.d.ts @@ -1,7 +1,7 @@ import ":package"; declare module ":package" { - export type PageAlias = string; - - export function getPageUrl(alias: PageAlias): string; + interface PageAliasRegistry { + // :page-aliases + } } diff --git a/src/cli/plugins/page/index.ts b/src/cli/plugins/page/index.ts index 0acbba84..01f889cc 100644 --- a/src/cli/plugins/page/index.ts +++ b/src/cli/plugins/page/index.ts @@ -1,4 +1,4 @@ -import {Configuration as RspackConfig, DefinePlugin, HtmlRspackPlugin, Plugins} from "@rspack/core"; +import {Configuration as RspackConfig, HtmlRspackPlugin, Plugins} from "@rspack/core"; import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; import Page from "./Page"; @@ -7,10 +7,10 @@ import {PageDeclaration} from "./declaration"; import {definePlugin} from "@main/plugin"; import {virtualViewModule} from "@cli/virtual"; -import {EntrypointPlugin} from "@cli/bundler"; -import {ViewAliasToFilename} from "@cli/entrypoint"; +import {EntrypointPlugin, RuntimeDataPlugin} from "@cli/bundler"; import {Command} from "@typing/app"; +import {PageAliasesRuntimeProperty} from "@typing/page"; export default definePlugin(() => { let page: Page; @@ -26,17 +26,19 @@ export default definePlugin(() => { bundler: async ({config}) => { declaration.setAlias(await page.getAlias()).build(); - const plugins: Plugins = []; + const aliases = Object.fromEntries( + Array.from((await page.views()).values(), item => [item.alias, item.filename]) + ); + + const aliasPlugin = new RuntimeDataPlugin({property: PageAliasesRuntimeProperty, data: aliases}); - let alias: ViewAliasToFilename = new Map(); + const plugins: Plugins = []; if (await page.empty()) { if (config.debug) { console.info("Page entries not found"); } } else { - alias = await page.getAliasToFilename(); - // prettier-ignore const plugin = EntrypointPlugin.from(await page.view().entries()) .virtual(file => virtualViewModule(file)); @@ -45,6 +47,12 @@ export default definePlugin(() => { plugin.watch(async () => { declaration.setAlias(await page.clear().getAlias()).build(); + aliasPlugin.update( + Object.fromEntries( + Array.from((await page.views()).values(), item => [item.alias, item.filename]) + ) + ); + return page.view().entries(); }); } @@ -56,12 +64,7 @@ export default definePlugin(() => { } return { - plugins: [ - new DefinePlugin({ - __ADNBN_PAGE_ALIAS__: JSON.stringify(alias), - }), - ...plugins, - ], + plugins: [aliasPlugin, ...plugins], } satisfies RspackConfig; }, manifest: async ({manifest}) => { diff --git a/src/main/page.ts b/src/main/page.ts index b5a57afe..b4896915 100644 --- a/src/main/page.ts +++ b/src/main/page.ts @@ -1,23 +1,20 @@ import {getUrl} from "@addon-core/browser"; -import {PageConfig, PageDefinition, PageProps} from "@typing/page"; +import {PageAliasesRuntimeProperty, type PageAlias, type PageDefinition, type PageMap} from "@typing/page"; -export type {PageDefinition, PageProps, PageConfig}; - -export type PageAlias = string; - -export type PageMap = Map; +export type {PageDefinition, PageProps, PageConfig, PageAliasRegistry, PageAlias, PageMap} from "@typing/page"; export const definePage = (options: PageDefinition): PageDefinition => { return options; }; +declare const __webpack_require__: {[PageAliasesRuntimeProperty]?: Record}; + export const getPages = (): PageMap => { const pages: PageMap = new Map(); try { - // @ts-expect-error: __ADNBN_PAGE_ALIAS__ is a virtual variable generated by the bundler `src/cli/plugins/page/index.ts` - Object.entries(__ADNBN_PAGE_ALIAS__).forEach(([key, value]) => { + Object.entries(__webpack_require__[PageAliasesRuntimeProperty] ?? {}).forEach(([key, value]) => { pages.set(key, value); }); } catch (e) { diff --git a/src/main/relay.test.ts b/src/main/relay.test.ts new file mode 100644 index 00000000..ec47e0d4 --- /dev/null +++ b/src/main/relay.test.ts @@ -0,0 +1,51 @@ +import {getRelay} from "./relay"; +import {RelayMethod, RelayOptionsRuntimeProperty, type RelayOptions} from "@typing/relay"; +import {RelayPermissionGlobalKey} from "@relay/RelayPermission"; + +// Generated RelayRegistry typing is covered by RelayDeclaration tests; exercise arbitrary runtime maps here. +const getFixtureRelay = getRelay as (name: string, tabId: number) => unknown; + +describe("getRelay runtime options", () => { + const runtimeGlobal = globalThis as typeof globalThis & { + __webpack_require__?: {[RelayOptionsRuntimeProperty]?: Record}; + }; + const runtimeDescriptor = Object.getOwnPropertyDescriptor(globalThis, "__webpack_require__"); + const permissionDescriptor = Object.getOwnPropertyDescriptor(globalThis, RelayPermissionGlobalKey); + + afterEach(() => { + for (const [property, descriptor] of [ + ["__webpack_require__", runtimeDescriptor], + [RelayPermissionGlobalKey, permissionDescriptor], + ] as const) { + if (descriptor) Object.defineProperty(globalThis, property, descriptor); + else Reflect.deleteProperty(globalThis, property); + } + }); + + test("creates a proxy from the options in the current entry runtime", () => { + runtimeGlobal.__webpack_require__ = { + [RelayOptionsRuntimeProperty]: {collector: {name: "collector", method: RelayMethod.Messaging}}, + }; + + expect(getFixtureRelay("collector", 1)).toHaveProperty("__proxy", true); + expect(() => getFixtureRelay("missing", 1)).toThrow('Failed to get relay "missing"'); + }); + + test("does not keep the options map of another runtime", () => { + runtimeGlobal.__webpack_require__ = { + [RelayOptionsRuntimeProperty]: {first: {name: "first", method: RelayMethod.Messaging}}, + }; + expect(getFixtureRelay("first", 1)).toHaveProperty("__proxy", true); + + runtimeGlobal.__webpack_require__ = { + [RelayOptionsRuntimeProperty]: {second: {name: "second", method: RelayMethod.Messaging}}, + }; + expect(getFixtureRelay("second", 1)).toHaveProperty("__proxy", true); + expect(() => getFixtureRelay("first", 1)).toThrow('Failed to get relay "first"'); + }); + + test("preserves the unknown Relay error for a runtime without Relay data", () => { + runtimeGlobal.__webpack_require__ = {}; + expect(() => getFixtureRelay("missing", 1)).toThrow('Failed to get relay "missing"'); + }); +}); diff --git a/src/main/relay.ts b/src/main/relay.ts index bc81421b..5e14e853 100644 --- a/src/main/relay.ts +++ b/src/main/relay.ts @@ -1,7 +1,6 @@ import RelayPermission from "@relay/RelayPermission"; import {ProxyRelay, type ProxyRelayParams} from "@relay/providers"; -import type {RelayName} from "@relay/index"; import type {TransportType} from "@typing/transport"; import { RelayDefinition, @@ -10,8 +9,10 @@ import { RelayMethod, RelayOptions, RelayOptionsMap, + RelayOptionsRuntimeProperty, type RelayBatchOptions, type RelayBatchProxyTarget, + type RelayName, type RelayProxyTarget, type RelayScalarOptions, } from "@typing/relay"; @@ -46,12 +47,15 @@ export const defineRelay = (options: RelayDefinition return options; }; +declare const __webpack_require__: {[RelayOptionsRuntimeProperty]?: Record}; + const getRelayOptionsMap = (): RelayOptionsMap => { const relays: RelayOptionsMap = new Map(); try { - // @ts-expect-error: __ADNBN_RELAY_OPTIONS__ is a virtual variable generated by the bundler `src/cli/plugins/content/index.ts` - Object.entries(__ADNBN_RELAY_OPTIONS__).forEach(([key, value]) => relays.set(key, value)); + Object.entries(__webpack_require__[RelayOptionsRuntimeProperty] ?? {}).forEach(([name, options]) => + relays.set(name, options) + ); } catch (e) { console.error("Failed getting relays: ", e); } diff --git a/src/relay/index.ts b/src/relay/index.ts index b5838e8e..0d5f0dd3 100644 --- a/src/relay/index.ts +++ b/src/relay/index.ts @@ -1,12 +1,8 @@ import Relay from "./providers/Relay"; -import type {TransportTarget} from "@typing/transport"; +import type {RelayName, RelayTarget} from "@typing/relay"; -export interface RelayRegistry {} - -export type RelayName = Extract; - -export type RelayTarget = TransportTarget; +export type {RelayRegistry, RelayName, RelayTarget} from "@typing/relay"; export const getRelay = (name: N): RelayTarget => { return new Relay(name).get(); diff --git a/src/types/page.ts b/src/types/page.ts index 0dd89a07..fdb195d7 100644 --- a/src/types/page.ts +++ b/src/types/page.ts @@ -1,6 +1,16 @@ import {ViewDefinition, ViewOptions} from "@typing/view"; import {CspOptions} from "@typing/csp"; +/** Shared property used to write and read page aliases on the bundler runtime. */ +export const PageAliasesRuntimeProperty = "__adnbnPageAliases"; + +/** Augmented by the generated page declarations of the consuming application. */ +export interface PageAliasRegistry {} + +export type PageAlias = keyof PageAliasRegistry extends never ? string : Extract; + +export type PageMap = Map; + export interface PageConfig { name?: string; matches?: string[]; diff --git a/src/types/relay.ts b/src/types/relay.ts index beccd0f0..e0d976b5 100644 --- a/src/types/relay.ts +++ b/src/types/relay.ts @@ -1,12 +1,19 @@ import {EntrypointOptions} from "@typing/entrypoint"; import {ContentScriptConfig, ContentScriptContext, ContentScriptDefinition} from "@typing/content"; -import {TransportConfig, TransportDefinition, TransportType, type TransportProxyTarget} from "@typing/transport"; +import {TransportConfig, TransportDefinition, TransportType, type TransportProxyTarget, type TransportTarget} from "@typing/transport"; import {Awaiter} from "@typing/helpers"; import type {MessageError} from "@typing/message"; -import type {RelayRegistry} from "@relay/index"; export const RelayGlobalKey = "adnbnRelay"; +/** Shared build/runtime key for the serialized Relay options map. */ +export const RelayOptionsRuntimeProperty = "__adnbnRelayOptions"; + +/** Augmented through adnbn/relay by the generated Relay declarations of the consuming application. */ +export interface RelayRegistry {} + +export type RelayName = Extract; + export enum RelayMethod { Scripting = "scripting", Messaging = "messaging", @@ -166,6 +173,8 @@ export type RelayBatchRpcProxy = { export type RelayBatchRpcProxyObject = (() => Promise>>) & RelayBatchRpcProxy; +export type RelayTarget = TransportTarget; + export type RelayProxyTarget = TransportProxyTarget; export type RelayBatchProxyTarget = RelayBatchRpcProxy; diff --git a/tsup.config.ts b/tsup.config.ts index 2adeacbe..dd0016e8 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -57,6 +57,7 @@ const fixVirtualIndexImportPlugin = (): Plugin => ({ }); const runtimeTemplateEntries = [ + "src/cli/bundler/plugins/runtime-data/templates.ts", "src/cli/bundler/plugins/chunk-loader/templates.ts", "src/cli/bundler/plugins/shadow-styles/templates.ts", ]; From f5701c614deb98a4b3dd27ab07c932d58d8c4a02 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:22:56 +0300 Subject: [PATCH 14/38] fix(build): correct watch updates and virtual module imports Normalize watched entry paths, retain runtime-only JavaScript chunks in asset validation, and share chunk filename resolution below plugin owners. Resolve the virtual entrypoint source alias to its emitted ESM path during bundling instead of encoding output extensions in TypeScript imports. --- .../build-assets-map/BuildAssetsMapPlugin.ts | 153 ++---------------- .../entrypoint/EntrypointPlugin.test.ts | 17 ++ .../plugins/entrypoint/EntrypointPlugin.ts | 11 +- src/cli/bundler/utils/chunk-filename.ts | 135 ++++++++++++++++ src/cli/virtual/index.ts | 2 +- src/cli/virtual/virtual.test.ts | 8 + tsup.config.ts | 14 +- 7 files changed, 186 insertions(+), 154 deletions(-) create mode 100644 src/cli/bundler/utils/chunk-filename.ts diff --git a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts index e33302f7..c55b65cf 100644 --- a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts +++ b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts @@ -1,18 +1,15 @@ -import { - AssetInfo, - Chunk, - Compilation, - Compiler, - Filename, - PathData, - RuntimeGlobals, - RuntimeModule, - sources, -} from "@rspack/core"; +import {Chunk, Compilation, Compiler, Filename, RuntimeGlobals, RuntimeModule, sources} from "@rspack/core"; import stringify from "json-stringify-deterministic"; import ts from "typescript"; import {collectBuildAssets, setCompilationBuildAssets} from "@cli/bundler/utils/output"; +import { + CssContentHashType, + JavaScriptContentHashType, + resolveChunkFilename, + resolveFilenameTemplate, + filenameRequiresFullHash, +} from "@cli/bundler/utils/chunk-filename"; import {toPosix} from "@cli/utils/path"; import type {EntrypointAssetsMap, EntrypointAssets} from "@typing/entrypoint"; @@ -25,11 +22,6 @@ const FullMapEnvelopeKey = "__adnbnBuildAssetsFullMap__"; const CurrentMapEnvelopeKey = "__adnbnBuildAssetsCurrentMap__"; const MapProperty = "__adnbnBuildAssets"; const CurrentMapProperty = "__adnbnCurrentEntrypointAssets"; -const CssContentHashType = "css/mini-extract"; -const JavaScriptContentHashType = "javascript"; -const UnsupportedCallbackHashError = - "Build assets filename callbacks cannot read PathData hash values; return [contenthash], [chunkhash], or [fullhash] placeholders instead"; - // Hash-based output names are fixed when processAssets starts, so the asset graph is frozen here. const EmbedStage = Number.NEGATIVE_INFINITY; const FinalValidationStage = Infinity; @@ -46,134 +38,8 @@ export interface BuildAssetsMapPluginOptions { readonly fullMapEntrypoint: string; } -type FilenameTemplateCache = Map>>; type RuntimeModuleFingerprints = Map>; -const filenameTemplateCaches = new WeakMap(); - -// Final hashes do not exist while chunkHash is calculated. Returning placeholders keeps the callback result -// hashable before Rspack resolves those placeholders into the emitted filename. -const rejectResolvedHashAccess = (value: T): T => { - return new Proxy(value, { - get(target, property, receiver) { - if (property === "hash" || property === "contentHash") { - throw new Error(UnsupportedCallbackHashError); - } - - return Reflect.get(target, property, receiver); - }, - }); -}; - -const getFilenameCallbackContext = ( - compilation: Compilation, - chunk: Chunk, - contentHashType: string -): {assetInfo: AssetInfo; pathData: PathData} => { - const id = chunk.id == null ? undefined : String(chunk.id); - const pathData: PathData = { - chunk: rejectResolvedHashAccess({ - id, - name: chunk.name ?? id, - hash: chunk.hash ?? undefined, - }), - contentHash: chunk.contentHash[contentHashType], - hash: compilation.fullHash ?? undefined, - }; - const assetInfo: AssetInfo = { - fullhash: [], - chunkhash: [], - contenthash: [], - related: {}, - assetType: contentHashType === JavaScriptContentHashType ? "javascript" : "extract-css", - }; - - if (contentHashType === JavaScriptContentHashType) { - pathData.runtime = undefined; - assetInfo.javascriptModule = Boolean(compilation.outputOptions.module); - } - - return {assetInfo, pathData: rejectResolvedHashAccess(pathData)}; -}; - -const getFilenamePathData = (compilation: Compilation, chunk: Chunk, contentHashType: string): PathData => { - return { - chunk, - contentHash: chunk.contentHash[contentHashType], - contentHashType, - hash: compilation.fullHash ?? undefined, - }; -}; - -const resolveFilenameTemplate = ( - compilation: Compilation, - chunk: Chunk, - filename: Filename, - contentHashType: string -): string => { - if (typeof filename !== "function") { - return filename; - } - - let compilationCache = filenameTemplateCaches.get(compilation); - - if (!compilationCache) { - compilationCache = new Map(); - filenameTemplateCaches.set(compilation, compilationCache); - } - - let filenameCache = compilationCache.get(filename); - - if (!filenameCache) { - filenameCache = new Map(); - compilationCache.set(filename, filenameCache); - } - - let chunkCache = filenameCache.get(chunk); - - if (!chunkCache) { - chunkCache = new Map(); - filenameCache.set(chunk, chunkCache); - } - - const cached = chunkCache.get(contentHashType); - - if (cached !== undefined) { - return cached; - } - - const {assetInfo, pathData} = getFilenameCallbackContext(compilation, chunk, contentHashType); - const template = filename(pathData, assetInfo); - - chunkCache.set(contentHashType, template); - - return template; -}; - -const resolveChunkFilename = ( - compilation: Compilation, - chunk: Chunk, - filename: Filename, - contentHashType: string -): string => { - const data = getFilenamePathData(compilation, chunk, contentHashType); - const template = resolveFilenameTemplate(compilation, chunk, filename, contentHashType); - - return toPosix(compilation.getPath(template, data)); -}; - -const filenameRequiresFullHash = ( - compilation: Compilation, - chunk: Chunk, - filename: Filename, - contentHashType: string -): boolean => { - const template = resolveFilenameTemplate(compilation, chunk, filename, contentHashType); - const callbackUsesHash = typeof filename === "function" && /\b(?:fullHash|hash)\b/.test(filename.toString()); - - return callbackUsesHash || /\[(?:fullhash|hash)(?::\d+)?\]/i.test(template); -}; - const validateAsyncChunkFilenames = (compilation: Compilation, cssChunkFilename: Filename): void => { const asyncChunks = new Set( Array.from(compilation.entrypoints.values()).flatMap(entrypoint => { @@ -231,6 +97,9 @@ const chunkHasCss = (compilation: Compilation, chunk: Chunk): boolean => { }; const chunkHasJavaScript = (compilation: Compilation, chunk: Chunk): boolean => { + // splitChunks can move every user module out of an entry, but its bootstrap + // still emits a JavaScript file and remains an initial dependency. + if (chunk.hasRuntime()) return true; return Array.from(compilation.chunkGraph.getChunkModulesIterable(chunk)).some(module => { return module.type.startsWith("javascript"); }); diff --git a/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.test.ts b/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.test.ts index 14c5151a..115d38c2 100644 --- a/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.test.ts +++ b/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.test.ts @@ -16,6 +16,23 @@ class TestEntrypointPlugin extends EntrypointPlugin { } } +test.each(["./example.content.ts", path.resolve("example.content.ts")])( + "watch recognizes the entry path %s", + async filename => { + const entries = new Map([["example.content", new Set([{file: filename, import: filename}])]]); + const update = jest.fn(async () => entries); + const plugin = new TestEntrypointPlugin(entries).watch(update); + const entry = {} as EntryNormalized; + plugin.applyEntryOptions(entry); + await plugin.applyWatch({ + context: process.cwd(), + modifiedFiles: new Set([path.resolve("example.content.ts")]), + options: {entry}, + } as unknown as Compiler); + expect(update).toHaveBeenCalledTimes(1); + } +); + test("applies entry options while preserving existing entry configuration", () => { const plugin = new TestEntrypointPlugin( new Map([ diff --git a/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.ts b/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.ts index ae3f4ccf..396927ee 100644 --- a/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.ts +++ b/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.ts @@ -142,15 +142,8 @@ export default class EntrypointPlugin { protected async hookWatchRun(compiler: Compiler): Promise { const {modifiedFiles = new Set()} = compiler; - const watchFiles = this.watchFiles; - - const needUpdate = Array.from(modifiedFiles).some(file => { - if (path.isAbsolute(file)) { - file = path.relative(compiler.context, file); - } - - return watchFiles.has(file); - }); + const watchFiles = new Set(Array.from(this.watchFiles, file => path.resolve(compiler.context, file))); + const needUpdate = Array.from(modifiedFiles).some(file => watchFiles.has(path.resolve(compiler.context, file))); if (!needUpdate) { return; diff --git a/src/cli/bundler/utils/chunk-filename.ts b/src/cli/bundler/utils/chunk-filename.ts new file mode 100644 index 00000000..729eb47c --- /dev/null +++ b/src/cli/bundler/utils/chunk-filename.ts @@ -0,0 +1,135 @@ +import type {AssetInfo, Chunk, Compilation, Filename, PathData} from "@rspack/core"; + +import {toPosix} from "@cli/utils/path"; + +export const CssContentHashType = "css/mini-extract"; +export const JavaScriptContentHashType = "javascript"; +const UnsupportedCallbackHashError = + "Build assets filename callbacks cannot read PathData hash values; return [contenthash], [chunkhash], or [fullhash] placeholders instead"; + +type FilenameTemplateCache = Map>>; + +const filenameTemplateCaches = new WeakMap(); + +// Final hashes do not exist while chunkHash is calculated. Returning placeholders keeps the callback result +// hashable before Rspack resolves those placeholders into the emitted filename. +const rejectResolvedHashAccess = (value: T): T => { + return new Proxy(value, { + get(target, property, receiver) { + if (property === "hash" || property === "contentHash") { + throw new Error(UnsupportedCallbackHashError); + } + + return Reflect.get(target, property, receiver); + }, + }); +}; + +const getFilenameCallbackContext = ( + compilation: Compilation, + chunk: Chunk, + contentHashType: string +): {assetInfo: AssetInfo; pathData: PathData} => { + const id = chunk.id == null ? undefined : String(chunk.id); + const pathData: PathData = { + chunk: rejectResolvedHashAccess({ + id, + name: chunk.name ?? id, + hash: chunk.hash ?? undefined, + }), + contentHash: chunk.contentHash[contentHashType], + hash: compilation.fullHash ?? undefined, + }; + const assetInfo: AssetInfo = { + fullhash: [], + chunkhash: [], + contenthash: [], + related: {}, + assetType: contentHashType === JavaScriptContentHashType ? "javascript" : "extract-css", + }; + + if (contentHashType === JavaScriptContentHashType) { + pathData.runtime = undefined; + assetInfo.javascriptModule = Boolean(compilation.outputOptions.module); + } + + return {assetInfo, pathData: rejectResolvedHashAccess(pathData)}; +}; + +const getFilenamePathData = (compilation: Compilation, chunk: Chunk, contentHashType: string): PathData => { + return { + chunk, + contentHash: chunk.contentHash[contentHashType], + contentHashType, + hash: compilation.fullHash ?? undefined, + }; +}; + +export const resolveFilenameTemplate = ( + compilation: Compilation, + chunk: Chunk, + filename: Filename, + contentHashType: string +): string => { + if (typeof filename !== "function") { + return filename; + } + + let compilationCache = filenameTemplateCaches.get(compilation); + + if (!compilationCache) { + compilationCache = new Map(); + filenameTemplateCaches.set(compilation, compilationCache); + } + + let filenameCache = compilationCache.get(filename); + + if (!filenameCache) { + filenameCache = new Map(); + compilationCache.set(filename, filenameCache); + } + + let chunkCache = filenameCache.get(chunk); + + if (!chunkCache) { + chunkCache = new Map(); + filenameCache.set(chunk, chunkCache); + } + + const cached = chunkCache.get(contentHashType); + + if (cached !== undefined) { + return cached; + } + + const {assetInfo, pathData} = getFilenameCallbackContext(compilation, chunk, contentHashType); + const template = filename(pathData, assetInfo); + + chunkCache.set(contentHashType, template); + + return template; +}; + +export const resolveChunkFilename = ( + compilation: Compilation, + chunk: Chunk, + filename: Filename, + contentHashType: string +): string => { + const data = getFilenamePathData(compilation, chunk, contentHashType); + const template = resolveFilenameTemplate(compilation, chunk, filename, contentHashType); + + return toPosix(compilation.getPath(template, data)); +}; + +export const filenameRequiresFullHash = ( + compilation: Compilation, + chunk: Chunk, + filename: Filename, + contentHashType: string +): boolean => { + const template = resolveFilenameTemplate(compilation, chunk, filename, contentHashType); + const callbackUsesHash = typeof filename === "function" && /\b(?:fullHash|hash)\b/.test(filename.toString()); + + return callbackUsesHash || /\[(?:fullhash|hash)(?::\d+)?\]/i.test(template); +}; diff --git a/src/cli/virtual/index.ts b/src/cli/virtual/index.ts index edc99f28..d378923b 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 "../entrypoint/index.js"; +import {inferEntrypointFramework} from "@cli/entrypoint"; import {PackageName} from "@typing/app"; import {EntrypointFile} from "@typing/entrypoint"; diff --git a/src/cli/virtual/virtual.test.ts b/src/cli/virtual/virtual.test.ts index 783ef734..ac1b45ad 100644 --- a/src/cli/virtual/virtual.test.ts +++ b/src/cli/virtual/virtual.test.ts @@ -1,4 +1,5 @@ import {execFileSync} from "child_process"; +import {readFileSync} from "fs"; import path from "path"; import ts from "typescript"; @@ -98,6 +99,13 @@ describe("Built virtual modules", () => { expect(Object.keys(generated.ts).sort()).toEqual(cases.map(({generator}) => generator).sort()); }); + test("keeps the entrypoint dependency external with a relative ESM path", () => { + const source = readFileSync(path.join(projectDir, "dist/cli/virtual/index.js"), "utf8"); + const importedFiles = ts.preProcessFile(source).importedFiles.map(file => file.fileName); + + expect(importedFiles).toContain("../entrypoint/index.js"); + }); + describe.each([ {extension: "ts" as const, framework: "vanilla"}, {extension: "tsx" as const, framework: "react"}, diff --git a/tsup.config.ts b/tsup.config.ts index dd0016e8..570c5ffc 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -56,6 +56,17 @@ const fixVirtualIndexImportPlugin = (): Plugin => ({ }, }); +const virtualEntrypointImportPlugin = (): Plugin => ({ + name: "virtual-entrypoint-import", + setup(build) { + // Keep the entrypoint module external; only the build knows its emitted ESM path. + build.onResolve({filter: /^@cli\/entrypoint$/}, () => ({ + path: "../entrypoint/index.js", + external: true, + })); + }, +}); + const runtimeTemplateEntries = [ "src/cli/bundler/plugins/runtime-data/templates.ts", "src/cli/bundler/plugins/chunk-loader/templates.ts", @@ -98,10 +109,9 @@ export default defineConfig([ clean: false, dts: false, sourcemap: false, - external: ["../entrypoint/index.js"], // Raw templates are source text; output-wide import rewrites corrupt their package specifiers. // @ts-ignore - esbuildPlugins: [rawPlugin()], + esbuildPlugins: [virtualEntrypointImportPlugin(), rawPlugin()], esbuildOptions: options => { options.outbase = "src"; }, From e40b617219f18f91ef65e89113932a967b1f276a Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:27:22 +0300 Subject: [PATCH 15/38] feat(content): unify shadow and iframe isolation Support isolation modes and frame page/src navigation for content scripts and Relay. Route opt-in isolation CSS separately from document styles, retain lazy loading, and expose resources without background dependencies. Render into ShadowRoot or iframe targets, recover blank iframe documents after host moves, and release stale CSS waits before teardown. Register fonts through CSS and remove the branch's experimental shadow/fonts API. Separate shared validation, feature policy, bundler integration and runtime nodes. Cover parser contracts, access rules, watch transitions, asset names, CSS errors/retries and browser behavior with unit and integration fixtures. --- jest.config.ts | 2 + package-lock.json | 1 + package.json | 5 + src/cli/builders/manifest/ManifestBase.ts | 11 +- src/cli/builders/manifest/ManifestV3.test.ts | 9 +- src/cli/bundler/plugins/index.ts | 6 +- .../IsolatedStylesPlugin.test.ts | 907 ++++++++++++++++++ .../isolated-styles/IsolatedStylesPlugin.ts | 224 +++++ .../bundler/plugins/isolated-styles/index.ts | 1 + .../templates.ts | 21 +- .../templates/css-loader.template.js | 12 + .../templates/runtime.template.js | 127 +++ .../tests/fixtures/normal-shared.js | 2 + .../tests/fixtures/normal.css | 0 .../tests/fixtures/normal.js | 0 .../tests/fixtures/normal.lazy.css | 0 .../tests/fixtures/normal.lazy.js | 0 .../tests/fixtures/shadow-async.js | 2 + .../tests/fixtures/shadow-shared.css | 3 + .../tests/fixtures/shadow-shared.js | 4 + .../tests/fixtures/shadow.css | 0 .../isolated-styles/tests/fixtures/shadow.js | 4 + .../tests/fixtures/shadow.lazy-a.css | 0 .../tests/fixtures/shadow.lazy-b.css | 0 .../tests/fixtures/shadow.lazy.js | 0 .../shadow-styles/ShadowStylesPlugin.test.ts | 548 ----------- .../shadow-styles/ShadowStylesPlugin.ts | 123 --- .../bundler/plugins/shadow-styles/index.ts | 1 - .../templates/css-loader.template.js | 10 - .../templates/runtime.template.js | 108 --- .../shadow-styles/tests/fixtures/shadow.js | 4 - src/cli/bundler/utils/index.ts | 2 + src/cli/bundler/utils/layers.ts | 10 + src/cli/bundler/utils/output.ts | 56 +- src/cli/bundler/utils/styles.ts | 31 + src/cli/entrypoint/file/OptionFile.ts | 64 ++ src/cli/entrypoint/file/SourceFile.ts | 31 + src/cli/entrypoint/file/injectors/core.ts | 12 +- .../entrypoint/parser/ContentParser.test.ts | 80 +- src/cli/entrypoint/parser/ContentParser.ts | 132 ++- src/cli/entrypoint/parser/RelayParser.test.ts | 23 +- src/cli/entrypoint/parser/RelayParser.ts | 36 +- .../content/invalid/default-render/Panel.tsx | 3 + .../anonymous-function.content.tsx | 6 + .../invalid/default-render/arrow.content.tsx | 4 + .../imported-component.content.tsx | 5 + .../invalid/default-render/jsx.content.tsx | 5 + .../local-component.content.tsx | 5 + .../default-render/named-function.content.tsx | 6 + .../reexported-component.content.tsx | 3 + .../content/invalid/frame-auto.content.ts | 2 + .../content/invalid/frame-conflict.content.ts | 2 + .../invalid/frame-dynamic-page.content.ts | 2 + .../invalid/frame-dynamic-src.content.ts | 2 + .../invalid/frame-identifier-page.content.ts | 3 + .../content/invalid/frame-render.content.ts | 8 + .../content/invalid/frame-spread.content.ts | 2 + .../frame-without-isolation.content.ts | 2 + .../invalid/isolation-dynamic.content.ts | 2 + .../invalid/shadow-identifier.content.ts | 7 - .../default-render/function.content.tsx | 5 + .../default-render/imported-object.content.ts | 5 + .../default-render/local-object.content.ts | 5 + .../options/default-render/object.content.ts | 1 + .../content/options/default-render/options.ts | 1 + .../isolation/frame-constant.content.ts | 4 + .../options/isolation/iframe.content.ts | 6 + .../content/options/isolation/page.content.ts | 3 + .../options/isolation/shadow.content.ts | 2 + .../content/options/shadow/false.content.ts | 5 - .../options/shadow/object/entry.content.ts | 15 - .../content/options/shadow/object/panel.woff2 | 0 .../content/options/shadow/true.content.ts | 5 - .../relay/invalid/frame-render.relay.ts | 6 + .../options/default-init/function.relay.ts | 6 + .../options/default-init/imported.relay.ts | 5 + .../relay/options/default-init/init.ts | 1 + .../default-init/named-render.relay.ts | 6 + .../fixtures/relay/options/isolation/relay.ts | 9 + .../fixtures/relay/options/shadow/relay.ts | 7 - src/cli/plugins/asset.ts | 18 +- src/cli/plugins/content/Content.ts | 5 +- .../plugins/content/ContentManager.test.ts | 165 +++- src/cli/plugins/content/ContentManager.ts | 100 +- src/cli/plugins/content/ContentName.ts | 7 +- src/cli/plugins/content/Relay.ts | 5 +- src/cli/plugins/content/bundler.test.ts | 82 ++ src/cli/plugins/content/bundler.ts | 57 ++ src/cli/plugins/content/index.ts | 260 ++--- .../tests/fixtures/relay-types/virtual.ts | 2 +- src/cli/plugins/content/types.ts | 2 +- src/cli/plugins/content/utils.test.ts | 23 +- src/cli/plugins/content/utils.ts | 17 +- src/cli/plugins/style/index.ts | 84 +- src/cli/plugins/style/types.ts | 6 + src/cli/resolvers/config.ts | 3 +- src/cli/virtual/content.ts | 8 +- src/cli/virtual/index.ts | 11 +- src/cli/virtual/relay.ts | 5 +- src/cli/virtual/virtual.d.ts | 13 +- src/cli/virtual/virtual.test.ts | 58 +- src/entry/content/adapters/react/Builder.ts | 2 +- src/entry/content/adapters/react/Node.ts | 7 +- src/entry/content/adapters/vanilla/Builder.ts | 2 +- src/entry/content/adapters/vanilla/Node.ts | 10 +- src/entry/content/core/Builder.ts | 30 +- src/entry/content/core/MountBuilder.ts | 28 +- .../content/core/ShadowFontRegistry.test.ts | 80 -- src/entry/content/core/ShadowFontRegistry.ts | 49 - .../core/{ => context}/Context.test.ts | 2 +- .../content/core/{ => context}/Context.ts | 0 .../core/{ => context}/EventEmitter.ts | 0 .../core/{ => context}/ManagedContext.ts | 0 src/entry/content/core/context/index.ts | 3 + .../core/{ => markers}/AbstractMarker.ts | 0 .../{ => markers}/AttributeMarker.test.ts | 2 +- .../core/{ => markers}/AttributeMarker.ts | 0 .../core/{ => markers}/WeakMarker.test.ts | 2 +- .../content/core/{ => markers}/WeakMarker.ts | 0 src/entry/content/core/markers/index.ts | 3 + .../marker.test.ts} | 3 +- .../content/core/{ => nodes}/EventNode.ts | 0 .../content/core/nodes/FrameNode.test.ts | 94 ++ src/entry/content/core/nodes/FrameNode.ts | 158 +++ .../content/core/{ => nodes}/MarkerNode.ts | 0 .../content/core/{ => nodes}/MountNode.ts | 0 src/entry/content/core/{ => nodes}/Node.ts | 0 .../core/{ => nodes}/ShadowNode.test.ts | 60 +- .../content/core/{ => nodes}/ShadowNode.ts | 17 +- src/entry/content/core/nodes/index.ts | 6 + .../content/core/nodes/isolated-styles.ts | 23 + src/entry/content/core/shadow-styles.ts | 25 - src/entry/content/frame/Builder.ts | 14 + src/entry/content/frame/index.ts | 4 + src/shared/content/index.ts | 1 + src/shared/content/isolation.test.ts | 15 + src/shared/content/isolation.ts | 54 ++ src/types/content.ts | 105 +- src/types/manifest.ts | 9 +- src/types/relay.ts | 35 +- .../browser/content/css-fonts-utils.ts | 141 +++ .../css-fonts.firefox.integration.test.ts | 8 + .../content/css-fonts.integration.test.ts | 8 + .../browser/content/css-fonts/site/top.html | 12 + .../content/css-fonts/src/imported-font.css | 7 + .../content/css-fonts/src/isolated.css | 37 + .../browser/content/css-fonts/src/page.css | 13 + .../browser/content/css-fonts/src/probe.ts | 99 ++ .../browser/content/frame-pages-utils.ts | 80 ++ .../frame-pages.firefox.integration.test.ts | 9 + .../content/frame-pages.integration.test.ts | 6 + .../content/frame-pages/adnbn.config.ts | 7 + .../package.json | 2 +- .../content/frame-pages/site/page.html | 7 + .../browser/content/frame-pages/site/page.js | 1 + .../browser/content/frame-pages/site/top.html | 6 + .../content/frame-pages/src/contract.ts | 24 + .../content/frame-pages/src/page.content.ts | 14 + .../frame-pages/src/panel.page/index.ts | 11 + .../content/frame-pages/src/source.content.ts | 15 + .../browser/content/frame-pages/tsconfig.json | 1 + ...yles-utils.ts => isolated-styles-utils.ts} | 61 +- ...olation-iframe.firefox.integration.test.ts | 9 + .../isolation-iframe.integration.test.ts | 6 + .../content/isolation-iframe/adnbn.config.ts | 27 + .../content/isolation-iframe/package.json | 14 + .../site/child.html | 0 .../site/frames.html | 0 .../site/top.html | 0 .../src/normal.content.ts | 2 +- .../src/probe.content/Panel.tsx | 63 ++ .../src/probe.content/fonts.css | 6 + .../src/probe.content/index.tsx | 29 + .../src/probe.content/lazy.module.css | 0 .../src/probe.content/lazy.ts | 2 +- .../src/probe.content/probe.woff2 | Bin .../src/probe.content/styles.module.css | 0 .../src/secondary.content/index.ts | 63 ++ .../src/secondary.content/lazy.module.css | 0 .../src/secondary.content/lazy.ts | 2 +- .../src/secondary.content/styles.module.css | 0 .../isolation-iframe/src/shared/events.ts | 11 + .../src/shared/styles.module.css | 0 .../content/isolation-iframe/tsconfig.json | 6 + ...lation-shadow.firefox.integration.test.ts} | 4 +- ...s => isolation-shadow.integration.test.ts} | 4 +- .../adnbn.config.ts | 2 +- .../font.py | 0 .../content/isolation-shadow/package.json | 9 + .../content/isolation-shadow/site/child.html | 11 + .../content/isolation-shadow/site/frames.html | 12 + .../content/isolation-shadow/site/top.html | 14 + .../isolation-shadow/src/normal.content.ts | 15 + .../src/probe.content/fonts.css | 6 + .../src/probe.content/index.ts | 14 +- .../src/probe.content/lazy.module.css | 3 + .../src/probe.content/lazy.ts | 5 + .../src/probe.content/probe.woff2 | Bin 0 -> 404 bytes .../src/probe.content/styles.module.css | 13 + .../src/secondary.content/index.ts | 8 +- .../src/secondary.content/lazy.module.css | 3 + .../src/secondary.content/lazy.ts | 5 + .../src/secondary.content/styles.module.css | 3 + .../src/shared/styles.module.css | 3 + .../tsconfig.json | 0 .../browser/content/isolation-spike-utils.ts | 160 +++ ...solation-spike.firefox.integration.test.ts | 11 + .../isolation-spike.integration.test.ts | 8 + .../content/isolation-spike/site/allowed.html | 7 + .../isolation-spike/site/csp-blocked.html | 7 + .../content/isolation-spike/site/page.js | 1 + .../content/isolation-spike/site/top.html | 8 + .../isolation-spike/site/xfo-blocked.html | 7 + .../content/isolation-spike/src/embed.ts | 14 + .../content/isolation-spike/src/initial.css | 15 + .../content/isolation-spike/src/lazy.css | 3 + .../content/isolation-spike/src/panel.html | 7 + .../content/isolation-spike/src/panel.js | 1 + .../content/isolation-spike/src/probe.tsx | 162 ++++ .../content/relay-styles.integration.test.ts | 44 + .../content/relay-styles/adnbn.config.ts | 9 + .../browser/content/relay-styles/package.json | 9 + .../content/relay-styles/site/top.html | 6 + .../content/relay-styles/src/background.ts | 45 + .../relay-styles/src/control.page/index.ts | 14 + .../content/relay-styles/src/control.relay.ts | 22 + .../content/relay-styles/src/iframe.relay.ts | 12 + .../content/relay-styles/src/shadow.relay.ts | 11 + .../relay-styles/src/shared/host-lazy.css | 3 + .../content/relay-styles/src/shared/host.css | 3 + .../relay-styles/src/shared/lazy.module.css | 3 + .../content/relay-styles/src/shared/lazy.ts | 3 + .../relay-styles/src/shared/panel.module.css | 3 + .../content/relay-styles/src/shared/panel.ts | 27 + .../content/relay-styles/tsconfig.json | 8 + tests/integration/browser/utils/session.ts | 101 ++ tests/integration/browser/utils/site.ts | 8 +- .../isolation-watch.integration.test.ts | 146 +++ .../build/isolation/states/fonts.css | 4 + .../build/isolation/states/fonts.ts | 11 + .../build/isolation/states/iframe.ts | 11 + .../build/isolation/states/none.ts | 9 + .../build/isolation/states/page-isolated.ts | 8 + .../build/isolation/states/page.ts | 10 + .../build/isolation/states/renamed.ts | 10 + .../build/isolation/states/shadow-document.ts | 10 + .../build/isolation/states/shadow.ts | 10 + .../build/isolation/states/source.ts | 10 + .../relay-runtime.integration.test.ts | 95 ++ .../build/relay-runtime/states/background.ts | 7 + .../build/relay-runtime/states/messaging.ts | 6 + .../build/relay-runtime/states/scripting.ts | 10 + .../routing-spike.integration.test.ts | 242 +++++ .../build/style-routing/src/assets.js | 7 + .../build/style-routing/src/background.js | 1 + .../build/style-routing/src/entry.js | 3 + .../build/style-routing/src/lazy-page.css | 3 + .../build/style-routing/src/lazy-panel.css | 3 + .../build/style-routing/src/lazy.js | 4 + .../build/style-routing/src/nested.css | 3 + .../build/style-routing/src/other-assets.js | 2 + .../build/style-routing/src/package.json | 5 + .../build/style-routing/src/page.css | 3 + .../build/style-routing/src/panel.css | 4 + .../build/style-routing/src/resources/code.js | 1 + .../src/resources/css-font.woff2 | 1 + .../style-routing/src/resources/data.json | 1 + .../style-routing/src/resources/direct.woff2 | 1 + .../src/resources/document.custom | 1 + .../style-routing/src/resources/image.svg | 1 + .../style-routing/src/resources/inline.woff2 | 1 + .../style-routing/src/resources/movie.mp4 | 1 + .../style-routing/src/resources/styles.css | 13 + .../build/style-routing/src/shared.js | 2 + .../style-routing.integration.test.ts | 207 ++++ tsconfig.json | 4 + tsup.config.ts | 2 +- types/client.d.ts | 15 + 278 files changed, 5613 insertions(+), 1512 deletions(-) create mode 100644 src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts create mode 100644 src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.ts create mode 100644 src/cli/bundler/plugins/isolated-styles/index.ts rename src/cli/bundler/plugins/{shadow-styles => isolated-styles}/templates.ts (51%) create mode 100644 src/cli/bundler/plugins/isolated-styles/templates/css-loader.template.js create mode 100644 src/cli/bundler/plugins/isolated-styles/templates/runtime.template.js create mode 100644 src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal-shared.js rename src/cli/bundler/plugins/{shadow-styles => isolated-styles}/tests/fixtures/normal.css (100%) rename src/cli/bundler/plugins/{shadow-styles => isolated-styles}/tests/fixtures/normal.js (100%) rename src/cli/bundler/plugins/{shadow-styles => isolated-styles}/tests/fixtures/normal.lazy.css (100%) rename src/cli/bundler/plugins/{shadow-styles => isolated-styles}/tests/fixtures/normal.lazy.js (100%) create mode 100644 src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow-async.js create mode 100644 src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow-shared.css create mode 100644 src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow-shared.js rename src/cli/bundler/plugins/{shadow-styles => isolated-styles}/tests/fixtures/shadow.css (100%) create mode 100644 src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.js rename src/cli/bundler/plugins/{shadow-styles => isolated-styles}/tests/fixtures/shadow.lazy-a.css (100%) rename src/cli/bundler/plugins/{shadow-styles => isolated-styles}/tests/fixtures/shadow.lazy-b.css (100%) rename src/cli/bundler/plugins/{shadow-styles => isolated-styles}/tests/fixtures/shadow.lazy.js (100%) delete mode 100644 src/cli/bundler/plugins/shadow-styles/ShadowStylesPlugin.test.ts delete mode 100644 src/cli/bundler/plugins/shadow-styles/ShadowStylesPlugin.ts delete mode 100644 src/cli/bundler/plugins/shadow-styles/index.ts delete mode 100644 src/cli/bundler/plugins/shadow-styles/templates/css-loader.template.js delete mode 100644 src/cli/bundler/plugins/shadow-styles/templates/runtime.template.js delete mode 100644 src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.js create mode 100644 src/cli/bundler/utils/layers.ts create mode 100644 src/cli/bundler/utils/styles.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/Panel.tsx create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/anonymous-function.content.tsx create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/arrow.content.tsx create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/imported-component.content.tsx create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/jsx.content.tsx create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/local-component.content.tsx create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/named-function.content.tsx create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/reexported-component.content.tsx create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-auto.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-conflict.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-dynamic-page.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-dynamic-src.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-identifier-page.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-render.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-spread.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-without-isolation.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-dynamic.content.ts delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow-identifier.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/function.content.tsx create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/imported-object.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/local-object.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/object.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/options.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/frame-constant.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/page.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/shadow.content.ts delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/false.content.ts delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/object/entry.content.ts delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/object/panel.woff2 delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/true.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/relay/invalid/frame-render.relay.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/function.relay.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/imported.relay.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/init.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/named-render.relay.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/relay.ts delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/relay/options/shadow/relay.ts create mode 100644 src/cli/plugins/content/bundler.test.ts create mode 100644 src/cli/plugins/content/bundler.ts create mode 100644 src/cli/plugins/style/types.ts delete mode 100644 src/entry/content/core/ShadowFontRegistry.test.ts delete mode 100644 src/entry/content/core/ShadowFontRegistry.ts rename src/entry/content/core/{ => context}/Context.test.ts (96%) rename src/entry/content/core/{ => context}/Context.ts (100%) rename src/entry/content/core/{ => context}/EventEmitter.ts (100%) rename src/entry/content/core/{ => context}/ManagedContext.ts (100%) create mode 100644 src/entry/content/core/context/index.ts rename src/entry/content/core/{ => markers}/AbstractMarker.ts (100%) rename src/entry/content/core/{ => markers}/AttributeMarker.test.ts (95%) rename src/entry/content/core/{ => markers}/AttributeMarker.ts (100%) rename src/entry/content/core/{ => markers}/WeakMarker.test.ts (93%) rename src/entry/content/core/{ => markers}/WeakMarker.ts (100%) create mode 100644 src/entry/content/core/markers/index.ts rename src/entry/content/core/{Marker.test.ts => markers/marker.test.ts} (98%) rename src/entry/content/core/{ => nodes}/EventNode.ts (100%) create mode 100644 src/entry/content/core/nodes/FrameNode.test.ts create mode 100644 src/entry/content/core/nodes/FrameNode.ts rename src/entry/content/core/{ => nodes}/MarkerNode.ts (100%) rename src/entry/content/core/{ => nodes}/MountNode.ts (100%) rename src/entry/content/core/{ => nodes}/Node.ts (100%) rename src/entry/content/core/{ => nodes}/ShadowNode.test.ts (58%) rename src/entry/content/core/{ => nodes}/ShadowNode.ts (76%) create mode 100644 src/entry/content/core/nodes/index.ts create mode 100644 src/entry/content/core/nodes/isolated-styles.ts delete mode 100644 src/entry/content/core/shadow-styles.ts create mode 100644 src/entry/content/frame/Builder.ts create mode 100644 src/entry/content/frame/index.ts create mode 100644 src/shared/content/index.ts create mode 100644 src/shared/content/isolation.test.ts create mode 100644 src/shared/content/isolation.ts create mode 100644 tests/integration/browser/content/css-fonts-utils.ts create mode 100644 tests/integration/browser/content/css-fonts.firefox.integration.test.ts create mode 100644 tests/integration/browser/content/css-fonts.integration.test.ts create mode 100644 tests/integration/browser/content/css-fonts/site/top.html create mode 100644 tests/integration/browser/content/css-fonts/src/imported-font.css create mode 100644 tests/integration/browser/content/css-fonts/src/isolated.css create mode 100644 tests/integration/browser/content/css-fonts/src/page.css create mode 100644 tests/integration/browser/content/css-fonts/src/probe.ts create mode 100644 tests/integration/browser/content/frame-pages-utils.ts create mode 100644 tests/integration/browser/content/frame-pages.firefox.integration.test.ts create mode 100644 tests/integration/browser/content/frame-pages.integration.test.ts create mode 100644 tests/integration/browser/content/frame-pages/adnbn.config.ts rename tests/integration/browser/content/{shadow-styles => frame-pages}/package.json (76%) create mode 100644 tests/integration/browser/content/frame-pages/site/page.html create mode 100644 tests/integration/browser/content/frame-pages/site/page.js create mode 100644 tests/integration/browser/content/frame-pages/site/top.html create mode 100644 tests/integration/browser/content/frame-pages/src/contract.ts create mode 100644 tests/integration/browser/content/frame-pages/src/page.content.ts create mode 100644 tests/integration/browser/content/frame-pages/src/panel.page/index.ts create mode 100644 tests/integration/browser/content/frame-pages/src/source.content.ts create mode 100644 tests/integration/browser/content/frame-pages/tsconfig.json rename tests/integration/browser/content/{shadow-styles-utils.ts => isolated-styles-utils.ts} (80%) create mode 100644 tests/integration/browser/content/isolation-iframe.firefox.integration.test.ts create mode 100644 tests/integration/browser/content/isolation-iframe.integration.test.ts create mode 100644 tests/integration/browser/content/isolation-iframe/adnbn.config.ts create mode 100644 tests/integration/browser/content/isolation-iframe/package.json rename tests/integration/browser/content/{shadow-styles => isolation-iframe}/site/child.html (100%) rename tests/integration/browser/content/{shadow-styles => isolation-iframe}/site/frames.html (100%) rename tests/integration/browser/content/{shadow-styles => isolation-iframe}/site/top.html (100%) rename tests/integration/browser/content/{shadow-styles => isolation-iframe}/src/normal.content.ts (84%) create mode 100644 tests/integration/browser/content/isolation-iframe/src/probe.content/Panel.tsx create mode 100644 tests/integration/browser/content/isolation-iframe/src/probe.content/fonts.css create mode 100644 tests/integration/browser/content/isolation-iframe/src/probe.content/index.tsx rename tests/integration/browser/content/{shadow-styles => isolation-iframe}/src/probe.content/lazy.module.css (100%) rename tests/integration/browser/content/{shadow-styles => isolation-iframe}/src/probe.content/lazy.ts (68%) rename tests/integration/browser/content/{shadow-styles => isolation-iframe}/src/probe.content/probe.woff2 (100%) rename tests/integration/browser/content/{shadow-styles => isolation-iframe}/src/probe.content/styles.module.css (100%) create mode 100644 tests/integration/browser/content/isolation-iframe/src/secondary.content/index.ts rename tests/integration/browser/content/{shadow-styles => isolation-iframe}/src/secondary.content/lazy.module.css (100%) rename tests/integration/browser/content/{shadow-styles => isolation-iframe}/src/secondary.content/lazy.ts (68%) rename tests/integration/browser/content/{shadow-styles => isolation-iframe}/src/secondary.content/styles.module.css (100%) create mode 100644 tests/integration/browser/content/isolation-iframe/src/shared/events.ts rename tests/integration/browser/content/{shadow-styles => isolation-iframe}/src/shared/styles.module.css (100%) create mode 100644 tests/integration/browser/content/isolation-iframe/tsconfig.json rename tests/integration/browser/content/{shadow-styles.firefox.integration.test.ts => isolation-shadow.firefox.integration.test.ts} (61%) rename tests/integration/browser/content/{shadow-styles.integration.test.ts => isolation-shadow.integration.test.ts} (58%) rename tests/integration/browser/content/{shadow-styles => isolation-shadow}/adnbn.config.ts (93%) rename tests/integration/browser/content/{shadow-styles => isolation-shadow}/font.py (100%) create mode 100644 tests/integration/browser/content/isolation-shadow/package.json create mode 100644 tests/integration/browser/content/isolation-shadow/site/child.html create mode 100644 tests/integration/browser/content/isolation-shadow/site/frames.html create mode 100644 tests/integration/browser/content/isolation-shadow/site/top.html create mode 100644 tests/integration/browser/content/isolation-shadow/src/normal.content.ts create mode 100644 tests/integration/browser/content/isolation-shadow/src/probe.content/fonts.css rename tests/integration/browser/content/{shadow-styles => isolation-shadow}/src/probe.content/index.ts (86%) create mode 100644 tests/integration/browser/content/isolation-shadow/src/probe.content/lazy.module.css create mode 100644 tests/integration/browser/content/isolation-shadow/src/probe.content/lazy.ts create mode 100644 tests/integration/browser/content/isolation-shadow/src/probe.content/probe.woff2 create mode 100644 tests/integration/browser/content/isolation-shadow/src/probe.content/styles.module.css rename tests/integration/browser/content/{shadow-styles => isolation-shadow}/src/secondary.content/index.ts (87%) create mode 100644 tests/integration/browser/content/isolation-shadow/src/secondary.content/lazy.module.css create mode 100644 tests/integration/browser/content/isolation-shadow/src/secondary.content/lazy.ts create mode 100644 tests/integration/browser/content/isolation-shadow/src/secondary.content/styles.module.css create mode 100644 tests/integration/browser/content/isolation-shadow/src/shared/styles.module.css rename tests/integration/browser/content/{shadow-styles => isolation-shadow}/tsconfig.json (100%) create mode 100644 tests/integration/browser/content/isolation-spike-utils.ts create mode 100644 tests/integration/browser/content/isolation-spike.firefox.integration.test.ts create mode 100644 tests/integration/browser/content/isolation-spike.integration.test.ts create mode 100644 tests/integration/browser/content/isolation-spike/site/allowed.html create mode 100644 tests/integration/browser/content/isolation-spike/site/csp-blocked.html create mode 100644 tests/integration/browser/content/isolation-spike/site/page.js create mode 100644 tests/integration/browser/content/isolation-spike/site/top.html create mode 100644 tests/integration/browser/content/isolation-spike/site/xfo-blocked.html create mode 100644 tests/integration/browser/content/isolation-spike/src/embed.ts create mode 100644 tests/integration/browser/content/isolation-spike/src/initial.css create mode 100644 tests/integration/browser/content/isolation-spike/src/lazy.css create mode 100644 tests/integration/browser/content/isolation-spike/src/panel.html create mode 100644 tests/integration/browser/content/isolation-spike/src/panel.js create mode 100644 tests/integration/browser/content/isolation-spike/src/probe.tsx create mode 100644 tests/integration/browser/content/relay-styles.integration.test.ts create mode 100644 tests/integration/browser/content/relay-styles/adnbn.config.ts create mode 100644 tests/integration/browser/content/relay-styles/package.json create mode 100644 tests/integration/browser/content/relay-styles/site/top.html create mode 100644 tests/integration/browser/content/relay-styles/src/background.ts create mode 100644 tests/integration/browser/content/relay-styles/src/control.page/index.ts create mode 100644 tests/integration/browser/content/relay-styles/src/control.relay.ts create mode 100644 tests/integration/browser/content/relay-styles/src/iframe.relay.ts create mode 100644 tests/integration/browser/content/relay-styles/src/shadow.relay.ts create mode 100644 tests/integration/browser/content/relay-styles/src/shared/host-lazy.css create mode 100644 tests/integration/browser/content/relay-styles/src/shared/host.css create mode 100644 tests/integration/browser/content/relay-styles/src/shared/lazy.module.css create mode 100644 tests/integration/browser/content/relay-styles/src/shared/lazy.ts create mode 100644 tests/integration/browser/content/relay-styles/src/shared/panel.module.css create mode 100644 tests/integration/browser/content/relay-styles/src/shared/panel.ts create mode 100644 tests/integration/browser/content/relay-styles/tsconfig.json create mode 100644 tests/integration/browser/utils/session.ts create mode 100644 tests/integration/build/isolation/isolation-watch.integration.test.ts create mode 100644 tests/integration/build/isolation/states/fonts.css create mode 100644 tests/integration/build/isolation/states/fonts.ts create mode 100644 tests/integration/build/isolation/states/iframe.ts create mode 100644 tests/integration/build/isolation/states/none.ts create mode 100644 tests/integration/build/isolation/states/page-isolated.ts create mode 100644 tests/integration/build/isolation/states/page.ts create mode 100644 tests/integration/build/isolation/states/renamed.ts create mode 100644 tests/integration/build/isolation/states/shadow-document.ts create mode 100644 tests/integration/build/isolation/states/shadow.ts create mode 100644 tests/integration/build/isolation/states/source.ts create mode 100644 tests/integration/build/relay-runtime/relay-runtime.integration.test.ts create mode 100644 tests/integration/build/relay-runtime/states/background.ts create mode 100644 tests/integration/build/relay-runtime/states/messaging.ts create mode 100644 tests/integration/build/relay-runtime/states/scripting.ts create mode 100644 tests/integration/build/style-routing/routing-spike.integration.test.ts create mode 100644 tests/integration/build/style-routing/src/assets.js create mode 100644 tests/integration/build/style-routing/src/background.js create mode 100644 tests/integration/build/style-routing/src/entry.js create mode 100644 tests/integration/build/style-routing/src/lazy-page.css create mode 100644 tests/integration/build/style-routing/src/lazy-panel.css create mode 100644 tests/integration/build/style-routing/src/lazy.js create mode 100644 tests/integration/build/style-routing/src/nested.css create mode 100644 tests/integration/build/style-routing/src/other-assets.js create mode 100644 tests/integration/build/style-routing/src/package.json create mode 100644 tests/integration/build/style-routing/src/page.css create mode 100644 tests/integration/build/style-routing/src/panel.css create mode 100644 tests/integration/build/style-routing/src/resources/code.js create mode 100644 tests/integration/build/style-routing/src/resources/css-font.woff2 create mode 100644 tests/integration/build/style-routing/src/resources/data.json create mode 100644 tests/integration/build/style-routing/src/resources/direct.woff2 create mode 100644 tests/integration/build/style-routing/src/resources/document.custom create mode 100644 tests/integration/build/style-routing/src/resources/image.svg create mode 100644 tests/integration/build/style-routing/src/resources/inline.woff2 create mode 100644 tests/integration/build/style-routing/src/resources/movie.mp4 create mode 100644 tests/integration/build/style-routing/src/resources/styles.css create mode 100644 tests/integration/build/style-routing/src/shared.js create mode 100644 tests/integration/build/style-routing/style-routing.integration.test.ts diff --git a/jest.config.ts b/jest.config.ts index 7dd46e95..d5fad946 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -4,6 +4,7 @@ const config: Config = { verbose: true, testEnvironment: "jsdom", setupFiles: ["/tests/jest.setup.ts"], + modulePathIgnorePatterns: ["/.cache/"], resolver: "/tests/raw-module-resolver.cjs", moduleNameMapper: { "^@cli/(.*)$": "/src/cli/$1", @@ -15,6 +16,7 @@ const config: Config = { "^@relay/(.*)$": "/src/relay/$1", "^@sandbox/(.*)$": "/src/sandbox/$1", "^@service/(.*)$": "/src/service/$1", + "^@shared/(.*)$": "/src/shared/$1", "^@storage/(.*)$": "/src/storage/$1", "^@transport/(.*)$": "/src/transport/$1", "^@main/(.*)$": "/src/main/$1", diff --git a/package-lock.json b/package-lock.json index 60a81a26..9736f37f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "pluralize": "^8.0.0", "postcss": "^8.5.15", "postcss-scss": "^4.0.9", + "postcss-value-parser": "^4.2.0", "rspack-plugin-virtual-module": "^1.0.1", "sass": "^1.83.4", "sass-loader": "^16.0.4", diff --git a/package.json b/package.json index 4594f26e..df1a5507 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,10 @@ "types": "./dist/entry/*/index.d.ts", "default": "./dist/entry/*/index.js" }, + "./entry/content/frame": { + "types": "./dist/entry/content/frame/index.d.ts", + "default": "./dist/entry/content/frame/index.js" + }, "./entry/content/*": { "types": "./dist/entry/content/adapters/*/index.d.ts", "default": "./dist/entry/content/adapters/*/index.js" @@ -157,6 +161,7 @@ "pluralize": "^8.0.0", "postcss": "^8.5.15", "postcss-scss": "^4.0.9", + "postcss-value-parser": "^4.2.0", "rspack-plugin-virtual-module": "^1.0.1", "sass": "^1.83.4", "sass-loader": "^16.0.4", diff --git a/src/cli/builders/manifest/ManifestBase.ts b/src/cli/builders/manifest/ManifestBase.ts index 5c9e7ec9..80027b2c 100644 --- a/src/cli/builders/manifest/ManifestBase.ts +++ b/src/cli/builders/manifest/ManifestBase.ts @@ -428,7 +428,7 @@ export default abstract class implements ManifestBuilder } public build(): T { - return this.merge( + const manifest = this.merge( this.buildName(), this.buildShortName(), this.buildDescription(), @@ -456,6 +456,8 @@ export default abstract class implements ManifestBuilder this.buildBrowserSpecificSettings(), this.buildRaw() ) as T; + + return manifest; } public get(): T { @@ -601,7 +603,6 @@ export default abstract class implements ManifestBuilder world, matchAboutBlank, matchOriginAsFallback, - shadow, } = script; const dependencies = this.dependencies.get(entry); @@ -611,7 +612,7 @@ export default abstract class implements ManifestBuilder } const js = Array.from(dependencies.js); - const css = shadow ? [] : Array.from(dependencies.css); + const css = Array.from(dependencies.css); if (js.length === 0 && css.length === 0) { throw new ManifestError(`Content script and style entry "${entry}" not found in dependencies`); @@ -816,9 +817,7 @@ export default abstract class implements ManifestBuilder for (const contentScript of this.contentScripts.values()) { const dependencies = this.dependencies.get(contentScript.entry); - const assets = dependencies - ? new Set([...dependencies.assets, ...(contentScript.shadow ? dependencies.css : [])]) - : undefined; + const assets = dependencies?.assets; if (assets && assets.size > 0) { resources.push({ diff --git a/src/cli/builders/manifest/ManifestV3.test.ts b/src/cli/builders/manifest/ManifestV3.test.ts index 9c51d02d..a2819d8a 100644 --- a/src/cli/builders/manifest/ManifestV3.test.ts +++ b/src/cli/builders/manifest/ManifestV3.test.ts @@ -101,17 +101,17 @@ describe("ManifestV3", () => { }); }); - it("keeps shadow initial CSS out of content_scripts and exposes it through WAR", () => { + it("uses prepared CSS lists and exposes runtime resources without interpreting their delivery policy", () => { const manifest: any = new ManifestV3(Browser.Chrome) .setDependencies( new Map([ - ["shadow", dependency(["shadow.js"], ["shared.css", "shadow.css"], ["lazy.css"])], + ["shadow", dependency(["shadow.js"], ["page.css"], ["lazy.css", "shared.css", "shadow.css"])], ["normal", dependency(["normal.js"], ["shared.css"], ["normal-lazy.css"])], ]) ) .setContentScripts( new Set([ - {entry: "shadow", matches: ["https://example.com/*"], shadow: true}, + {entry: "shadow", matches: ["https://example.com/*"]}, {entry: "normal", matches: ["https://example.com/*"]}, ]) ) @@ -119,14 +119,13 @@ describe("ManifestV3", () => { expect(manifest.content_scripts).toEqual( expect.arrayContaining([ - expect.objectContaining({js: ["shadow.js"], css: undefined}), + expect.objectContaining({js: ["shadow.js"], css: ["page.css"]}), expect.objectContaining({js: ["normal.js"], css: ["shared.css"]}), ]) ); expect(manifest.web_accessible_resources[0].resources).toEqual( expect.arrayContaining(["lazy.css", "normal-lazy.css", "shared.css", "shadow.css"]) ); - expect(manifest.content_scripts[0]).not.toHaveProperty("shadow"); }); it("builds permissions separately from host permissions", () => { diff --git a/src/cli/bundler/plugins/index.ts b/src/cli/bundler/plugins/index.ts index 6fcb42da..0610d4c9 100644 --- a/src/cli/bundler/plugins/index.ts +++ b/src/cli/bundler/plugins/index.ts @@ -11,7 +11,11 @@ export { export {default as ChunkLoaderPlugin, type ChunkLoaderPluginOptions} from "./chunk-loader"; -export {default as ShadowStylesPlugin, type ShadowStylesPluginOptions} from "./shadow-styles"; +export { + default as IsolatedStylesPlugin, + type IsolatedStylesPluginOptions, + type IsolatedStylesPluginFiles, +} from "./isolated-styles"; export {default as BuildAssetsMapPlugin, type BuildAssetsMapPluginOptions} from "./build-assets-map"; diff --git a/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts b/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts new file mode 100644 index 00000000..f4a9fca6 --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts @@ -0,0 +1,907 @@ +/** @jest-environment node */ + +import fs from "fs"; +import os from "os"; +import path from "path"; +import vm from "vm"; + +import {type Compiler, CssExtractRspackPlugin, type Filename, rspack, type Stats} from "@rspack/core"; + +import {IsolatedStylesLayer} from "@cli/bundler/utils/styles"; + +import IsolatedStylesPlugin, { + type IsolatedStylesPluginOptions, + type IsolatedStylesPluginFiles, +} from "./IsolatedStylesPlugin"; + +const fixtures = path.resolve(__dirname, "tests", "fixtures"); +const projectRoot = path.resolve(__dirname, "../../../../.."); +const RuntimeProperty = "fixtureStyles"; + +interface BuildResult { + readonly property: string; + readonly close: () => Promise; + readonly compiler: Compiler; + readonly outputPath: string; + readonly stats: Stats; +} + +interface FakeLink { + href: string; + onerror: ((event: {type: string}) => void) | null; + onload: (() => void) | null; + rel: string; + type: string; + remove(): void; +} + +interface FakeRoot { + ownerDocument: {createElement(tag: string): FakeLink}; + readonly links: FakeLink[]; + insertBefore(link: FakeLink, container: object): FakeLink; +} + +interface RuntimeApi { + initialize(resolveUrl: (file: string) => string): void; + add(root: FakeRoot, target: object, retry?: boolean): void; + delete(root: FakeRoot): void; + load(url: string): Promise; +} + +interface RuntimeHarness { + readonly requestedScripts: string[]; + readonly runtime: RuntimeApi; + readonly sandbox: Record; +} + +const closeCompiler = (compiler: Compiler): Promise => { + return new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); +}; + +const runCompiler = (compiler: Compiler): Promise => { + return new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) { + reject(error); + } else if (!stats) { + reject(new Error("Rspack did not return build stats")); + } else if (stats.hasErrors()) { + reject(new Error(stats.toString({all: false, errors: true, errorDetails: true}))); + } else { + resolve(stats); + } + }); + }); +}; + +interface CompileOptions { + validate?: IsolatedStylesPluginOptions["validate"]; + ignoreWarnings?: RegExp[]; + property?: string; + shadowEntry?: string; + context?: string; + filename?: Filename; + cssFilename?: Filename; + sharedInitial?: boolean; + mode?: "development" | "production"; + selected?: (entry: string) => boolean; + timeout?: number; +} + +const createCompiler = (outputPath: string, options: CompileOptions = {}): Compiler => { + const filename = options.filename ?? "js/[name].[contenthash:8].js"; + + const cssFilename = options.cssFilename ?? "css/[name].[contenthash:8].css"; + + return rspack({ + context: options.context ?? fixtures, + mode: options.mode ?? "development", + target: ["web", "es2020"], + devtool: false, + ignoreWarnings: options.ignoreWarnings, + entry: { + shadow: options.shadowEntry ?? (options.sharedInitial ? "./shadow-shared.js" : "./shadow.js"), + normal: options.sharedInitial ? "./normal-shared.js" : "./normal.js", + }, + output: { + path: outputPath, + clean: true, + filename, + chunkFilename: filename, + publicPath: "https://extension.test/", + globalObject: "globalThis", + uniqueName: "isolatedStylesFixture", + }, + resolveLoader: { + modules: [path.resolve(projectRoot, "node_modules"), "node_modules"], + }, + module: { + rules: [ + {test: /shadow.*\.css$/, layer: IsolatedStylesLayer}, + { + test: /\.css$/i, + sideEffects: true, + type: "javascript/auto", + use: [ + CssExtractRspackPlugin.loader, + { + loader: "css-loader", + options: {esModule: true, modules: false}, + }, + ], + }, + ], + }, + optimization: { + minimize: false, + runtimeChunk: false, + splitChunks: options.sharedInitial + ? { + cacheGroups: { + sharedStyles: { + type: "css/mini-extract", + name: "shared-styles", + chunks: "all", + minChunks: 2, + enforce: true, + }, + }, + } + : false, + }, + plugins: [ + new CssExtractRspackPlugin({ + filename: cssFilename, + chunkFilename: cssFilename, + }), + new IsolatedStylesPlugin({ + cssFilename, + cssChunkFilename: cssFilename, + property: options.property ?? RuntimeProperty, + validate: options.validate, + test: options.selected ?? (entry => entry === "shadow"), + timeout: options.timeout ?? 1_000, + }), + ], + }); +}; + +const compile = async (options: CompileOptions = {}): Promise => { + const outputPath = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-isolated-styles-")); + const compiler = createCompiler(outputPath, options); + + try { + const stats = await runCompiler(compiler); + + return { + property: options.property ?? RuntimeProperty, + close: async () => { + await closeCompiler(compiler); + fs.rmSync(outputPath, {force: true, recursive: true}); + }, + compiler, + outputPath, + stats, + }; + } catch (error) { + await closeCompiler(compiler); + fs.rmSync(outputPath, {force: true, recursive: true}); + throw error; + } +}; + +test.each(["./shadow.js", "./shadow-async.js"])( + "reports initial and async CSS independently of runtime selection (%s)", + async shadowEntry => { + const validated = new Map(); + const result = await compile({ + shadowEntry, + selected: () => false, + validate: (entry, files) => { + validated.set(entry, files); + }, + }); + await result.close(); + expect([...validated.keys()].sort()).toEqual(["normal", "shadow"]); + expect(validated.get("normal")!.isolated).toEqual([]); + expect(validated.get("normal")!.document).toEqual([ + expect.stringMatching(/normal-lazy\./), + expect.stringMatching(/normal\./), + ]); + expect(validated.get("shadow")!.document).toEqual([]); + expect(validated.get("shadow")!.isolated).toHaveLength(shadowEntry === "./shadow.js" ? 2 : 1); + + await expect( + compile({ + shadowEntry, + selected: () => false, + validate: (entry, files) => { + if (files.isolated.length) throw new Error(`Consumer policy rejects styles for "${entry}"`); + }, + }) + ).rejects.toThrow('Consumer policy rejects styles for "shadow"'); + } +); + +test("reports shared styles to both entries without duplicate filenames", async () => { + const validated = new Map(); + const result = await compile({ + sharedInitial: true, + validate: (entry, files) => { + validated.set(entry, files); + }, + }); + try { + const shared = validated.get("shadow")!.isolated.find(file => /shared-styles\./.test(file)); + expect(shared).toBeDefined(); + expect(validated.get("normal")!.isolated).toEqual([shared]); + expect(validated.get("shadow")!.isolated).toHaveLength(2); + } finally { + await result.close(); + } +}); + +test("refreshes warnings between compilations and supports the bundler warning filter", async () => { + let warn = true; + const message = '[fixture:missing-isolation-css] Entrypoint "shadow"'; + const validate: IsolatedStylesPluginOptions["validate"] = (entry, files) => + warn && entry === "shadow" && files.document.length > 0 && files.isolated.length === 0 ? [message] : []; + const result = await compile({shadowEntry: "./normal.js", validate}); + try { + expect(result.stats.compilation.warnings.map(warning => warning.message)).toEqual([ + expect.stringContaining(message), + ]); + const filenames = result.stats.compilation + .getAssets() + .map(asset => asset.name) + .sort(); + warn = false; + const rebuilt = await runCompiler(result.compiler); + expect(rebuilt.compilation.warnings).toHaveLength(0); + expect( + rebuilt.compilation + .getAssets() + .map(asset => asset.name) + .sort() + ).toEqual(filenames); + warn = true; + const repeated = await runCompiler(result.compiler); + expect(repeated.compilation.warnings.map(warning => warning.message)).toEqual([ + expect.stringContaining(message), + ]); + } finally { + await result.close(); + } + const suppressed = await compile({ + shadowEntry: "./normal.js", + validate, + ignoreWarnings: [/fixture:missing-isolation-css/], + }); + try { + expect(suppressed.stats.toJson({all: false, warnings: true}).warnings).toEqual([]); + } finally { + await suppressed.close(); + } +}); + +const entryFile = (stats: Stats, entry: string, extension: ".css" | ".js"): string => { + const file = stats.compilation.entrypoints + .get(entry) + ?.getFiles() + .find(candidate => candidate.endsWith(extension)); + + if (!file) { + throw new Error(`${extension} file for entrypoint ${entry} was not found`); + } + + return file; +}; + +const source = (stats: Stats, file: string): string => { + const asset = stats.compilation.getAsset(file); + + if (!asset) { + throw new Error(`Asset ${file} was not found`); + } + + return asset.source.source().toString(); +}; + +const createRoot = (onInsert: (link: FakeLink) => void): FakeRoot => { + const links: FakeLink[] = []; + + return { + ownerDocument: {createElement: () => ({href: "", rel: "", type: "", onload: null, onerror: null, remove() {}})}, + links, + insertBefore(link, target) { + const position = links.indexOf(target as FakeLink); + if (position < 0) links.push(link); + else links.splice(position, 0, link); + link.remove = () => { + const index = links.indexOf(link); + + if (index >= 0) links.splice(index, 1); + }; + onInsert(link); + + return link; + }, + }; +}; + +const executeShadowEntrypoint = (result: BuildResult): RuntimeHarness => { + const requestedScripts: string[] = []; + let context: vm.Context; + const sandbox: Record = { + clearTimeout, + console, + Promise, + setTimeout, + }; + + const element = (tagName: string): Record => { + const attributes = new Map(); + + return { + tagName: tagName.toUpperCase(), + getAttribute: (name: string) => attributes.get(name) ?? null, + setAttribute: (name: string, value: string) => attributes.set(name, value), + }; + }; + + const head = { + appendChild(node: Record) { + node.parentNode = head; + + if (node.tagName === "SCRIPT") { + const url = String(node.src); + const filename = url.replace("https://extension.test/", ""); + requestedScripts.push(filename); + vm.runInContext(fs.readFileSync(path.join(result.outputPath, filename), "utf8"), context); + queueMicrotask(() => node.onload?.({target: node, type: "load"})); + } else { + queueMicrotask(() => node.onload?.({target: node, type: "load"})); + } + + return node; + }, + removeChild(node: Record) { + node.parentNode = undefined; + + return node; + }, + }; + + sandbox.document = { + createElement: element, + getElementsByTagName: () => [], + head, + }; + sandbox.globalThis = sandbox; + sandbox.self = sandbox; + context = vm.createContext(sandbox); + + vm.runInContext(source(result.stats, entryFile(result.stats, "shadow", ".js")), context); + + const getRuntime = sandbox.getStylesRuntime as ((property: string) => RuntimeApi) | undefined; + + if (!getRuntime) { + throw new Error("Shadow fixture did not expose the style runtime"); + } + + const runtime = getRuntime(result.property); + runtime.initialize(file => `https://extension.test/${file}`); + return {requestedScripts, runtime, sandbox}; +}; + +test("replaces the CSS loading runtime only for selected entrypoints", async () => { + const result = await compile(); + + try { + const shadow = source(result.stats, entryFile(result.stats, "shadow", ".js")); + const normal = source(result.stats, entryFile(result.stats, "normal", ".js")); + + expect(shadow).toContain(RuntimeProperty); + expect(shadow).toContain("isolatedStyleRoots"); + expect(shadow).toContain("loadDocumentStylesheet"); + expect(normal).not.toContain(RuntimeProperty); + expect(normal).toContain("document.head.appendChild(linkTag)"); + } finally { + await result.close(); + } +}); + +test("uses a literal custom property for both the style registry and async CSS loader", async () => { + const result = await compile({property: 'styles.$&__ADNBN_REQUIRE__"\\\n'}); + try { + const harness = executeShadowEntrypoint(result); + const root = createRoot(link => queueMicrotask(() => link.onload?.())); + harness.runtime.add(root, {}); + await (harness.sandbox.loadIsolatedStyles as () => Promise)(); + expect(root.links.length).toBeGreaterThan(0); + harness.runtime.delete(root); + } finally { + await result.close(); + } +}); + +test("loads initial and async CSS into every active root and keeps requested styles for late roots", async () => { + const result = await compile(); + + try { + const harness = executeShadowEntrypoint(result); + const initialUrl = `https://extension.test/${entryFile(result.stats, "shadow", ".css")}`; + const inserted: string[] = []; + const rootA = createRoot(link => { + inserted.push(`a:${link.href}`); + queueMicrotask(() => link.onload?.()); + }); + const rootB = createRoot(link => { + inserted.push(`b:${link.href}`); + queueMicrotask(() => link.onload?.()); + }); + + harness.runtime.add(rootA, {}); + harness.runtime.add(rootB, {}); + await (harness.sandbox.loadIsolatedStyles as () => Promise)(); + + const lazyCss = result.stats.compilation + .getAssets() + .map(asset => asset.name) + .find(file => file.includes("shadow-lazy") && file.endsWith(".css")); + + expect(lazyCss).toBeDefined(); + expect(inserted).toEqual( + expect.arrayContaining([ + `a:${initialUrl}`, + `b:${initialUrl}`, + `a:https://extension.test/${lazyCss}`, + `b:https://extension.test/${lazyCss}`, + ]) + ); + expect(rootA.links).toHaveLength(2); + expect(rootB.links).toHaveLength(2); + + const lateRoot = createRoot(link => queueMicrotask(() => link.onload?.())); + harness.runtime.add(lateRoot, {}); + await Promise.resolve(); + await Promise.resolve(); + + expect(lateRoot.links.map(link => link.href)).toEqual([initialUrl, `https://extension.test/${lazyCss}`]); + expect(harness.requestedScripts).toEqual([expect.stringMatching(/shadow-lazy.*\.js$/)]); + } finally { + await result.close(); + } +}); + +test("does not wait for a future root when import happens before rendering", async () => { + const result = await compile(); + + try { + const harness = executeShadowEntrypoint(result); + + await expect((harness.sandbox.loadIsolatedStyles as () => Promise)()).resolves.toMatchObject({ + shadowLazy: true, + }); + + const root = createRoot(link => queueMicrotask(() => link.onload?.())); + harness.runtime.add(root, {}); + await Promise.resolve(); + await Promise.resolve(); + + expect(root.links).toHaveLength(2); + expect(root.links[0].href).toBe(`https://extension.test/${entryFile(result.stats, "shadow", ".css")}`); + expect(root.links[1].href).toContain("shadow-lazy"); + } finally { + await result.close(); + } +}); + +test("reports an initial stylesheet failure without removing the root and permits retry", async () => { + const result = await compile(); + const report = jest.spyOn(console, "error").mockImplementation(() => {}); + + try { + const harness = executeShadowEntrypoint(result); + const initialUrl = `https://extension.test/${entryFile(result.stats, "shadow", ".css")}`; + let attempt = 0; + const root = createRoot(link => { + attempt += 1; + queueMicrotask(() => { + if (attempt === 1) { + link.onerror?.({type: "error"}); + } else { + link.onload?.(); + } + }); + }); + + harness.runtime.add(root, {}); + await new Promise(resolve => setImmediate(resolve)); + + expect(report).toHaveBeenCalledWith( + expect.objectContaining({ + code: "CSS_CHUNK_LOAD_FAILED", + request: initialUrl, + type: "error", + }) + ); + expect(root.links).toHaveLength(0); + + await expect(harness.runtime.load(initialUrl)).resolves.toBeUndefined(); + expect(root.links.map(link => link.href)).toEqual([initialUrl]); + } finally { + report.mockRestore(); + await result.close(); + } +}); + +test("retries restored styles once in cascade order, sharing the pending promise but not retrying future imports", async () => { + const result = await compile({sharedInitial: true}); + const report = jest.spyOn(console, "error").mockImplementation(() => {}); + try { + const {runtime} = executeShadowEntrypoint(result); + const remembered = "https://extension.test/css/remembered.css"; + await runtime.load(remembered); + const inserted: FakeLink[] = []; + const root = createRoot(link => inserted.push(link)); + runtime.add(root, {}, true); + const original = [...root.links]; + expect(original).toHaveLength(3); + const pending = Promise.all(original.map(link => runtime.load(link.href))); + let resolved = false; + void pending.then( + () => { + resolved = true; + }, + () => {} + ); + for (const link of original) link.onerror?.({type: "error"}); + expect(inserted).toHaveLength(6); + expect(root.links.map(link => link.href)).toEqual(original.map(link => link.href)); + expect(root.links.every(link => !original.includes(link))).toBe(true); + await Promise.resolve(); + expect(resolved).toBe(false); + for (const link of root.links) link.onload?.(); + await pending; + expect(report).not.toHaveBeenCalled(); + + const future = runtime.load("https://extension.test/css/future.css"); + const failure = expect(future).rejects.toMatchObject({type: "error"}); + root.links.at(-1)!.onerror?.({type: "error"}); + await failure; + expect(inserted).toHaveLength(7); + runtime.delete(root); + } finally { + report.mockRestore(); + await result.close(); + } +}); + +test("reports a second recovery failure and does not resurrect links through stale callbacks", async () => { + const result = await compile(); + const report = jest.spyOn(console, "error").mockImplementation(() => {}); + try { + const {runtime} = executeShadowEntrypoint(result); + const root = createRoot(() => {}); + runtime.add(root, {}, true); + const first = root.links[0]; + const staleError = first.onerror!; + const pending = runtime.load(first.href); + const failure = expect(pending).rejects.toMatchObject({ + code: "CSS_CHUNK_LOAD_FAILED", + type: "error", + request: first.href, + }); + first.onerror?.({type: "error"}); + const retry = root.links[0]; + expect(retry).not.toBe(first); + staleError({type: "error"}); + expect(root.links).toEqual([retry]); + retry.onerror?.({type: "error"}); + await failure; + expect(root.links).toHaveLength(0); + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith(expect.objectContaining({message: expect.stringContaining("shadow")})); + staleError({type: "error"}); + expect(root.links).toHaveLength(0); + runtime.delete(root); + } finally { + report.mockRestore(); + await result.close(); + } +}); + +test("keeps one timeout budget across recovery attempts and cancels work when a root is removed", async () => { + const result = await compile({timeout: 100}); + const report = jest.spyOn(console, "error").mockImplementation(() => {}); + jest.useFakeTimers(); + try { + const {runtime} = executeShadowEntrypoint(result); + const inserted: FakeLink[] = []; + const root = createRoot(link => inserted.push(link)); + runtime.add(root, {}, true); + const initial = root.links[0]; + const pending = runtime.load(initial.href); + const failure = expect(pending).rejects.toMatchObject({type: "timeout", request: initial.href}); + jest.advanceTimersByTime(75); + initial.onerror?.({type: "error"}); + expect(root.links[0]).not.toBe(initial); + jest.advanceTimersByTime(25); + await failure; + expect(root.links).toHaveLength(0); + expect(inserted).toHaveLength(2); + expect(jest.getTimerCount()).toBe(0); + runtime.delete(root); + + // A timeout is final even when recovery permits a retry on a load error. + const stalledLinks: FakeLink[] = []; + const stalled = createRoot(link => stalledLinks.push(link)); + runtime.add(stalled, {}, true); + const timedOut = expect(runtime.load(stalled.links[0].href)).rejects.toMatchObject({type: "timeout"}); + jest.advanceTimersByTime(100); + await timedOut; + expect(stalledLinks).toHaveLength(1); + expect(stalled.links).toHaveLength(0); + runtime.delete(stalled); + + report.mockClear(); + for (const afterRetry of [false, true]) { + const removed = createRoot(() => {}); + runtime.add(removed, {}, true); + const waiting = runtime.load(removed.links[0].href); + if (afterRetry) removed.links[0].onerror?.({type: "error"}); + const lastLink = removed.links[0]; + const lateError = lastLink.onerror!; + runtime.delete(removed); + lateError({type: "error"}); + await waiting; + expect(removed.links).toHaveLength(0); + expect(lastLink.onerror).toBeNull(); + expect(jest.getTimerCount()).toBe(0); + } + expect(report).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + report.mockRestore(); + await result.close(); + } +}); + +test("rejects failed and timed out styles, removes failed links and permits retry", async () => { + const result = await compile({timeout: 20, shadowEntry: "./shadow-async.js"}); + + try { + const harness = executeShadowEntrypoint(result); + let attempt = 0; + const root = createRoot(link => { + attempt += 1; + + if (attempt === 1) queueMicrotask(() => link.onerror?.({type: "error"})); + if (attempt === 2) queueMicrotask(() => link.onload?.()); + }); + harness.runtime.add(root, {}); + + await expect((harness.sandbox.loadIsolatedStyles as () => Promise)()).rejects.toThrow( + /shadow[\s\S]*https:\/\/extension\.test\/css\/shadow-lazy/i + ); + expect(root.links).toHaveLength(0); + await expect((harness.sandbox.loadIsolatedStyles as () => Promise)()).resolves.toMatchObject({ + shadowLazy: true, + }); + + harness.runtime.delete(root); + const timedOut = createRoot(link => { + if (!link.href.endsWith("/hanging.css")) queueMicrotask(() => link.onload?.()); + }); + harness.runtime.add(timedOut, {}); + + await expect(harness.runtime.load("https://extension.test/css/hanging.css")).rejects.toMatchObject({ + type: "timeout", + request: "https://extension.test/css/hanging.css", + }); + expect(timedOut.links.map(link => link.href)).not.toContain("https://extension.test/css/hanging.css"); + } finally { + await result.close(); + } +}); + +test("settles pending work when a root is removed and does not duplicate successful links", async () => { + const result = await compile({shadowEntry: "./shadow-async.js"}); + + try { + const harness = executeShadowEntrypoint(result); + const root = createRoot(() => undefined); + harness.runtime.add(root, {}); + const pending = harness.runtime.load("https://extension.test/css/pending.css"); + + harness.runtime.delete(root); + + await expect(pending).resolves.toBeUndefined(); + await harness.runtime.load("https://extension.test/css/pending.css"); + expect(root.links).toHaveLength(0); + harness.runtime.add(root, {}); + expect(root.links).toHaveLength(1); + root.links[0].onload?.(); + await harness.runtime.load("https://extension.test/css/pending.css"); + expect(root.links).toHaveLength(1); + } finally { + await result.close(); + } +}); + +test.each(["contenthash", "chunkhash", "fullhash"])( + "resolves initial CSS with user %s filenames without the asset map plugin", + async hash => { + const result = await compile({ + filename: `js/[name].[${hash}:8].js`, + cssFilename: `css/[name].[${hash}:8].css`, + mode: "production", + }); + + try { + expect(entryFile(result.stats, "shadow", ".js")).toMatch(/^js\/shadow\.[a-f0-9]{8}\.js$/); + expect(source(result.stats, entryFile(result.stats, "shadow", ".js"))).toContain(RuntimeProperty); + const harness = executeShadowEntrypoint(result); + const root = createRoot(link => queueMicrotask(() => link.onload?.())); + harness.runtime.add(root, {}); + expect(root.links.map(link => link.href)).toEqual([ + `https://extension.test/${entryFile(result.stats, "shadow", ".css")}`, + ]); + harness.runtime.delete(root); + } finally { + await result.close(); + } + } +); + +test("owns multiple initial files in bundler order, including a shared CSS chunk, and initializes once", async () => { + const result = await compile({sharedInitial: true}); + try { + const harness = executeShadowEntrypoint(result); + const resolveAgain = jest.fn(); + harness.runtime.initialize(resolveAgain); + expect(resolveAgain).not.toHaveBeenCalled(); + const root = createRoot(link => queueMicrotask(() => link.onload?.())); + harness.runtime.add(root, {}); + const initial = result.stats.compilation.entrypoints + .get("shadow")! + .getFiles() + .filter(file => file.endsWith(".css")); + expect(initial).toHaveLength(2); + expect(root.links.map(link => link.href)).toEqual(initial.map(file => `https://extension.test/${file}`)); + const normal = result.stats.compilation.entrypoints.get("normal")!.getFiles(); + expect(initial.some(file => normal.includes(file))).toBe(true); + harness.runtime.delete(root); + } finally { + await result.close(); + } +}); + +test.each(["contenthash", "chunkhash", "fullhash", "callback"])( + "refreshes initial CSS URLs after editing CSS across %s rebuilds", + async hash => { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-initial-styles-")); + const context = path.join(temporaryRoot, "src"); + const outputPath = path.join(temporaryRoot, "dist"); + fs.cpSync(fixtures, context, {recursive: true}); + const cssFilename: Filename = + hash === "callback" ? () => "custom/[name].[contenthash:8].css" : `css/[name].[${hash}:8].css`; + const compiler = createCompiler(outputPath, {context, cssFilename, mode: "production"}); + try { + const filenames: string[] = []; + for (const color of ["red", "green"]) { + fs.writeFileSync(path.join(context, "shadow.css"), `.shadow-initial { color: ${color}; }`); + const stats = await runCompiler(compiler); + const filename = entryFile(stats, "shadow", ".css"); + filenames.push(filename); + expect(source(stats, filename)).toContain(color); + const harness = executeShadowEntrypoint({ + property: RuntimeProperty, + compiler, + outputPath, + stats, + close: async () => {}, + }); + const root = createRoot(link => queueMicrotask(() => link.onload?.())); + harness.runtime.add(root, {}); + expect(root.links.map(link => link.href)).toEqual([`https://extension.test/${filename}`]); + harness.runtime.delete(root); + } + // Rspack can keep a CSS [chunkhash] name for a CSS-only edit; the runtime must match its emitted file. + if (hash !== "chunkhash") expect(filenames[0]).not.toBe(filenames[1]); + } finally { + await closeCompiler(compiler); + fs.rmSync(temporaryRoot, {recursive: true, force: true}); + } + } +); + +test("supports callback filename templates and includes the runtime mutation in chunk hashes", async () => { + const filename: Filename = () => "js/[name].[chunkhash:8].js"; + const shadow = await compile({filename, mode: "production"}); + const normal = await compile({filename, mode: "production", selected: () => false}); + + try { + expect(entryFile(shadow.stats, "shadow", ".js")).not.toBe(entryFile(normal.stats, "shadow", ".js")); + } finally { + await shadow.close(); + await normal.close(); + } +}); + +test("switches the selected CSS runtime from shadow to normal and back during watch rebuilds", async () => { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-isolated-styles-watch-")); + const watchFixtures = path.join(temporaryRoot, "fixtures"); + const outputPath = path.join(temporaryRoot, "dist"); + fs.cpSync(fixtures, watchFixtures, {recursive: true}); + fs.writeFileSync(path.join(watchFixtures, "watch-state.js"), "globalThis.shadowWatchState = true;\n"); + fs.writeFileSync( + path.join(watchFixtures, "shadow.js"), + `import "./watch-state.js";\n${fs.readFileSync(path.join(fixtures, "shadow.js"), "utf8")}` + ); + let shadow = true; + const compiler = createCompiler(outputPath, { + context: watchFixtures, + selected: entry => entry === "shadow" && shadow, + }); + const observations: boolean[] = []; + const watchErrors: string[] = []; + + const watcher = compiler.watch({poll: 50}, (error, stats) => { + if (error) { + watchErrors.push(error.message); + return; + } + + if (!stats || stats.hasErrors()) { + watchErrors.push(stats?.toString({all: false, errors: true, errorDetails: true}) ?? "missing stats"); + return; + } + + observations.push( + source(stats, entryFile(stats, "shadow", ".js")).includes("var isolatedStyleRoots = new Map()") + ); + + if (observations.length === 1) { + shadow = false; + fs.writeFileSync(path.join(watchFixtures, "watch-state.js"), "globalThis.shadowWatchState = false;\n"); + watcher.invalidate(); + } else if (observations.length === 2) { + shadow = true; + fs.writeFileSync(path.join(watchFixtures, "watch-state.js"), "globalThis.shadowWatchState = true;\n"); + watcher.invalidate(); + } + }); + + try { + await new Promise((resolve, reject) => { + const poll = setInterval(() => { + if (observations.length === 3) { + clearInterval(poll); + clearTimeout(timeout); + resolve(); + } + }, 10); + const timeout = setTimeout(() => { + clearInterval(poll); + reject( + new Error( + `Rspack watch did not finish three rebuilds: ${JSON.stringify({observations, watchErrors})}` + ) + ); + }, 5_000); + }); + + expect(observations).toEqual([true, false, true]); + } finally { + await new Promise((resolve, reject) => { + try { + watcher.close(resolve); + } catch (error) { + reject(error); + } + }); + fs.rmSync(temporaryRoot, {force: true, recursive: true}); + } +}, 15_000); diff --git a/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.ts b/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.ts new file mode 100644 index 00000000..fbbbd5dc --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.ts @@ -0,0 +1,224 @@ +import {type Chunk, type Compilation, type Compiler, type Filename, RuntimeGlobals, RuntimeModule} from "@rspack/core"; + +import {getIsolatedStylesFiles, isIsolatedStylesChunk} from "@cli/bundler/utils/styles"; +import {CssContentHashType, filenameRequiresFullHash, resolveChunkFilename} from "@cli/bundler/utils/chunk-filename"; +import type {RuntimePropertyOptions} from "../types"; +import {getManifestHooks} from "../utils/manifest-hooks"; + +import {renderIsolatedStylesCssLoader, renderIsolatedStylesRuntime} from "./templates"; + +const PluginName = "IsolatedStylesPlugin"; +const CssLoadingRuntimeIdentifier = "webpack/runtime/css loading"; +const CssRuntimeBoundary = "// object to store loaded CSS chunks"; +const CssFilenameExpression = /var href = (.+?)\(chunkId\);/; + +export interface IsolatedStylesPluginOptions extends RuntimePropertyOptions { + cssFilename: Filename; + cssChunkFilename: Filename; + test: (entry: string) => boolean; + timeout?: number; + /** Inspects every entry's initial, shared and async CSS, independently of test. Throw to reject; return warning messages to report without changing delivery. */ + validate?: (entry: string, files: IsolatedStylesPluginFiles) => void | readonly string[]; +} + +/** CSS destinations in one entrypoint's complete dependency graph, for build diagnostics only. */ +export interface IsolatedStylesPluginFiles { + readonly document: readonly string[]; + readonly isolated: readonly string[]; +} + +class IsolatedStylesRuntimeModule extends RuntimeModule { + public constructor( + private readonly entry: string, + private readonly property: string, + private readonly timeout: number, + private readonly cssFilename: Filename, + private readonly cssChunkFilename: Filename, + fullHash: boolean + ) { + super(`isolated styles ${entry}`, RuntimeModule.STAGE_ATTACH); + this.dependentHash = true; + this.fullHash = fullHash; + } + + public generate(): string { + const compilation = this.compilation; + if (!compilation) throw new Error("Isolated styles runtime is not attached to a compilation"); + + const initialStyles = initialStyleChunks(compilation, this.entry, this.cssFilename, this.cssChunkFilename).map( + ({chunk, filename}) => resolveChunkFilename(compilation, chunk, filename, CssContentHashType) + ); + + return renderIsolatedStylesRuntime({ + entry: this.entry, + require: RuntimeGlobals.require, + property: this.property, + timeout: this.timeout, + initialStyles, + }); + } +} + +const initialStyleChunks = ( + compilation: Compilation, + entry: string, + cssFilename: Filename, + cssChunkFilename: Filename +): {chunk: Chunk; filename: Filename}[] => { + const entrypoint = compilation.entrypoints.get(entry); + if (!entrypoint) throw new Error(`Isolated styles entrypoint "${entry}" is unavailable`); + + const entryChunk = entrypoint.getEntrypointChunk(); + return Array.from(entrypoint.chunks) + .filter(chunk => isIsolatedStylesChunk(compilation, chunk)) + .map(chunk => ({chunk, filename: chunk === entryChunk ? cssFilename : cssChunkFilename})); +}; + +const hasAsyncCss = (chunk: Chunk): boolean => { + return Array.from(chunk.getAllAsyncChunks()).some(candidate => { + return [...candidate.files, ...candidate.auxiliaryFiles].some(file => file.endsWith(".css")); + }); +}; + +export default class IsolatedStylesPlugin { + private readonly originalRuntimeSources = new WeakMap(); + + public constructor(private readonly options: IsolatedStylesPluginOptions) {} + + public apply(compiler: Compiler): void { + compiler.hooks.compilation.tap(PluginName, compilation => { + getManifestHooks(compilation).prepareDependencies.tap(PluginName, dependencies => { + const isolatedFiles = getIsolatedStylesFiles(compilation); + for (const [entry, dependency] of dependencies) { + if (!this.options.test(entry)) continue; + + for (const file of dependency.css) { + if (!isolatedFiles.has(file)) continue; + dependency.css.delete(file); + dependency.assets.add(file); + } + } + }); + }); + + const timeout = this.options.timeout ?? compiler.options.output.chunkLoadTimeout ?? 120_000; + + compiler.hooks.thisCompilation.tap(PluginName, compilation => { + const injected = new Set(); + const patched = new Set(); + + compilation.hooks.additionalTreeRuntimeRequirements.tap(PluginName, (chunk, requirements) => { + if (chunk.name === undefined || !this.options.test(chunk.name) || injected.has(chunk)) { + return; + } + + requirements.add(RuntimeGlobals.require); + requirements.add(RuntimeGlobals.publicPath); + injected.add(chunk); + const fullHash = initialStyleChunks( + compilation, + chunk.name, + this.options.cssFilename, + this.options.cssChunkFilename + ).some(({chunk, filename}) => + filenameRequiresFullHash(compilation, chunk, filename, CssContentHashType) + ); + compilation.addRuntimeModule( + chunk, + new IsolatedStylesRuntimeModule( + chunk.name, + this.options.property, + timeout, + this.options.cssFilename, + this.options.cssChunkFilename, + fullHash + ) + ); + }); + + compilation.hooks.runtimeModule.tap(PluginName, (runtimeModule, chunk) => { + if (chunk.name === undefined || runtimeModule.identifier() !== CssLoadingRuntimeIdentifier) { + return; + } + + const source = runtimeModule.source; + + if (!source || typeof source.source !== "string") { + throw new Error( + `CSS loading runtime source for isolated entrypoint "${chunk.name}" is unavailable` + ); + } + + if (source.source.length === 0) { + return; + } + + const originalSource = this.originalRuntimeSources.get(runtimeModule) ?? source.source; + this.originalRuntimeSources.set(runtimeModule, originalSource); + source.source = originalSource; + + if (!this.options.test(chunk.name)) { + return; + } + + const boundary = originalSource.indexOf(CssRuntimeBoundary); + const filenameExpression = originalSource.match(CssFilenameExpression)?.[1]; + + if (boundary < 0 || !filenameExpression) { + throw new Error(`CSS loading runtime seam for isolated entrypoint "${chunk.name}" is unavailable`); + } + + source.source = renderIsolatedStylesCssLoader({ + isolatedChunks: Object.fromEntries( + [...compilation.chunks] + .filter(candidate => isIsolatedStylesChunk(compilation, candidate)) + .map(candidate => [candidate.id, true]) + ), + originalLoader: originalSource.slice(0, boundary), + cssFilenameExpression: filenameExpression, + originalRuntime: originalSource.slice(boundary), + publicPath: RuntimeGlobals.publicPath, + require: RuntimeGlobals.require, + property: this.options.property, + }); + patched.add(chunk); + }); + + compilation.hooks.afterSeal.tap(PluginName, () => { + for (const [entry, entrypoint] of compilation.entrypoints) { + const chunk = entrypoint.getRuntimeChunk(); + + if (this.options.validate) { + const document = new Set(); + const isolated = new Set(); + const referenced = new Set([ + ...entrypoint.chunks, + ...entrypoint.getEntrypointChunk().getAllReferencedChunks(), + ]); + for (const candidate of referenced) { + const destination = isIsolatedStylesChunk(compilation, candidate) ? isolated : document; + for (const file of candidate.files) { + if (file.endsWith(".css")) destination.add(file); + } + } + const warnings = this.options.validate(entry, { + document: [...document].sort(), + isolated: [...isolated].sort(), + }); + for (const message of warnings ?? []) compilation.warnings.push(new Error(message)); + } + + if ( + chunk && + chunk.name !== undefined && + this.options.test(chunk.name) && + hasAsyncCss(entrypoint.getEntrypointChunk()) && + !patched.has(chunk) + ) { + throw new Error(`CSS loading runtime for isolated entrypoint "${chunk.name}" was not replaced`); + } + } + }); + }); + } +} diff --git a/src/cli/bundler/plugins/isolated-styles/index.ts b/src/cli/bundler/plugins/isolated-styles/index.ts new file mode 100644 index 00000000..1034c9f4 --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/index.ts @@ -0,0 +1 @@ +export {default, type IsolatedStylesPluginOptions, type IsolatedStylesPluginFiles} from "./IsolatedStylesPlugin"; diff --git a/src/cli/bundler/plugins/shadow-styles/templates.ts b/src/cli/bundler/plugins/isolated-styles/templates.ts similarity index 51% rename from src/cli/bundler/plugins/shadow-styles/templates.ts rename to src/cli/bundler/plugins/isolated-styles/templates.ts index 896035f5..9371db74 100644 --- a/src/cli/bundler/plugins/shadow-styles/templates.ts +++ b/src/cli/bundler/plugins/isolated-styles/templates.ts @@ -2,37 +2,42 @@ import cssLoaderTemplate from "./templates/css-loader.template.js?raw"; import runtimeTemplate from "./templates/runtime.template.js?raw"; import {renderRuntimeTemplate} from "@cli/bundler/plugins/utils"; +import type {RuntimePropertyOptions} from "../types"; -interface ShadowStylesRuntimeTemplateOptions { +interface IsolatedStylesRuntimeTemplateOptions extends RuntimePropertyOptions { readonly entry: string; readonly require: string; - readonly runtimeProperty: string; readonly timeout: number; + readonly initialStyles: readonly string[]; } -interface ShadowStylesCssLoaderTemplateOptions { +interface IsolatedStylesCssLoaderTemplateOptions extends RuntimePropertyOptions { + readonly isolatedChunks: Readonly>; + readonly originalLoader: string; readonly cssFilenameExpression: string; readonly originalRuntime: string; readonly publicPath: string; readonly require: string; - readonly runtimeProperty: string; } -export const renderShadowStylesRuntime = (options: ShadowStylesRuntimeTemplateOptions): string => { +export const renderIsolatedStylesRuntime = (options: IsolatedStylesRuntimeTemplateOptions): string => { return renderRuntimeTemplate(runtimeTemplate, { __ADNBN_ENTRY__: JSON.stringify(options.entry), __ADNBN_REQUIRE__: options.require, - __ADNBN_RUNTIME_PROPERTY__: options.runtimeProperty, + __ADNBN_PROPERTY__: JSON.stringify(options.property), __ADNBN_TIMEOUT__: JSON.stringify(options.timeout), + __ADNBN_INITIAL_STYLES__: JSON.stringify(options.initialStyles), }); }; -export const renderShadowStylesCssLoader = (options: ShadowStylesCssLoaderTemplateOptions): string => { +export const renderIsolatedStylesCssLoader = (options: IsolatedStylesCssLoaderTemplateOptions): string => { return renderRuntimeTemplate(cssLoaderTemplate, { + __ADNBN_ISOLATED_CSS_CHUNKS__: JSON.stringify(options.isolatedChunks), + __ADNBN_ORIGINAL_CSS_LOADER__: options.originalLoader, __ADNBN_CSS_FILENAME_EXPRESSION__: options.cssFilenameExpression, __ADNBN_ORIGINAL_CSS_RUNTIME__: options.originalRuntime, __ADNBN_PUBLIC_PATH__: options.publicPath, __ADNBN_REQUIRE__: options.require, - __ADNBN_RUNTIME_PROPERTY__: options.runtimeProperty, + __ADNBN_PROPERTY__: JSON.stringify(options.property), }); }; diff --git a/src/cli/bundler/plugins/isolated-styles/templates/css-loader.template.js b/src/cli/bundler/plugins/isolated-styles/templates/css-loader.template.js new file mode 100644 index 00000000..4435b57d --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/templates/css-loader.template.js @@ -0,0 +1,12 @@ +__ADNBN_ORIGINAL_CSS_LOADER__; + +var loadDocumentStylesheet = loadStylesheet; +loadStylesheet = function (chunkId) { + if (!__ADNBN_ISOLATED_CSS_CHUNKS__[chunkId]) return loadDocumentStylesheet(chunkId); + var href = __ADNBN_CSS_FILENAME_EXPRESSION__(chunkId); + var fullhref = __ADNBN_PUBLIC_PATH__ + href; + + return __ADNBN_REQUIRE__[__ADNBN_PROPERTY__].load(fullhref); +}; + +__ADNBN_ORIGINAL_CSS_RUNTIME__; diff --git a/src/cli/bundler/plugins/isolated-styles/templates/runtime.template.js b/src/cli/bundler/plugins/isolated-styles/templates/runtime.template.js new file mode 100644 index 00000000..a71c5058 --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/templates/runtime.template.js @@ -0,0 +1,127 @@ +var isolatedStyleRoots = new Map(); +var isolatedRequestedStyles = new Set(); +var isolatedStyleTimeout = __ADNBN_TIMEOUT__; +var isolatedStyleEntry = __ADNBN_ENTRY__; +var isolatedInitialStyles; + +var createStyleError = function (url, type) { + var error = new Error('Loading isolated CSS for entrypoint "' + isolatedStyleEntry + '" failed.\n(' + url + ")"); + error.code = "CSS_CHUNK_LOAD_FAILED"; + error.type = type; + error.request = url; + return error; +}; + +var attachStyle = function (root, url, retry) { + var rootState = isolatedStyleRoots.get(root); + + if (!rootState) return Promise.resolve(); + + var existing = rootState.styles.get(url); + + if (existing) return existing.promise; + + var resolveStyle; + var rejectStyle; + var promise = new Promise(function (resolve, reject) { + resolveStyle = resolve; + rejectStyle = reject; + }); + var settled = false; + var record = { + link: undefined, + promise: promise, + cancel: function () { + settle(); + record.link.remove(); + }, + }; + + rootState.styles.set(url, record); + + var settle = function (error) { + if (settled) return; + settled = true; + record.link.onerror = record.link.onload = null; + clearTimeout(timer); + + if (error) { + rootState.styles.delete(url); + record.link.remove(); + rejectStyle(error); + } else { + resolveStyle(); + } + }; + + var start = function (previous) { + var link = root.ownerDocument.createElement("link"); + record.link = link; + link.rel = "stylesheet"; + link.type = "text/css"; + link.href = url; + link.onload = function () { + if (!settled && record.link === link) settle(); + }; + link.onerror = function (event) { + if (settled || record.link !== link) return; + if (retry) { + retry = false; + link.onerror = link.onload = null; + start(link); + } else { + var type = event && event.type ? event.type : "error"; + settle(createStyleError(url, type)); + } + }; + // Replace in place: appending a retry could change the CSS cascade order. + root.insertBefore(link, previous || rootState.target); + if (previous) previous.remove(); + }; + // One budget for both attempts, never another timer for the retry. + var timer = setTimeout(function () { + settle(createStyleError(url, "timeout")); + }, isolatedStyleTimeout); + + start(); + + return promise; +}; + +__ADNBN_REQUIRE__[__ADNBN_PROPERTY__] = { + initialize: function (resolveUrl) { + if (!isolatedInitialStyles) isolatedInitialStyles = __ADNBN_INITIAL_STYLES__.map(resolveUrl); + }, + add: function (root, target, retry) { + if (isolatedStyleRoots.has(root)) return; + if (!isolatedInitialStyles) + throw new Error('Isolated stylesheet URLs for entrypoint "' + isolatedStyleEntry + '" are not initialized'); + + isolatedStyleRoots.set(root, {target: target, styles: new Map()}); + + isolatedInitialStyles.concat(Array.from(isolatedRequestedStyles)).forEach(function (url) { + attachStyle(root, url, retry).catch(function (error) { + console.error(error); + }); + }); + }, + delete: function (root) { + var rootState = isolatedStyleRoots.get(root); + + if (!rootState) return; + + isolatedStyleRoots.delete(root); + rootState.styles.forEach(function (record) { + record.cancel(); + }); + }, + load: function (url) { + isolatedRequestedStyles.add(url); + + return Promise.all( + Array.from(isolatedStyleRoots.keys(), function (root) { + return attachStyle(root, url); + }) + ).then(function () {}); + }, +}; diff --git a/src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal-shared.js b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal-shared.js new file mode 100644 index 00000000..d1772926 --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal-shared.js @@ -0,0 +1,2 @@ +import "./normal.css"; +import "./shadow-shared.css"; diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.css b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal.css similarity index 100% rename from src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.css rename to src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal.css diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.js b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal.js similarity index 100% rename from src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.js rename to src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal.js diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.lazy.css b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal.lazy.css similarity index 100% rename from src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.lazy.css rename to src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal.lazy.css diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.lazy.js b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal.lazy.js similarity index 100% rename from src/cli/bundler/plugins/shadow-styles/tests/fixtures/normal.lazy.js rename to src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal.lazy.js diff --git a/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow-async.js b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow-async.js new file mode 100644 index 00000000..11b28c22 --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow-async.js @@ -0,0 +1,2 @@ +globalThis.getStylesRuntime = property => __webpack_require__[property]; +globalThis.loadIsolatedStyles = () => import(/* webpackChunkName: "shadow-lazy" */ "./shadow.lazy.js"); diff --git a/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow-shared.css b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow-shared.css new file mode 100644 index 00000000..a290467f --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow-shared.css @@ -0,0 +1,3 @@ +.shadow-shared { + color: purple; +} diff --git a/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow-shared.js b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow-shared.js new file mode 100644 index 00000000..f3355f2f --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow-shared.js @@ -0,0 +1,4 @@ +import "./shadow.css"; +import "./shadow-shared.css"; + +globalThis.getStylesRuntime = property => __webpack_require__[property]; diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.css b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.css similarity index 100% rename from src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.css rename to src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.css diff --git a/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.js b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.js new file mode 100644 index 00000000..87ab2c61 --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.js @@ -0,0 +1,4 @@ +import "./shadow.css"; + +globalThis.getStylesRuntime = property => __webpack_require__[property]; +globalThis.loadIsolatedStyles = () => import(/* webpackChunkName: "shadow-lazy" */ "./shadow.lazy.js"); diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy-a.css b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.lazy-a.css similarity index 100% rename from src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy-a.css rename to src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.lazy-a.css diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy-b.css b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.lazy-b.css similarity index 100% rename from src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy-b.css rename to src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.lazy-b.css diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy.js b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.lazy.js similarity index 100% rename from src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.lazy.js rename to src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.lazy.js diff --git a/src/cli/bundler/plugins/shadow-styles/ShadowStylesPlugin.test.ts b/src/cli/bundler/plugins/shadow-styles/ShadowStylesPlugin.test.ts deleted file mode 100644 index eebed6a3..00000000 --- a/src/cli/bundler/plugins/shadow-styles/ShadowStylesPlugin.test.ts +++ /dev/null @@ -1,548 +0,0 @@ -/** @jest-environment node */ - -import fs from "fs"; -import os from "os"; -import path from "path"; -import vm from "vm"; - -import {type Compiler, CssExtractRspackPlugin, type Filename, rspack, type Stats} from "@rspack/core"; - -import ShadowStylesPlugin, {ShadowStylesRuntimeProperty} from "./ShadowStylesPlugin"; - -const fixtures = path.resolve(__dirname, "tests", "fixtures"); -const projectRoot = path.resolve(__dirname, "../../../../.."); - -interface BuildResult { - readonly close: () => Promise; - readonly compiler: Compiler; - readonly outputPath: string; - readonly stats: Stats; -} - -interface FakeLink { - href: string; - onerror: ((event: {type: string}) => void) | null; - onload: (() => void) | null; - rel: string; - type: string; - remove(): void; -} - -interface FakeRoot { - readonly links: FakeLink[]; - insertBefore(link: FakeLink, container: object): FakeLink; -} - -interface RuntimeApi { - add(root: FakeRoot, target: object, initialStyles: string[]): void; - delete(root: FakeRoot): void; - load(url: string): Promise; -} - -interface RuntimeHarness { - readonly requestedScripts: string[]; - readonly runtime: RuntimeApi; - readonly sandbox: Record; -} - -const closeCompiler = (compiler: Compiler): Promise => { - return new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); -}; - -const runCompiler = (compiler: Compiler): Promise => { - return new Promise((resolve, reject) => { - compiler.run((error, stats) => { - if (error) { - reject(error); - } else if (!stats) { - reject(new Error("Rspack did not return build stats")); - } else if (stats.hasErrors()) { - reject(new Error(stats.toString({all: false, errors: true, errorDetails: true}))); - } else { - resolve(stats); - } - }); - }); -}; - -interface CompileOptions { - context?: string; - filename?: Filename; - mode?: "development" | "production"; - selected?: (entry: string) => boolean; - timeout?: number; -} - -const createCompiler = (outputPath: string, options: CompileOptions = {}): Compiler => { - const filename = options.filename ?? "js/[name].[contenthash:8].js"; - - return rspack({ - context: options.context ?? fixtures, - mode: options.mode ?? "development", - target: ["web", "es2020"], - devtool: false, - entry: { - shadow: "./shadow.js", - normal: "./normal.js", - }, - output: { - path: outputPath, - clean: true, - filename, - chunkFilename: filename, - publicPath: "https://extension.test/", - globalObject: "globalThis", - uniqueName: "shadowStylesFixture", - }, - resolveLoader: { - modules: [path.resolve(projectRoot, "node_modules"), "node_modules"], - }, - module: { - rules: [ - { - test: /\.css$/i, - sideEffects: true, - type: "javascript/auto", - use: [ - CssExtractRspackPlugin.loader, - { - loader: "css-loader", - options: {esModule: true, modules: false}, - }, - ], - }, - ], - }, - optimization: { - minimize: false, - runtimeChunk: false, - splitChunks: false, - }, - plugins: [ - new CssExtractRspackPlugin({ - filename: "css/[name].[contenthash:8].css", - chunkFilename: "css/[name].[contenthash:8].css", - }), - new ShadowStylesPlugin({ - test: options.selected ?? (entry => entry === "shadow"), - timeout: options.timeout ?? 1_000, - }), - ], - }); -}; - -const compile = async (options: CompileOptions = {}): Promise => { - const outputPath = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-shadow-styles-")); - const compiler = createCompiler(outputPath, options); - - try { - const stats = await runCompiler(compiler); - - return { - close: async () => { - await closeCompiler(compiler); - fs.rmSync(outputPath, {force: true, recursive: true}); - }, - compiler, - outputPath, - stats, - }; - } catch (error) { - await closeCompiler(compiler); - fs.rmSync(outputPath, {force: true, recursive: true}); - throw error; - } -}; - -const entryFile = (stats: Stats, entry: string, extension: ".css" | ".js"): string => { - const file = stats.compilation.entrypoints - .get(entry) - ?.getFiles() - .find(candidate => candidate.endsWith(extension)); - - if (!file) { - throw new Error(`${extension} file for entrypoint ${entry} was not found`); - } - - return file; -}; - -const source = (stats: Stats, file: string): string => { - const asset = stats.compilation.getAsset(file); - - if (!asset) { - throw new Error(`Asset ${file} was not found`); - } - - return asset.source.source().toString(); -}; - -const createRoot = (onInsert: (link: FakeLink) => void): FakeRoot => { - const links: FakeLink[] = []; - - return { - links, - insertBefore(link) { - links.push(link); - link.remove = () => { - const index = links.indexOf(link); - - if (index >= 0) links.splice(index, 1); - }; - onInsert(link); - - return link; - }, - }; -}; - -const executeShadowEntrypoint = (result: BuildResult): RuntimeHarness => { - const requestedScripts: string[] = []; - let context: vm.Context; - const sandbox: Record = { - clearTimeout, - console, - Promise, - setTimeout, - }; - - const element = (tagName: string): Record => { - const attributes = new Map(); - - return { - tagName: tagName.toUpperCase(), - getAttribute: (name: string) => attributes.get(name) ?? null, - setAttribute: (name: string, value: string) => attributes.set(name, value), - }; - }; - - const head = { - appendChild(node: Record) { - node.parentNode = head; - - if (node.tagName === "SCRIPT") { - const url = String(node.src); - const filename = url.replace("https://extension.test/", ""); - requestedScripts.push(filename); - vm.runInContext(fs.readFileSync(path.join(result.outputPath, filename), "utf8"), context); - queueMicrotask(() => node.onload?.({target: node, type: "load"})); - } else { - queueMicrotask(() => node.onload?.({target: node, type: "load"})); - } - - return node; - }, - removeChild(node: Record) { - node.parentNode = undefined; - - return node; - }, - }; - - sandbox.document = { - createElement: element, - getElementsByTagName: () => [], - head, - }; - sandbox.globalThis = sandbox; - sandbox.self = sandbox; - context = vm.createContext(sandbox); - - vm.runInContext(source(result.stats, entryFile(result.stats, "shadow", ".js")), context); - - const getRuntime = sandbox.getShadowStylesRuntime as (() => RuntimeApi) | undefined; - - if (!getRuntime) { - throw new Error("Shadow fixture did not expose the style runtime"); - } - - return {requestedScripts, runtime: getRuntime(), sandbox}; -}; - -test("replaces the CSS loading runtime only for selected entrypoints", async () => { - const result = await compile(); - - try { - const shadow = source(result.stats, entryFile(result.stats, "shadow", ".js")); - const normal = source(result.stats, entryFile(result.stats, "normal", ".js")); - - expect(shadow).toContain(ShadowStylesRuntimeProperty); - expect(shadow).toContain("shadowStyleRoots"); - expect(shadow).not.toContain("document.head.appendChild(linkTag)"); - expect(normal).not.toContain(ShadowStylesRuntimeProperty); - expect(normal).toContain("document.head.appendChild(linkTag)"); - } finally { - await result.close(); - } -}); - -test("loads initial and async CSS into every active root and keeps requested styles for late roots", async () => { - const result = await compile(); - - try { - const harness = executeShadowEntrypoint(result); - const initialUrl = `https://extension.test/${entryFile(result.stats, "shadow", ".css")}`; - const inserted: string[] = []; - const rootA = createRoot(link => { - inserted.push(`a:${link.href}`); - queueMicrotask(() => link.onload?.()); - }); - const rootB = createRoot(link => { - inserted.push(`b:${link.href}`); - queueMicrotask(() => link.onload?.()); - }); - - harness.runtime.add(rootA, {}, [initialUrl]); - harness.runtime.add(rootB, {}, [initialUrl]); - await (harness.sandbox.loadShadowStyles as () => Promise)(); - - const lazyCss = result.stats.compilation - .getAssets() - .map(asset => asset.name) - .find(file => file.includes("shadow-lazy") && file.endsWith(".css")); - - expect(lazyCss).toBeDefined(); - expect(inserted).toEqual( - expect.arrayContaining([ - `a:${initialUrl}`, - `b:${initialUrl}`, - `a:https://extension.test/${lazyCss}`, - `b:https://extension.test/${lazyCss}`, - ]) - ); - expect(rootA.links).toHaveLength(2); - expect(rootB.links).toHaveLength(2); - - const lateRoot = createRoot(link => queueMicrotask(() => link.onload?.())); - harness.runtime.add(lateRoot, {}, [initialUrl]); - await Promise.resolve(); - await Promise.resolve(); - - expect(lateRoot.links.map(link => link.href)).toEqual([initialUrl, `https://extension.test/${lazyCss}`]); - expect(harness.requestedScripts).toEqual([expect.stringMatching(/shadow-lazy.*\.js$/)]); - } finally { - await result.close(); - } -}); - -test("does not wait for a future root when import happens before rendering", async () => { - const result = await compile(); - - try { - const harness = executeShadowEntrypoint(result); - - await expect((harness.sandbox.loadShadowStyles as () => Promise)()).resolves.toMatchObject({ - shadowLazy: true, - }); - - const root = createRoot(link => queueMicrotask(() => link.onload?.())); - harness.runtime.add(root, {}, []); - await Promise.resolve(); - await Promise.resolve(); - - expect(root.links).toHaveLength(1); - expect(root.links[0].href).toContain("shadow-lazy"); - } finally { - await result.close(); - } -}); - -test("reports an initial stylesheet failure without removing the root and permits retry", async () => { - const result = await compile(); - const report = jest.spyOn(console, "error").mockImplementation(() => {}); - - try { - const harness = executeShadowEntrypoint(result); - const initialUrl = `https://extension.test/${entryFile(result.stats, "shadow", ".css")}`; - let attempt = 0; - const root = createRoot(link => { - attempt += 1; - queueMicrotask(() => { - if (attempt === 1) { - link.onerror?.({type: "error"}); - } else { - link.onload?.(); - } - }); - }); - - harness.runtime.add(root, {}, [initialUrl]); - await new Promise(resolve => setImmediate(resolve)); - - expect(report).toHaveBeenCalledWith( - expect.objectContaining({ - code: "CSS_CHUNK_LOAD_FAILED", - request: initialUrl, - type: "error", - }) - ); - expect(root.links).toHaveLength(0); - - await expect(harness.runtime.load(initialUrl)).resolves.toBeUndefined(); - expect(root.links.map(link => link.href)).toEqual([initialUrl]); - } finally { - report.mockRestore(); - await result.close(); - } -}); - -test("rejects failed and timed out styles, removes failed links and permits retry", async () => { - const result = await compile({timeout: 20}); - - try { - const harness = executeShadowEntrypoint(result); - let attempt = 0; - const root = createRoot(link => { - attempt += 1; - - if (attempt === 1) queueMicrotask(() => link.onerror?.({type: "error"})); - if (attempt === 2) queueMicrotask(() => link.onload?.()); - }); - harness.runtime.add(root, {}, []); - - await expect((harness.sandbox.loadShadowStyles as () => Promise)()).rejects.toThrow( - /shadow[\s\S]*https:\/\/extension\.test\/css\/shadow-lazy/i - ); - expect(root.links).toHaveLength(0); - await expect((harness.sandbox.loadShadowStyles as () => Promise)()).resolves.toMatchObject({ - shadowLazy: true, - }); - - harness.runtime.delete(root); - const timedOut = createRoot(link => { - if (!link.href.endsWith("/hanging.css")) queueMicrotask(() => link.onload?.()); - }); - harness.runtime.add(timedOut, {}, []); - - await expect(harness.runtime.load("https://extension.test/css/hanging.css")).rejects.toMatchObject({ - type: "timeout", - request: "https://extension.test/css/hanging.css", - }); - expect(timedOut.links.map(link => link.href)).not.toContain("https://extension.test/css/hanging.css"); - } finally { - await result.close(); - } -}); - -test("settles pending work when a root is removed and does not duplicate successful links", async () => { - const result = await compile(); - - try { - const harness = executeShadowEntrypoint(result); - const root = createRoot(() => undefined); - harness.runtime.add(root, {}, []); - const pending = harness.runtime.load("https://extension.test/css/pending.css"); - - harness.runtime.delete(root); - - await expect(pending).resolves.toBeUndefined(); - await harness.runtime.load("https://extension.test/css/pending.css"); - expect(root.links).toHaveLength(1); - } finally { - await result.close(); - } -}); - -test.each([ - ["contenthash", "js/[name].[contenthash:8].js"], - ["chunkhash", "js/[name].[chunkhash:8].js"], - ["fullhash", "js/[name].[fullhash:8].js"], -] as const)("supports user %s filename templates", async (_hash, filename) => { - const result = await compile({filename, mode: "production"}); - - try { - expect(entryFile(result.stats, "shadow", ".js")).toMatch(/^js\/shadow\.[a-f0-9]{8}\.js$/); - expect(source(result.stats, entryFile(result.stats, "shadow", ".js"))).toContain(ShadowStylesRuntimeProperty); - } finally { - await result.close(); - } -}); - -test("supports callback filename templates and includes the runtime mutation in chunk hashes", async () => { - const filename: Filename = () => "js/[name].[chunkhash:8].js"; - const shadow = await compile({filename, mode: "production"}); - const normal = await compile({filename, mode: "production", selected: () => false}); - - try { - expect(entryFile(shadow.stats, "shadow", ".js")).not.toBe(entryFile(normal.stats, "shadow", ".js")); - } finally { - await shadow.close(); - await normal.close(); - } -}); - -test("switches the selected CSS runtime from shadow to normal and back during watch rebuilds", async () => { - const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-shadow-styles-watch-")); - const watchFixtures = path.join(temporaryRoot, "fixtures"); - const outputPath = path.join(temporaryRoot, "dist"); - fs.cpSync(fixtures, watchFixtures, {recursive: true}); - fs.writeFileSync(path.join(watchFixtures, "watch-state.js"), "globalThis.shadowWatchState = true;\n"); - fs.writeFileSync( - path.join(watchFixtures, "shadow.js"), - `import "./watch-state.js";\n${fs.readFileSync(path.join(fixtures, "shadow.js"), "utf8")}` - ); - let shadow = true; - const compiler = createCompiler(outputPath, { - context: watchFixtures, - selected: entry => entry === "shadow" && shadow, - }); - const observations: boolean[] = []; - const watchErrors: string[] = []; - - const watcher = compiler.watch({poll: 50}, (error, stats) => { - if (error) { - watchErrors.push(error.message); - return; - } - - if (!stats || stats.hasErrors()) { - watchErrors.push(stats?.toString({all: false, errors: true, errorDetails: true}) ?? "missing stats"); - return; - } - - observations.push( - source(stats, entryFile(stats, "shadow", ".js")).includes("var shadowStyleRoots = new Map()") - ); - - if (observations.length === 1) { - shadow = false; - fs.writeFileSync(path.join(watchFixtures, "watch-state.js"), "globalThis.shadowWatchState = false;\n"); - watcher.invalidate(); - } else if (observations.length === 2) { - shadow = true; - fs.writeFileSync(path.join(watchFixtures, "watch-state.js"), "globalThis.shadowWatchState = true;\n"); - watcher.invalidate(); - } - }); - - try { - await new Promise((resolve, reject) => { - const poll = setInterval(() => { - if (observations.length === 3) { - clearInterval(poll); - clearTimeout(timeout); - resolve(); - } - }, 10); - const timeout = setTimeout(() => { - clearInterval(poll); - reject( - new Error( - `Rspack watch did not finish three rebuilds: ${JSON.stringify({observations, watchErrors})}` - ) - ); - }, 5_000); - }); - - expect(observations).toEqual([true, false, true]); - } finally { - await new Promise((resolve, reject) => { - try { - watcher.close(resolve); - } catch (error) { - reject(error); - } - }); - fs.rmSync(temporaryRoot, {force: true, recursive: true}); - } -}, 15_000); diff --git a/src/cli/bundler/plugins/shadow-styles/ShadowStylesPlugin.ts b/src/cli/bundler/plugins/shadow-styles/ShadowStylesPlugin.ts deleted file mode 100644 index a311d68d..00000000 --- a/src/cli/bundler/plugins/shadow-styles/ShadowStylesPlugin.ts +++ /dev/null @@ -1,123 +0,0 @@ -import {type Chunk, type Compiler, RuntimeGlobals, RuntimeModule} from "@rspack/core"; - -import {ShadowStylesRuntimeProperty} from "@entry/content/core/shadow-styles"; - -import {renderShadowStylesCssLoader, renderShadowStylesRuntime} from "./templates"; - -const PluginName = "ShadowStylesPlugin"; -const CssLoadingRuntimeIdentifier = "webpack/runtime/css loading"; -const CssRuntimeBoundary = "// object to store loaded CSS chunks"; -const CssFilenameExpression = /var href = (.+?)\(chunkId\);/; - -export {ShadowStylesRuntimeProperty}; - -export interface ShadowStylesPluginOptions { - test: (entry: string) => boolean; - timeout?: number; -} - -class ShadowStylesRuntimeModule extends RuntimeModule { - public constructor( - private readonly entry: string, - private readonly timeout: number - ) { - super(`shadow styles ${entry}`, RuntimeModule.STAGE_ATTACH); - } - - public generate(): string { - return renderShadowStylesRuntime({ - entry: this.entry, - require: RuntimeGlobals.require, - runtimeProperty: ShadowStylesRuntimeProperty, - timeout: this.timeout, - }); - } -} - -const hasAsyncCss = (chunk: Chunk): boolean => { - return Array.from(chunk.getAllAsyncChunks()).some(candidate => { - return [...candidate.files, ...candidate.auxiliaryFiles].some(file => file.endsWith(".css")); - }); -}; - -export default class ShadowStylesPlugin { - private readonly originalRuntimeSources = new WeakMap(); - - public constructor(private readonly options: ShadowStylesPluginOptions) {} - - public apply(compiler: Compiler): void { - const timeout = this.options.timeout ?? compiler.options.output.chunkLoadTimeout ?? 120_000; - - compiler.hooks.thisCompilation.tap(PluginName, compilation => { - const injected = new Set(); - const patched = new Set(); - - compilation.hooks.additionalTreeRuntimeRequirements.tap(PluginName, (chunk, requirements) => { - if (chunk.name === undefined || !this.options.test(chunk.name) || injected.has(chunk)) { - return; - } - - requirements.add(RuntimeGlobals.require); - requirements.add(RuntimeGlobals.publicPath); - injected.add(chunk); - compilation.addRuntimeModule(chunk, new ShadowStylesRuntimeModule(chunk.name, timeout)); - }); - - compilation.hooks.runtimeModule.tap(PluginName, (runtimeModule, chunk) => { - if (chunk.name === undefined || runtimeModule.identifier() !== CssLoadingRuntimeIdentifier) { - return; - } - - const source = runtimeModule.source; - - if (!source || typeof source.source !== "string") { - throw new Error(`CSS loading runtime source for shadow entrypoint "${chunk.name}" is unavailable`); - } - - if (source.source.length === 0) { - return; - } - - const originalSource = this.originalRuntimeSources.get(runtimeModule) ?? source.source; - this.originalRuntimeSources.set(runtimeModule, originalSource); - source.source = originalSource; - - if (!this.options.test(chunk.name)) { - return; - } - - const boundary = originalSource.indexOf(CssRuntimeBoundary); - const filenameExpression = originalSource.match(CssFilenameExpression)?.[1]; - - if (boundary < 0 || !filenameExpression) { - throw new Error(`CSS loading runtime seam for shadow entrypoint "${chunk.name}" is unavailable`); - } - - source.source = renderShadowStylesCssLoader({ - cssFilenameExpression: filenameExpression, - originalRuntime: originalSource.slice(boundary), - publicPath: RuntimeGlobals.publicPath, - require: RuntimeGlobals.require, - runtimeProperty: ShadowStylesRuntimeProperty, - }); - patched.add(chunk); - }); - - compilation.hooks.afterSeal.tap(PluginName, () => { - for (const entrypoint of compilation.entrypoints.values()) { - const chunk = entrypoint.getRuntimeChunk(); - - if ( - chunk && - chunk.name !== undefined && - this.options.test(chunk.name) && - hasAsyncCss(entrypoint.getEntrypointChunk()) && - !patched.has(chunk) - ) { - throw new Error(`CSS loading runtime for shadow entrypoint "${chunk.name}" was not replaced`); - } - } - }); - }); - } -} diff --git a/src/cli/bundler/plugins/shadow-styles/index.ts b/src/cli/bundler/plugins/shadow-styles/index.ts deleted file mode 100644 index 6671f61e..00000000 --- a/src/cli/bundler/plugins/shadow-styles/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {default, ShadowStylesRuntimeProperty, type ShadowStylesPluginOptions} from "./ShadowStylesPlugin"; diff --git a/src/cli/bundler/plugins/shadow-styles/templates/css-loader.template.js b/src/cli/bundler/plugins/shadow-styles/templates/css-loader.template.js deleted file mode 100644 index c33e19e4..00000000 --- a/src/cli/bundler/plugins/shadow-styles/templates/css-loader.template.js +++ /dev/null @@ -1,10 +0,0 @@ -if (typeof document !== "undefined") { - var loadStylesheet = function (chunkId) { - var href = __ADNBN_CSS_FILENAME_EXPRESSION__(chunkId); - var fullhref = __ADNBN_PUBLIC_PATH__ + href; - - return __ADNBN_REQUIRE__.__ADNBN_RUNTIME_PROPERTY__.load(fullhref); - }; - - __ADNBN_ORIGINAL_CSS_RUNTIME__; -} diff --git a/src/cli/bundler/plugins/shadow-styles/templates/runtime.template.js b/src/cli/bundler/plugins/shadow-styles/templates/runtime.template.js deleted file mode 100644 index 2424d202..00000000 --- a/src/cli/bundler/plugins/shadow-styles/templates/runtime.template.js +++ /dev/null @@ -1,108 +0,0 @@ -var shadowStyleRoots = new Map(); -var shadowRequestedStyles = new Set(); -var shadowStyleTimeout = __ADNBN_TIMEOUT__; -var shadowStyleEntry = __ADNBN_ENTRY__; - -var createShadowStyleError = function (url, type) { - var error = new Error('Loading shadow CSS for entrypoint "' + shadowStyleEntry + '" failed.\n(' + url + ")"); - error.code = "CSS_CHUNK_LOAD_FAILED"; - error.type = type; - error.request = url; - return error; -}; - -var attachShadowStyle = function (root, url) { - var rootState = shadowStyleRoots.get(root); - - if (!rootState) return Promise.resolve(); - - var existing = rootState.styles.get(url); - - if (existing) return existing.promise; - - var resolveStyle; - var rejectStyle; - var promise = new Promise(function (resolve, reject) { - resolveStyle = resolve; - rejectStyle = reject; - }); - var link = document.createElement("link"); - var record = { - link: link, - promise: promise, - reject: rejectStyle, - resolve: resolveStyle, - timer: undefined, - }; - - rootState.styles.set(url, record); - link.rel = "stylesheet"; - link.type = "text/css"; - link.href = url; - - var settle = function (error) { - link.onerror = link.onload = null; - - if (record.timer !== undefined) clearTimeout(record.timer); - - if (error) { - rootState.styles.delete(url); - link.remove(); - rejectStyle(error); - } else { - resolveStyle(); - } - }; - - link.onload = function () { - settle(); - }; - link.onerror = function (event) { - var type = event && event.type ? event.type : "error"; - settle(createShadowStyleError(url, type)); - }; - record.timer = setTimeout(function () { - settle(createShadowStyleError(url, "timeout")); - }, shadowStyleTimeout); - - root.insertBefore(link, rootState.target); - - return promise; -}; - -__ADNBN_REQUIRE__.__ADNBN_RUNTIME_PROPERTY__ = { - add: function (root, target, initialStyles) { - if (shadowStyleRoots.has(root)) return; - - shadowStyleRoots.set(root, {target: target, styles: new Map()}); - - initialStyles.concat(Array.from(shadowRequestedStyles)).forEach(function (url) { - attachShadowStyle(root, url).catch(function (error) { - console.error(error); - }); - }); - }, - delete: function (root) { - var rootState = shadowStyleRoots.get(root); - - if (!rootState) return; - - rootState.styles.forEach(function (record) { - record.link.onerror = record.link.onload = null; - - if (record.timer !== undefined) clearTimeout(record.timer); - - record.resolve(); - }); - shadowStyleRoots.delete(root); - }, - load: function (url) { - shadowRequestedStyles.add(url); - - return Promise.all( - Array.from(shadowStyleRoots.keys(), function (root) { - return attachShadowStyle(root, url); - }) - ).then(function () {}); - }, -}; diff --git a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.js b/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.js deleted file mode 100644 index 9694e3fb..00000000 --- a/src/cli/bundler/plugins/shadow-styles/tests/fixtures/shadow.js +++ /dev/null @@ -1,4 +0,0 @@ -import "./shadow.css"; - -globalThis.getShadowStylesRuntime = () => __webpack_require__.__adnbnShadowStyles; -globalThis.loadShadowStyles = () => import(/* webpackChunkName: "shadow-lazy" */ "./shadow.lazy.js"); diff --git a/src/cli/bundler/utils/index.ts b/src/cli/bundler/utils/index.ts index 73807e43..d42c7064 100644 --- a/src/cli/bundler/utils/index.ts +++ b/src/cli/bundler/utils/index.ts @@ -1,2 +1,4 @@ export * from "./optimization"; +export * from "./layers"; export * from "./output"; +export * from "./styles"; diff --git a/src/cli/bundler/utils/layers.ts b/src/cli/bundler/utils/layers.ts new file mode 100644 index 00000000..7b38db89 --- /dev/null +++ b/src/cli/bundler/utils/layers.ts @@ -0,0 +1,10 @@ +import type {ContentScriptWorld} from "@typing/content"; + +const ContentLayerPrefix = "adnbn:content:"; + +/** Shared build identity for content and Relay modules, independent of their UI isolation. */ +export const getContentLayer = (world: ContentScriptWorld): string => { + return `${ContentLayerPrefix}${world.toLowerCase()}`; +}; + +export const isContentLayer = (layer: string): boolean => layer.startsWith(ContentLayerPrefix); diff --git a/src/cli/bundler/utils/output.ts b/src/cli/bundler/utils/output.ts index cc88d50d..2fce1c65 100644 --- a/src/cli/bundler/utils/output.ts +++ b/src/cli/bundler/utils/output.ts @@ -1,10 +1,12 @@ import type {AssetInfo, Chunk, Compilation, Filename, Module} from "@rspack/core"; import _ from "lodash"; import path from "path"; +import postcss from "postcss"; +import valueParser from "postcss-value-parser"; import {toPosix} from "@cli/utils/path"; -import type {EntrypointAssetsMap, EntrypointAssetsMapEntry} from "@typing/entrypoint"; +import type {EntrypointAssetsMap, EntrypointAssetsMapEntry, EntrypointAssetsFiles} from "@typing/entrypoint"; type AssetKind = "asset" | "css" | "js"; @@ -103,21 +105,69 @@ const collectResources = ( sourceMaps: ReadonlySet ): readonly string[] => { const candidates = new Map(); + const cssFiles = new Set(); + const sourceFiles = new Set(); for (const chunk of chunks) { for (const file of [...chunk.files, ...chunk.auxiliaryFiles]) { const normalized = toPosix(file); candidates.set(normalized, candidates.get(normalized) ?? false); + if (getAssetKind(compilation, normalized, sourceMaps) === "css") cssFiles.add(normalized); } for (const module of compilation.chunkGraph.getChunkModulesIterable(chunk)) { for (const file of getModuleAssetNames(module)) { candidates.set(toPosix(file), true); } + if (module.type === "css/mini-extract") { + for (const dependency of module.buildInfo?.fileDependencies ?? []) + sourceFiles.add(path.resolve(dependency)); + } } } + // CssExtract executes url() asset modules during the build. They are not + // runtime chunk modules, so follow the emitted CSS references as well. + // This only reads assets; it never rewrites CSS or its filenames. + const resources = compilation + .getAssets() + .filter( + asset => + getAssetKind(compilation, asset.name, sourceMaps) === "asset" && + (!asset.info.sourceFilename || + sourceFiles.has( + path.resolve(compilation.compiler.context, asset.info.sourceFilename.split(/[?#]/)[0]) + )) + ) + .map(asset => asset.name) + .sort((left, right) => right.length - left.length); + for (const file of cssFiles) { + const css = compilation.getAsset(file)?.source.source().toString(); + if (!css) continue; + postcss.parse(css, {from: file}).walkDecls(declaration => { + valueParser(declaration.value).walk(node => { + if (node.type !== "function" || node.value.toLowerCase() !== "url") return; + const argument = node.nodes[0]; + if (!argument || (argument.type !== "string" && argument.type !== "word")) return false; + const url = argument.value.replace(/\\([\da-f]{1,6}\s?|.)/gi, (_match, escape: string) => + /^[\da-f]{1,6}\s?$/i.test(escape) + ? String.fromCodePoint(parseInt(escape.trim(), 16) || 0xfffd) + : escape + ); + if (/^(?:data|blob):/i.test(url)) return false; + try { + const pathname = decodeURIComponent(new URL(url, `https://adnbn.invalid/${file}`).pathname); + const resource = resources.find(candidate => pathname.endsWith(`/${candidate}`)); + if (resource) candidates.set(resource, true); + } catch { + // External or malformed URLs do not name an emitted build resource. + } + return false; + }); + }); + } + return Array.from(candidates) .filter(([file, fromModuleAssets]) => { return getAssetKind(compilation, file, sourceMaps, fromModuleAssets) === "asset"; @@ -130,7 +180,7 @@ const collectCodeFiles = ( compilation: Compilation, files: Iterable, sourceMaps: ReadonlySet -): {css: readonly string[]; js: readonly string[]} => { +): EntrypointAssetsFiles => { const css = new Set(); const js = new Set(); @@ -153,7 +203,7 @@ const collectAsyncCodeFiles = ( chunks: Iterable, sourceMaps: ReadonlySet, initialFiles: ReadonlySet -): {css: readonly string[]; js: readonly string[]} => { +): EntrypointAssetsFiles => { const files = new Set(); for (const chunk of chunks) { diff --git a/src/cli/bundler/utils/styles.ts b/src/cli/bundler/utils/styles.ts new file mode 100644 index 00000000..2a07c1c1 --- /dev/null +++ b/src/cli/bundler/utils/styles.ts @@ -0,0 +1,31 @@ +import type {Chunk, Compilation, Module} from "@rspack/core"; + +// This is a bundler layer, not a CSS @layer. A distinct css-loader identity is +// also required: CssExtract deduplicates dependencies by their loader request. +export const IsolatedStylesLayer = "adnbn:css:isolation"; + +export const isIsolatedStylesModule = (module: Module): boolean => + module.type === "css/mini-extract" && module.layer === IsolatedStylesLayer; + +export const isIsolatedStylesChunk = (compilation: Compilation, chunk: Chunk): boolean => { + const modules = [...compilation.chunkGraph.getChunkModulesIterable(chunk)].filter( + module => module.type === "css/mini-extract" + ); + const isolated = modules.some(isIsolatedStylesModule); + if (isolated && !modules.every(isIsolatedStylesModule)) { + throw new Error( + `CSS chunk "${chunk.name ?? chunk.id}" mixes document and ?isolation styles. Preserve the adnbnIsolatedStyles CSS cache group in your bundler configuration.` + ); + } + return isolated; +}; + +export const getIsolatedStylesFiles = (compilation: Compilation): ReadonlySet => { + const files = new Set(); + for (const chunk of compilation.chunks) { + if (isIsolatedStylesChunk(compilation, chunk)) { + for (const file of chunk.files) if (file.endsWith(".css")) files.add(file); + } + } + return files; +}; diff --git a/src/cli/entrypoint/file/OptionFile.ts b/src/cli/entrypoint/file/OptionFile.ts index 755b022e..013c6c6d 100644 --- a/src/cli/entrypoint/file/OptionFile.ts +++ b/src/cli/entrypoint/file/OptionFile.ts @@ -32,6 +32,70 @@ export default class> extends SourceFile { return {...this.getOptionsFromVariables(), ...this.getOptionsFromDefinition()}; } + /** Presence is distinct from a statically evaluated value (for example a render method). */ + public getDeclaredProperties(): Set { + const names = new Set( + Array.from(this.getVariables().values()) + .filter(item => item.exported) + .map(item => item.name) + ); + for (const statement of this.getSourceFile().statements) { + if ( + ts.isFunctionDeclaration(statement) && + statement.name && + statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) && + !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword) + ) { + names.add(statement.name.text); + } + if (!ts.isExportAssignment(statement)) continue; + const expression = this.getExpressionForOptions(statement.expression); + if (!expression || !ts.isObjectLiteralExpression(expression)) continue; + for (const property of expression.properties) { + if (property.name && !ts.isComputedPropertyName(property.name)) + names.add(property.name.getText().replace(/^["']|["']$/g, "")); + } + } + return names; + } + + /** Returns the authored expression without confusing unresolved identifiers with string literals. */ + public getPropertyExpression(name: string): ts.Expression | undefined { + let result: ts.Expression | undefined; + for (const statement of this.getSourceFile().statements) { + if ( + ts.isVariableStatement(statement) && + statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) + ) { + const declaration = statement.declarationList.declarations.find(item => item.name.getText() === name); + if (declaration) result = declaration.initializer; + } + if (!ts.isExportAssignment(statement)) continue; + const expression = this.getExpressionForOptions(statement.expression); + if (!expression || !ts.isObjectLiteralExpression(expression)) continue; + const property = expression.properties.find( + item => + item.name && + !ts.isComputedPropertyName(item.name) && + item.name.getText().replace(/^["']|["']$/g, "") === name + ); + if (property && ts.isPropertyAssignment(property)) result = property.initializer; + else if (property && ts.isShorthandPropertyAssignment(property)) result = property.name; + } + const seen = new Set(); + while (result && ts.isIdentifier(result) && !seen.has(result.text)) { + const name = result.text; + seen.add(name); + const declaration = this.getSourceFile() + .statements.filter(ts.isVariableStatement) + .flatMap(statement => Array.from(statement.declarationList.declarations)) + .find(item => item.name.getText() === name); + if (!declaration?.initializer) break; + result = declaration.initializer; + } + return result; + } + public getDefinition(): string | undefined { if (this.definition === undefined) { this.getOptionsFromDefinition(); diff --git a/src/cli/entrypoint/file/SourceFile.ts b/src/cli/entrypoint/file/SourceFile.ts index 36411d7a..8103a7b2 100644 --- a/src/cli/entrypoint/file/SourceFile.ts +++ b/src/cli/entrypoint/file/SourceFile.ts @@ -295,6 +295,37 @@ export default class EntryFile { } } + /** Unlike parseNode's identifier fallback, proves a primitive comes from a static declaration. */ + public isStaticValue(node?: ts.Node, seen = new Set()): boolean { + if (!node) return false; + if ( + ts.isStringLiteral(node) || + ts.isNumericLiteral(node) || + node.kind === ts.SyntaxKind.TrueKeyword || + node.kind === ts.SyntaxKind.FalseKeyword + ) + return true; + if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node)) { + return this.isStaticValue(node.expression, seen); + } + if (ts.isPropertyAccessExpression(node)) { + const value = this.parseNode(node); + return (typeof value === "string" || typeof value === "number") && value !== node.getText(); + } + if (!ts.isIdentifier(node)) return false; + const key = `${this.file}:${node.text}`; + if (seen.has(key)) return false; + seen.add(key); + for (const statement of this.getSourceFile().statements) { + if (!ts.isVariableStatement(statement)) continue; + const declaration = statement.declarationList.declarations.find(item => item.name.getText() === node.text); + if (declaration) return this.isStaticValue(declaration.initializer, seen); + } + const imported = this.getImports().get(node.text); + if (!imported || !/\.[cm]?[jt]sx?$/.test(imported)) return false; + return EntryFile.make(imported).isStaticValue(node, seen); + } + protected getInputResolver(): ImportResolver { if (this.importResolver) { return this.importResolver; diff --git a/src/cli/entrypoint/file/injectors/core.ts b/src/cli/entrypoint/file/injectors/core.ts index b87462ab..bba2e82a 100644 --- a/src/cli/entrypoint/file/injectors/core.ts +++ b/src/cli/entrypoint/file/injectors/core.ts @@ -1,7 +1,13 @@ import {Command, Mode, PackageName} from "@typing/app"; import {Browser} from "@typing/browser"; import {RelayAllFrames, RelayMethod} from "@typing/relay"; -import {ContentScriptAppend, ContentScriptDeclarative, ContentScriptMarker, ContentScriptWorld} from "@typing/content"; +import { + ContentScriptIsolation, + ContentScriptAppend, + ContentScriptDeclarative, + ContentScriptMarker, + ContentScriptWorld, +} from "@typing/content"; import {OffscreenReason} from "@typing/offscreen"; import {SandboxAllow, SandboxSource} from "@typing/sandbox"; import {CspSource} from "@typing/csp"; @@ -65,6 +71,10 @@ export default (): Injector[] => { }); }); + Object.entries(ContentScriptIsolation).forEach(([key, value]) => { + resolvers.push({from: PackageName, target: "ContentScriptIsolation", name: key, value}); + }); + Object.entries(ContentScriptWorld).forEach(([key, value]) => { resolvers.push({ from: PackageName, diff --git a/src/cli/entrypoint/parser/ContentParser.test.ts b/src/cli/entrypoint/parser/ContentParser.test.ts index 95948cfa..1773e7e5 100644 --- a/src/cli/entrypoint/parser/ContentParser.test.ts +++ b/src/cli/entrypoint/parser/ContentParser.test.ts @@ -18,38 +18,70 @@ const file = (...parts: string[]) => { describe("ContentParser", () => { test.each([ - ["enum.content.ts", ContentScriptWorld.Main], - ["string.content.ts", ContentScriptWorld.Isolated], - ])("parses the execution world from %s", (name, world) => { - expect(parser.options(file("options", "world", name))).toEqual({ - matches: ["http://*/*", "https://*/*"], - runAt: "document_idle", - world, + "named-function", + "anonymous-function", + "arrow", + "local-component", + "imported-component", + "reexported-component", + "jsx", + ])("rejects frame navigation with default render: %s", name => { + const target = file("invalid", "default-render", name + ".content.tsx"); + expect(() => parser.options(target)).toThrow(/frame.page\/frame.src cannot be combined with render/); + expect(() => parser.options(target)).toThrow(target.file); + }); + test.each(["function.content.tsx", "object.content.ts", "local-object.content.ts", "imported-object.content.ts"])( + "preserves a valid default export in %s", + name => { + const options = parser.options(file("options", "default-render", name)); + expect(options.isolation).toBe(name === "function.content.tsx" ? "shadow" : "iframe"); + expect(options).not.toHaveProperty("render"); + } + ); + test("resolves a local frame object without treating identifiers as aliases", () => { + expect(parser.options(file("options", "isolation", "frame-constant.content.ts"))).toMatchObject({ + frame: {page: "panel", height: 320}, }); }); - - test.each([ - ["true.content.ts", true], - ["false.content.ts", false], - ])("parses the shadow literal from %s", (name, shadow) => { - expect(parser.options(file("options", "shadow", name))).toEqual({ + test("parses the isolation enum and frame dimensions", () => { + expect(parser.options(file("options", "isolation", "iframe.content.ts"))).toMatchObject({ + isolation: "iframe", + frame: {height: 320}, + }); + }); + test("projects the Shadow isolation mode without runtime properties", () => { + expect(parser.options(file("options", "isolation", "shadow.content.ts"))).toEqual({ matches: ["http://*/*", "https://*/*"], runAt: "document_idle", - shadow, + isolation: "shadow", }); }); - - test("collapses a shadow options object containing imported runtime values to one build bit", () => { - expect(parser.options(file("options", "shadow", "object", "entry.content.ts"))).toEqual({ + test("resolves a local page alias", () => { + expect(parser.options(file("options", "isolation", "page.content.ts"))).toMatchObject({ + frame: {page: "panel", width: "80%"}, + }); + }); + test.each([ + ["frame-identifier-page", /statically known string/], + ["frame-without-isolation", /frame requires/], + ["frame-conflict", /mutually exclusive/], + ["frame-render", /cannot be combined/], + ["frame-auto", /not supported yet/], + ["frame-spread", /explicit object/], + ["isolation-dynamic", /statically known/], + ["frame-dynamic-page", /statically known string/], + ["frame-dynamic-src", /statically known string/], + ])("rejects %s with an actionable diagnostic", (name, error) => { + expect(() => parser.options(file("invalid", name + ".content.ts"))).toThrow(error); + }); + test.each([ + ["enum.content.ts", ContentScriptWorld.Main], + ["string.content.ts", ContentScriptWorld.Isolated], + ])("parses the execution world from %s", (name, world) => { + expect(parser.options(file("options", "world", name))).toEqual({ matches: ["http://*/*", "https://*/*"], runAt: "document_idle", - shadow: true, + world, }); }); - - test("rejects a shadow flag that cannot be evaluated during the build", () => { - expect(() => parser.options(file("invalid", "shadow-identifier.content.ts"))).toThrow( - /shadow must be a boolean literal or an object/ - ); - }); }); diff --git a/src/cli/entrypoint/parser/ContentParser.ts b/src/cli/entrypoint/parser/ContentParser.ts index 467fc475..3daaf5e2 100644 --- a/src/cli/entrypoint/parser/ContentParser.ts +++ b/src/cli/entrypoint/parser/ContentParser.ts @@ -1,6 +1,10 @@ +import ts from "typescript"; +import path from "path"; +import {isContentScriptFrameNavigation, validateContentScriptIsolation} from "@shared/content"; import z from "zod"; import AbstractParser from "./AbstractParser"; +import {TsResolver, type OptionFile} from "../file"; import { ContentScriptDeclarative, @@ -29,19 +33,67 @@ export default class true)]).optional(), + isolation: z.enum(["none", "shadow", "iframe"]).optional(), + frame: z + .object({ + page: z.string().optional(), + src: z.string().optional(), + width: z.union([z.number(), z.string()]).optional(), + height: z.union([z.number(), z.string()]).optional(), + }) + .strict() + .optional(), }); } public options(file: EntrypointFile): O { - const shadow = this.getOptions(file).shadow; - - if ( - shadow !== undefined && - typeof shadow !== "boolean" && - (typeof shadow !== "object" || shadow === null || Array.isArray(shadow)) - ) { - throw new Error(`Invalid options shadow in "${file.file}": shadow must be a boolean literal or an object`); + if ("isolation" in this.schema().shape) { + const optionFile = this.optionFile(file); + const declared = optionFile.getDeclaredProperties(); + const values = optionFile.getOptions(); + try { + if ( + declared.has("isolation") && + !optionFile.isStaticValue(optionFile.getPropertyExpression("isolation")) + ) + throw new Error("isolation must be statically known"); + if (declared.has("frame") && values.frame === undefined) + throw new Error("frame must be a statically known object"); + const frame = optionFile.getPropertyExpression("frame"); + if (frame) { + if ( + !ts.isObjectLiteralExpression(frame) || + frame.properties.some( + property => + !ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property) + ) + ) { + throw new Error( + "frame must be an explicit object without spreads or methods so its page/src variant can be determined during the build" + ); + } + for (const property of frame.properties) { + if (!property.name || ts.isComputedPropertyName(property.name)) + throw new Error("frame keys must be statically known"); + const key = property.name.getText().replace(/^["']|["']$/g, ""); + const initializer = ts.isPropertyAssignment(property) ? property.initializer : property.name; + if ( + (key === "page" || key === "src") && + (!optionFile.isStaticValue(initializer) || typeof values.frame?.[key] !== "string") + ) { + throw new Error(`frame.${key} must be a statically known string`); + } + } + } + const hasRender = + declared.has("render") || + (isContentScriptFrameNavigation(values.frame) && this.hasDefaultRender(file, optionFile)); + validateContentScriptIsolation(values.isolation, values.frame, hasRender); + } catch (error) { + throw new Error( + `Invalid content isolation in "${file.file}": ${error instanceof Error ? error.message : String(error)}` + ); + } } const options = super.options(file); @@ -52,4 +104,66 @@ export default class>): boolean { + const source = optionFile.getSourceFile(); + const declaration = source.statements.find( + statement => + (ts.isExportAssignment(statement) && !statement.isExportEquals) || + (ts.canHaveModifiers(statement) && + ts.getModifiers(statement)?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) || + (ts.isExportDeclaration(statement) && + !statement.isTypeOnly && + statement.exportClause && + ts.isNamedExports(statement.exportClause) && + statement.exportClause.elements.some(item => !item.isTypeOnly && item.name.text === "default")) + ); + if (!declaration) return false; + if (ts.isFunctionDeclaration(declaration) || ts.isClassDeclaration(declaration)) return true; + + if (ts.isExportAssignment(declaration)) { + let expression = declaration.expression; + while ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) || + ts.isTypeAssertionExpression(expression) || + ts.isNonNullExpression(expression) + ) { + expression = expression.expression; + } + if ( + ts.isArrowFunction(expression) || + ts.isFunctionExpression(expression) || + ts.isClassExpression(expression) || + ts.isJsxElement(expression) || + ts.isJsxSelfClosingElement(expression) || + ts.isJsxFragment(expression) + ) + return true; + if (ts.isObjectLiteralExpression(expression)) return optionFile.getDeclaredProperties().has("$$typeof"); + if (optionFile.getDefinition()) return false; + } + + // Resolve aliases/imported components without assuming every identifier is a render value. + // This program is local to this check; watch rebuilds never reuse a stale export type. + const options = TsResolver.make(path.resolve(this.config.rootDir, "tsconfig.json")).getConfig().options; + const program = ts.createProgram([file.file], {...options, noEmit: true}); + const entry = program.getSourceFile(file.file); + if (!entry) return false; + const checker = program.getTypeChecker(); + const module = checker.getSymbolAtLocation(entry); + const exported = module && checker.getExportsOfModule(module).find(symbol => symbol.name === "default"); + if (!exported) return false; + const symbol = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported; + const type = checker.getTypeOfSymbolAtLocation(symbol, entry); + return ( + type.getCallSignatures().length > 0 || + type.getConstructSignatures().length > 0 || + type.getProperty("$$typeof") !== undefined || + (type.isStringLiteral() ? type.value.length > 0 : (type.flags & ts.TypeFlags.StringLike) !== 0) || + (type.flags & ts.TypeFlags.NumberLike) !== 0 + ); + } } diff --git a/src/cli/entrypoint/parser/RelayParser.test.ts b/src/cli/entrypoint/parser/RelayParser.test.ts index 06330be8..fc165d87 100644 --- a/src/cli/entrypoint/parser/RelayParser.test.ts +++ b/src/cli/entrypoint/parser/RelayParser.test.ts @@ -19,6 +19,25 @@ const file = (...parts: string[]) => { }; describe("RelayParser", () => { + test.each(["function.relay.ts", "named-render.relay.ts", "imported.relay.ts"])( + "treats %s as init rather than a content renderer", + name => { + expect(parser.options(file("options", "default-init", name))).toMatchObject({ + isolation: "iframe", + frame: {page: "panel"}, + }); + } + ); + test("still rejects an explicit Relay renderer alongside frame navigation", () => { + expect(() => parser.options(file("invalid", "frame-render.relay.ts"))).toThrow( + /cannot be combined with render/ + ); + }); + test("preserves Relay isolation and frame build options", () => { + const options = parser.options(file("options", "isolation", "relay.ts")); + expect(options).toHaveProperty("isolation", "iframe"); + expect(options).toHaveProperty("frame", {page: "panel"}); + }); test("parses the all-frame response capability from a real entrypoint file", () => { expect(parser.options(file("options", "all-frames", "relay.ts"))).toEqual( expect.objectContaining({ @@ -27,8 +46,4 @@ describe("RelayParser", () => { }) ); }); - - test("does not project the content shadow option into Relay build options", () => { - expect(parser.options(file("options", "shadow", "relay.ts"))).not.toHaveProperty("shadow"); - }); }); diff --git a/src/cli/entrypoint/parser/RelayParser.ts b/src/cli/entrypoint/parser/RelayParser.ts index f67a7e20..26555352 100644 --- a/src/cli/entrypoint/parser/RelayParser.ts +++ b/src/cli/entrypoint/parser/RelayParser.ts @@ -15,24 +15,26 @@ export default class extends ContentParser { return "init"; } + /** A Relay's default function initializes its transport; it is never an implicit content renderer. */ + protected hasDefaultRender(): boolean { + return false; + } + protected schema() { - return super - .schema() - .omit({shadow: true}) - .extend({ - allFrames: z.union([z.boolean(), z.nativeEnum(RelayAllFrames)]).optional(), - name: z - .string() - .trim() - .min(1) - .max(100) - .regex(/^[\p{L}_$][\p{L}\p{N}_$]*$/u, { - message: - "Key must start with a Unicode letter, `$` or `_`, and may only contain letters, digits, `$` or `_`", - }) - .optional(), - method: z.nativeEnum(RelayMethod).optional(), - }); + return super.schema().extend({ + allFrames: z.union([z.boolean(), z.nativeEnum(RelayAllFrames)]).optional(), + name: z + .string() + .trim() + .min(1) + .max(100) + .regex(/^[\p{L}_$][\p{L}\p{N}_$]*$/u, { + message: + "Key must start with a Unicode letter, `$` or `_`, and may only contain letters, digits, `$` or `_`", + }) + .optional(), + method: z.nativeEnum(RelayMethod).optional(), + }); } public options(file: EntrypointFile): RelayEntrypointOptions { diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/Panel.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/Panel.tsx new file mode 100644 index 00000000..fcdd5958 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/Panel.tsx @@ -0,0 +1,3 @@ +export default function Panel() { + return null; +} diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/anonymous-function.content.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/anonymous-function.content.tsx new file mode 100644 index 00000000..36a96ba3 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/anonymous-function.content.tsx @@ -0,0 +1,6 @@ +export const isolation = "iframe"; +export const frame = {src: "https://example.com/panel"}; + +export default function () { + return null; +} diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/arrow.content.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/arrow.content.tsx new file mode 100644 index 00000000..5d46ebef --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/arrow.content.tsx @@ -0,0 +1,4 @@ +export const isolation = "iframe"; +export const frame = {src: "https://example.com/panel"}; + +export default () => null; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/imported-component.content.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/imported-component.content.tsx new file mode 100644 index 00000000..e8206004 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/imported-component.content.tsx @@ -0,0 +1,5 @@ +import Panel from "./Panel"; + +export const isolation = "iframe"; +export const frame = {page: "panel"}; +export default Panel; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/jsx.content.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/jsx.content.tsx new file mode 100644 index 00000000..df2566b9 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/jsx.content.tsx @@ -0,0 +1,5 @@ +import React from "react"; + +export const isolation = "iframe"; +export const frame = {page: "panel"}; +export default ; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/local-component.content.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/local-component.content.tsx new file mode 100644 index 00000000..b4be450b --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/local-component.content.tsx @@ -0,0 +1,5 @@ +export const isolation = "iframe"; +export const frame = {page: "panel"}; + +const Panel = () => null; +export default Panel satisfies () => null; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/named-function.content.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/named-function.content.tsx new file mode 100644 index 00000000..089fe349 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/named-function.content.tsx @@ -0,0 +1,6 @@ +export const isolation = "iframe"; +export const frame = {page: "panel"}; + +export default function Panel() { + return null; +} diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/reexported-component.content.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/reexported-component.content.tsx new file mode 100644 index 00000000..c5237e5c --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/reexported-component.content.tsx @@ -0,0 +1,3 @@ +export const isolation = "iframe"; +export const frame = {page: "panel"}; +export {default} from "./Panel"; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-auto.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-auto.content.ts new file mode 100644 index 00000000..3bdb54c3 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-auto.content.ts @@ -0,0 +1,2 @@ +import {defineContentScriptAppend} from "adnbn"; +export default defineContentScriptAppend({isolation: "iframe", frame: {height: "auto"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-conflict.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-conflict.content.ts new file mode 100644 index 00000000..d7fa81f2 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-conflict.content.ts @@ -0,0 +1,2 @@ +import {defineContentScriptAppend} from "adnbn"; +export default defineContentScriptAppend({isolation: "iframe", frame: {page: "panel", src: "https://example.com"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-dynamic-page.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-dynamic-page.content.ts new file mode 100644 index 00000000..e045ed59 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-dynamic-page.content.ts @@ -0,0 +1,2 @@ +import {defineContentScriptAppend} from "adnbn"; +export default defineContentScriptAppend({isolation: "iframe", frame: {page: computeTarget()}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-dynamic-src.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-dynamic-src.content.ts new file mode 100644 index 00000000..2196908a --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-dynamic-src.content.ts @@ -0,0 +1,2 @@ +import {defineContentScriptAppend} from "adnbn"; +export default defineContentScriptAppend({isolation: "iframe", frame: {src: computeTarget()}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-identifier-page.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-identifier-page.content.ts new file mode 100644 index 00000000..26825163 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-identifier-page.content.ts @@ -0,0 +1,3 @@ +import {defineContentScriptAppend} from "adnbn"; +// An unresolved identifier must not be confused with an alias that happens to have this name. +export default defineContentScriptAppend({isolation: "iframe", frame: {page: panel}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-render.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-render.content.ts new file mode 100644 index 00000000..066f7143 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-render.content.ts @@ -0,0 +1,8 @@ +import {defineContentScriptAppend} from "adnbn"; +export default defineContentScriptAppend({ + isolation: "iframe", + frame: {page: "panel"}, + render() { + return null; + }, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-spread.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-spread.content.ts new file mode 100644 index 00000000..e3663c4a --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-spread.content.ts @@ -0,0 +1,2 @@ +import {defineContentScriptAppend} from "adnbn"; +export default defineContentScriptAppend({isolation: "iframe", frame: {...settings}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-without-isolation.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-without-isolation.content.ts new file mode 100644 index 00000000..c33143d2 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-without-isolation.content.ts @@ -0,0 +1,2 @@ +import {defineContentScriptAppend} from "adnbn"; +export default defineContentScriptAppend({frame: {height: 300}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-dynamic.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-dynamic.content.ts new file mode 100644 index 00000000..d15a7a1f --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-dynamic.content.ts @@ -0,0 +1,2 @@ +import {defineContentScriptAppend} from "adnbn"; +export default defineContentScriptAppend({isolation: Math.random() ? "iframe" : "none"}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow-identifier.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow-identifier.content.ts deleted file mode 100644 index 65cccf2f..00000000 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow-identifier.content.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {defineContentScript} from "adnbn"; - -declare const SOME_IMPORTED_FLAG: boolean; - -export default defineContentScript({ - shadow: SOME_IMPORTED_FLAG, -}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/function.content.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/function.content.tsx new file mode 100644 index 00000000..90548efa --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/function.content.tsx @@ -0,0 +1,5 @@ +export const isolation = "shadow"; + +export default function Panel() { + return null; +} diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/imported-object.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/imported-object.content.ts new file mode 100644 index 00000000..33e51c93 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/imported-object.content.ts @@ -0,0 +1,5 @@ +import options from "./options"; + +export const isolation = "iframe"; +export const frame = {page: "panel"}; +export default options; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/local-object.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/local-object.content.ts new file mode 100644 index 00000000..63d5ab3f --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/local-object.content.ts @@ -0,0 +1,5 @@ +export const isolation = "iframe"; +export const frame = {page: "panel"}; + +const options = {watch: true}; +export default options; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/object.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/object.content.ts new file mode 100644 index 00000000..7c37a9b7 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/object.content.ts @@ -0,0 +1 @@ +export default {isolation: "iframe", frame: {page: "panel"}}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/options.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/options.ts new file mode 100644 index 00000000..2a09676b --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/options.ts @@ -0,0 +1 @@ +export default {watch: true}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/frame-constant.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/frame-constant.content.ts new file mode 100644 index 00000000..987834a4 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/frame-constant.content.ts @@ -0,0 +1,4 @@ +import {defineContentScriptAppend} from "adnbn"; +const page = "panel"; +const frame = {page, height: 320}; +export default defineContentScriptAppend({isolation: "iframe", frame}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe.content.ts new file mode 100644 index 00000000..50c47549 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe.content.ts @@ -0,0 +1,6 @@ +import {ContentScriptIsolation, defineContentScriptAppend} from "adnbn"; + +export default defineContentScriptAppend({ + isolation: ContentScriptIsolation.Iframe, + frame: {height: 320}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/page.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/page.content.ts new file mode 100644 index 00000000..ee66d26a --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/page.content.ts @@ -0,0 +1,3 @@ +import {defineContentScriptAppend} from "adnbn"; +const alias = "panel"; +export default defineContentScriptAppend({isolation: "iframe", frame: {page: alias, width: "80%"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/shadow.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/shadow.content.ts new file mode 100644 index 00000000..3cc926ae --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/shadow.content.ts @@ -0,0 +1,2 @@ +import {defineContentScriptAppend} from "adnbn"; +export default defineContentScriptAppend({isolation: "shadow"}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/false.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/false.content.ts deleted file mode 100644 index 8e00d815..00000000 --- a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/false.content.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {defineContentScript} from "adnbn"; - -export default defineContentScript({ - shadow: false, -}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/object/entry.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/object/entry.content.ts deleted file mode 100644 index 839a1d57..00000000 --- a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/object/entry.content.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {defineContentScript} from "adnbn"; - -import panelFont from "./panel.woff2"; - -export default defineContentScript({ - shadow: { - fonts: [ - { - family: "AdnbnPanelInter", - source: panelFont, - weight: "400", - }, - ], - }, -}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/object/panel.woff2 b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/object/panel.woff2 deleted file mode 100644 index e69de29b..00000000 diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/true.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/true.content.ts deleted file mode 100644 index cc0c1422..00000000 --- a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/true.content.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {defineContentScript} from "adnbn"; - -export default defineContentScript({ - shadow: true, -}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/invalid/frame-render.relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/invalid/frame-render.relay.ts new file mode 100644 index 00000000..7b1f005b --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/invalid/frame-render.relay.ts @@ -0,0 +1,6 @@ +export const isolation = "iframe"; +export const frame = {page: "panel"}; +export function render() { + return "UI"; +} +export default () => ({ping: () => "pong"}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/function.relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/function.relay.ts new file mode 100644 index 00000000..2723fff0 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/function.relay.ts @@ -0,0 +1,6 @@ +export const isolation = "iframe"; +export const frame = {page: "panel"}; + +export default function init() { + return {ping: () => "pong"}; +} diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/imported.relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/imported.relay.ts new file mode 100644 index 00000000..f7e9a349 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/imported.relay.ts @@ -0,0 +1,5 @@ +import init from "./init"; + +export const isolation = "iframe"; +export const frame = {page: "panel"}; +export default init; diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/init.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/init.ts new file mode 100644 index 00000000..99c38a8c --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/init.ts @@ -0,0 +1 @@ +export default () => ({ping: () => "pong"}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/named-render.relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/named-render.relay.ts new file mode 100644 index 00000000..b1ede486 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/named-render.relay.ts @@ -0,0 +1,6 @@ +export const isolation = "iframe"; +export const frame = {page: "panel"}; + +export default function render() { + return {ping: () => "pong"}; +} diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/relay.ts new file mode 100644 index 00000000..5c8f5ef0 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/relay.ts @@ -0,0 +1,9 @@ +import {defineRelay} from "adnbn"; + +export default defineRelay({ + isolation: "iframe", + frame: {page: "panel"}, + init() { + return {}; + }, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/options/shadow/relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/options/shadow/relay.ts deleted file mode 100644 index 3b04c0a6..00000000 --- a/src/cli/entrypoint/parser/tests/fixtures/relay/options/shadow/relay.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {defineRelay} from "adnbn"; - -export default defineRelay({ - // @ts-expect-error Relay entrypoints do not support Shadow DOM rendering. - shadow: true, - init: () => ({scan: () => true}), -}); diff --git a/src/cli/plugins/asset.ts b/src/cli/plugins/asset.ts index cc2f9c36..51432d4f 100644 --- a/src/cli/plugins/asset.ts +++ b/src/cli/plugins/asset.ts @@ -19,27 +19,29 @@ export default definePlugin(() => { module: { rules: [ { - test: /\.(woff2?|eot|ttf|otf)$/i, - type: "asset/resource", - }, - { - test: /\.(png|apng|jpe?g|gif|webp|svg|avif|tiff|bmp|ico)$/i, + // Common binary resources work without a query. An explicit asset + // query also supports less common file formats, without hijacking code. + test: (file: string) => + !/\.(?:[cm]?[jt]sx?|json|css|s[ac]ss|less|styl|vue|svelte)$/i.test(file), resourceQuery: { - not: [/react/], + not: [/[?&]react(?:[=&]|$)/, /[?&]raw(?:[=&]|$)/], }, oneOf: [ { - resourceQuery: /(chrome|browser)/, + resourceQuery: /[?&](chrome|browser)(?:[=&]|$)/, type: "asset/resource", + // Both queries explicitly opt into an extension URL. Native CSS + // localization substitutes the installed extension ID/UUID on load. generator: { publicPath: `${browser === Browser.Firefox ? "moz" : "chrome"}-extension://__MSG_@@extension_id__/`, }, }, { - resourceQuery: /base64/, + resourceQuery: /[?&]base64(?:[=&]|$)/, type: "asset/inline", }, { + test: /\.(png|apng|jpe?g|gif|webp|svg|avif|tiff|bmp|ico|woff2?|eot|ttf|otf|mp3|wav|ogg|oga|opus|flac|aac|m4a|mp4|webm|ogv|mov|pdf|zip|gz|wasm)$/i, type: "asset/resource", }, ], diff --git a/src/cli/plugins/content/Content.ts b/src/cli/plugins/content/Content.ts index 881bc1e6..8570b434 100644 --- a/src/cli/plugins/content/Content.ts +++ b/src/cli/plugins/content/Content.ts @@ -4,6 +4,7 @@ import {ContentProvider} from "./types"; import {ContentFinder} from "@cli/entrypoint"; import {virtualContentScriptModule} from "@cli/virtual"; +import {isContentScriptFrameNavigation} from "@shared/content"; import {ReadonlyConfig} from "@typing/config"; import {ContentScriptEntrypointOptions} from "@typing/content"; @@ -20,12 +21,12 @@ export default class extends ContentFinder implements ContentProvider { } class ProviderFixture implements ContentProvider { - public constructor(private readonly contentDriver: DriverFixture) {} + public constructor( + private readonly contentDriver: DriverFixture, + private readonly createVirtual: ContentProvider["virtual"] = () => "" + ) {} - public virtual(_file: EntrypointFile): string { - return ""; + public virtual(file: EntrypointFile, options?: ContentScriptEntrypointOptions): string { + return this.createVirtual(file, options); } public driver(): DriverFixture { @@ -47,6 +50,48 @@ class ProviderFixture implements ContentProvider } } +describe("ContentManager virtual modules", () => { + test("requires a prepared group and a matching file before calling a provider", async () => { + const file = {file: "panel.content.ts", import: "./panel.content"}; + const options: ContentScriptEntrypointOptions = {isolation: "iframe", frame: {page: "panel"}}; + const createVirtual = jest.fn((_file, options) => JSON.stringify(options)); + const driver = new DriverFixture(new Set(), new Set(), new Map([["panel", {file, options}]])); + const manager = new ContentManager({rootDir: process.cwd()} as ReadonlyConfig).provider( + new ProviderFixture(driver, createVirtual) + ); + + expect(() => manager.virtual(file)).toThrow(/group is not prepared/); + expect(createVirtual).not.toHaveBeenCalled(); + + await manager.entries(); + expect(manager.virtual({...file})).toBe(JSON.stringify(options)); + expect(createVirtual).toHaveBeenCalledWith(file, options); + expect(() => manager.virtual({file: "missing.content.ts", import: "./missing.content"})).toThrow( + /missing.content.ts.*not in the prepared content group/ + ); + expect(createVirtual).toHaveBeenCalledTimes(1); + }); + + test("requires preparation again after clear and uses the rebuilt options", async () => { + const file = {file: "changing.content.ts", import: "./changing.content"}; + const driver = new DriverFixture( + new Set(), + new Set(), + new Map([["changing", {file, options: {isolation: "iframe", frame: {src: "https://example.com"}}}]]) + ); + const manager = new ContentManager({rootDir: process.cwd()} as ReadonlyConfig).provider( + new ProviderFixture(driver, (_file, options) => JSON.stringify(options)) + ); + await manager.entries(); + expect(JSON.parse(manager.virtual(file))).toHaveProperty("frame.src", "https://example.com"); + manager.clear(); + expect(() => manager.virtual(file)).toThrow(/group is not prepared/); + driver.setItems(new Map([["changing", {file, options: {isolation: "shadow"}}]])); + await manager.entries(); + expect(JSON.parse(manager.virtual(file))).toEqual({isolation: "shadow"}); + }); +}); + describe("ContentManager permissions", () => { test("aggregates driver permissions and gives required permissions precedence", async () => { const manager = new ContentManager({rootDir: process.cwd()} as ReadonlyConfig) @@ -73,6 +118,19 @@ describe("ContentManager permissions", () => { }); describe("ContentManager execution worlds", () => { + test.each([undefined, {page: "panel"}, {src: "chrome-extension://fixture/panel.html"}])( + "rejects a non-HTTP frame in MV3 MAIN (%j)", + async frame => { + const file = {file: "frame.content.ts", import: "./frame.content"}; + const items: ContentItems = new Map([ + ["frame", {file, options: {isolation: "iframe", frame, world: ContentScriptWorld.Main}}], + ]); + const manager = new ContentManager({manifestVersion: 3, rootDir: process.cwd()} as ReadonlyConfig).provider( + new ProviderFixture(new DriverFixture(new Set(), new Set(), items)) + ); + await expect(manager.entries()).rejects.toThrow(/only external HTTP\(S\) frame.src/); + } + ); test("normalizes MV2 worlds before grouping and warns without changing provider options", async () => { const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); const main = {file: "main.content.ts", import: "./main.content"}; @@ -90,8 +148,8 @@ describe("ContentManager execution worlds", () => { try { await expect(manager.entries()).resolves.toEqual(new Map([["main.content", new Set([main, isolated])]])); - await expect(manager.entryWorlds()).resolves.toEqual( - new Map([["main.content", ContentScriptWorld.Isolated]]) + await expect(manager.entryOptions()).resolves.toEqual( + new Map([["main.content", expect.objectContaining({world: ContentScriptWorld.Isolated})]]) ); expect(Array.from(await manager.manifest())).toEqual([ expect.objectContaining({entry: "main.content", world: ContentScriptWorld.Isolated}), @@ -117,16 +175,16 @@ describe("ContentManager execution worlds", () => { } as ReadonlyConfig).provider(new ProviderFixture(driver)); try { - await expect(manager.entryWorlds()).resolves.toEqual( - new Map([["changing.content", ContentScriptWorld.Isolated]]) + await expect(manager.entryOptions()).resolves.toEqual( + new Map([["changing.content", {world: ContentScriptWorld.Isolated}]]) ); expect(warn).not.toHaveBeenCalled(); driver.setItems(new Map([["changing", {file, options: {world: ContentScriptWorld.Main}}]])); manager.clear(); - await expect(manager.entryWorlds()).resolves.toEqual( - new Map([["changing.content", ContentScriptWorld.Isolated]]) + await expect(manager.entryOptions()).resolves.toEqual( + new Map([["changing.content", {world: ContentScriptWorld.Isolated}]]) ); await manager.entries(); await manager.manifest(); @@ -134,14 +192,14 @@ describe("ContentManager execution worlds", () => { driver.setItems(new Map([["changing", {file, options: {world: ContentScriptWorld.Isolated}}]])); manager.clear(); - await manager.entryWorlds(); + await manager.entryOptions(); expect(warn).toHaveBeenCalledTimes(1); } finally { warn.mockRestore(); } }); - test("treats an omitted world as ISOLATED and keeps MAIN entries separate", async () => { + test("preserves the default world and keeps MAIN entries separate", async () => { const isolated = {file: "isolated.content.ts", import: "./isolated.content"}; const main = {file: "main.content.ts", import: "./main.content"}; const reservedMain = {file: "common-main.content.ts", import: "./common-main.content"}; @@ -155,11 +213,11 @@ describe("ContentManager execution worlds", () => { rootDir: process.cwd(), } as ReadonlyConfig).provider(new ProviderFixture(new DriverFixture(new Set(), new Set(), items))); - await expect(manager.entryWorlds()).resolves.toEqual( + await expect(manager.entryOptions()).resolves.toEqual( new Map([ - ["isolated.content", ContentScriptWorld.Isolated], - ["main.content", ContentScriptWorld.Main], - ["common-main1.content", ContentScriptWorld.Main], + ["isolated.content", {}], + ["main.content", {world: ContentScriptWorld.Main}], + ["common-main1.content", {world: ContentScriptWorld.Main}], ]) ); }); @@ -186,7 +244,7 @@ describe("ContentManager execution worlds", () => { const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); const file = {file: "shadow.content.ts", import: "./shadow.content"}; const items: ContentItems = new Map([ - ["shadow", {file, options: {shadow: true, world: ContentScriptWorld.Main}}], + ["shadow", {file, options: {isolation: "shadow", world: ContentScriptWorld.Main}}], ]); const manager = new ContentManager({ manifestVersion: 2, @@ -195,10 +253,9 @@ describe("ContentManager execution worlds", () => { } as ReadonlyConfig).provider(new ProviderFixture(new DriverFixture(new Set(), new Set(), items))); try { - await expect(manager.entryWorlds()).resolves.toEqual( - new Map([["shadow.content", ContentScriptWorld.Isolated]]) + await expect(manager.entryOptions()).resolves.toEqual( + new Map([["shadow.content", {world: ContentScriptWorld.Isolated, isolation: "shadow"}]]) ); - await expect(manager.entryShadows()).resolves.toEqual(new Map([["shadow.content", true]])); } finally { warn.mockRestore(); } @@ -207,7 +264,7 @@ describe("ContentManager execution worlds", () => { test("rejects Shadow DOM in the MAIN world for Manifest V3", async () => { const file = {file: "shadow.content.ts", import: "./shadow.content"}; const items: ContentItems = new Map([ - ["shadow", {file, options: {shadow: true, world: ContentScriptWorld.Main}}], + ["shadow", {file, options: {isolation: "shadow", world: ContentScriptWorld.Main}}], ]); const manager = new ContentManager({ manifestVersion: 3, @@ -215,11 +272,32 @@ describe("ContentManager execution worlds", () => { rootDir: process.cwd(), } as ReadonlyConfig).provider(new ProviderFixture(new DriverFixture(new Set(), new Set(), items))); - await expect(manager.entries()).rejects.toThrow(/cannot use Shadow DOM in the MAIN execution world/); + await expect(manager.entries()).rejects.toThrow(/cannot use this isolation in the MAIN execution world/); }); }); describe("ContentManager Shadow DOM entries", () => { + test("keeps every iframe variant separate even with concatenation enabled", async () => { + const frames = [undefined, {height: 200}, {page: "panel"}, {src: "https://example.com/panel"}]; + const items: ContentItems = new Map( + frames.map((frame, index) => [ + `frame-${index}`, + { + file: {file: `frame-${index}.content.ts`, import: `./frame-${index}.content`}, + options: {isolation: "iframe", frame}, + }, + ]) + ); + const manager = new ContentManager({ + manifestVersion: 3, + concatContentScripts: true, + rootDir: process.cwd(), + } as ReadonlyConfig).provider(new ProviderFixture(new DriverFixture(new Set(), new Set(), items))); + expect((await manager.entries()).size).toBe(4); + expect(Array.from((await manager.entryOptions()).values())).toEqual( + frames.map(frame => ({isolation: "iframe", frame})) + ); + }); test("never concatenates shadow entries and keeps ordinary entries eligible for concatenation", async () => { const matches = ["https://example.com/*"]; const normalA = {file: "normal-a.content.ts", import: "./normal-a.content"}; @@ -229,8 +307,8 @@ describe("ContentManager Shadow DOM entries", () => { const items: ContentItems = new Map([ ["normal-a", {file: normalA, options: {matches}}], ["normal-b", {file: normalB, options: {matches}}], - ["shadow-a", {file: shadowA, options: {matches, shadow: true}}], - ["shadow-b", {file: shadowB, options: {matches, shadow: true}}], + ["shadow-a", {file: shadowA, options: {matches, isolation: "shadow"}}], + ["shadow-b", {file: shadowB, options: {matches, isolation: "shadow"}}], ]); const manager = new ContentManager({ manifestVersion: 3, @@ -245,11 +323,11 @@ describe("ContentManager Shadow DOM entries", () => { ["shadow-b.content", new Set([shadowB])], ]) ); - await expect(manager.entryShadows()).resolves.toEqual( + await expect(manager.entryOptions()).resolves.toEqual( new Map([ - ["normal-a.content", false], - ["shadow-a.content", true], - ["shadow-b.content", true], + ["normal-a.content", {matches}], + ["shadow-a.content", {matches, isolation: "shadow"}], + ["shadow-b.content", {matches, isolation: "shadow"}], ]) ); }); @@ -259,7 +337,7 @@ describe("ContentManager Shadow DOM entries", () => { const driver = new DriverFixture( new Set(), new Set(), - new Map([["changing", {file, options: {shadow: true}}]]) + new Map([["changing", {file, options: {isolation: "shadow"}}]]) ); const manager = new ContentManager({ manifestVersion: 3, @@ -267,23 +345,28 @@ describe("ContentManager Shadow DOM entries", () => { rootDir: process.cwd(), } as ReadonlyConfig).provider(new ProviderFixture(driver)); - await expect(manager.entryShadows()).resolves.toEqual(new Map([["changing.content", true]])); - await expect(manager.manifest()).resolves.toEqual( - new Set([expect.objectContaining({entry: "changing.content", shadow: true})]) + await expect(manager.entryOptions()).resolves.toEqual(new Map([["changing.content", {isolation: "shadow"}]])); + const manifest = await manager.manifest(); + expect(manifest).toEqual( + new Set([ + { + entry: "changing.content", + matches: undefined, + excludeMatches: undefined, + includeGlobs: undefined, + excludeGlobs: undefined, + }, + ]) ); - driver.setItems(new Map([["changing", {file, options: {shadow: false}}]])); + driver.setItems(new Map([["changing", {file, options: {isolation: "none"}}]])); manager.clear(); - await expect(manager.entryShadows()).resolves.toEqual(new Map([["changing.content", false]])); - await expect(manager.manifest()).resolves.toEqual( - new Set([expect.objectContaining({entry: "changing.content", shadow: false})]) - ); + await expect(manager.entryOptions()).resolves.toEqual(new Map([["changing.content", {isolation: "none"}]])); + await expect(manager.manifest()).resolves.toEqual(manifest); - driver.setItems(new Map([["changing", {file, options: {shadow: true}}]])); + driver.setItems(new Map([["changing", {file, options: {isolation: "shadow"}}]])); manager.clear(); - await expect(manager.entryShadows()).resolves.toEqual(new Map([["changing.content", true]])); - await expect(manager.manifest()).resolves.toEqual( - new Set([expect.objectContaining({entry: "changing.content", shadow: true})]) - ); + await expect(manager.entryOptions()).resolves.toEqual(new Map([["changing.content", {isolation: "shadow"}]])); + await expect(manager.manifest()).resolves.toEqual(manifest); }); }); diff --git a/src/cli/plugins/content/ContentManager.ts b/src/cli/plugins/content/ContentManager.ts index 8906c569..e5be6939 100644 --- a/src/cli/plugins/content/ContentManager.ts +++ b/src/cli/plugins/content/ContentManager.ts @@ -1,11 +1,13 @@ import ContentName from "./ContentName"; import {ContentGroupItems, ContentProvider} from "./types"; -import {getContentChunkName, getContentScriptConfigFromOptions} from "./utils"; +import {getContentScriptConfigFromOptions, hasIsolatedTarget} from "./utils"; +import {getContentChunkName} from "./bundler"; import {ReadonlyConfig} from "@typing/config"; import { ContentScriptDeclarative, + ContentScriptIsolation, ContentScriptEntrypointOptions, ContentScriptWorld, type ContentScriptWorldValue, @@ -68,8 +70,15 @@ export default class ContentManager { const world = this.resolveWorld(options.world); if (this.config.manifestVersion !== 2) { - if (world === ContentScriptWorld.Main && options.shadow) { - throw new Error(`Content script "${file.file}" cannot use Shadow DOM in the MAIN execution world`); + if ( + world === ContentScriptWorld.Main && + (options.isolation === ContentScriptIsolation.Shadow || + (options.isolation === ContentScriptIsolation.Iframe && + !(options.frame?.src && /^https?:\/\//.test(options.frame.src)))) + ) { + throw new Error( + `Content script "${file.file}" cannot use this isolation in the MAIN execution world; only external HTTP(S) frame.src is supported` + ); } return options; @@ -98,21 +107,34 @@ export default class ContentManager { return entries; } - public async manifest(): Promise { - const manifest: ManifestContentScripts = new Set(); + /** Settings are keyed by the final grouped entrypoint name, not the original source files. */ + public async entryOptions(): Promise> { + const entries = new Map(); for (const [entry, items] of await this.group()) { - // prettier-ignore - const options = Array - .from(items, ({options}) => options) - .reduce((acc, opt) => { - return {...acc, ...opt}; - }, {} as ContentScriptEntrypointOptions); - - manifest.add({entry, shadow: !!options.shadow, ...getContentScriptConfigFromOptions(options)}); + const options = Array.from(items, item => item.options); + const worlds = new Set(options.map(option => this.resolveWorld(option.world))); + if (worlds.size !== 1) { + throw new Error(`Content entrypoint "${entry}" cannot mix execution worlds`); + } + + if (new Set(options.map(hasIsolatedTarget)).size !== 1) { + throw new Error(`Content entrypoint "${entry}" cannot mix isolation style delivery modes`); + } + + entries.set(entry, Object.assign({}, ...options)); } - return manifest; + return entries; + } + + public async manifest(): Promise { + return new Set( + Array.from(await this.entryOptions(), ([entry, options]) => ({ + entry, + ...getContentScriptConfigFromOptions(options), + })) + ); } public async hostPermissions(): Promise { @@ -204,9 +226,23 @@ export default class ContentManager { } public virtual(file: EntrypointFile): string { + if (!this._group) { + throw new Error( + `Cannot create virtual file "${file.file}": content group is not prepared. Await entries() or entryOptions() before virtual().` + ); + } + + const item = Array.from(this._group.values()) + .flatMap(items => Array.from(items)) + .find(item => item.file.file === file.file); + + if (!item) { + throw new Error(`Virtual file "${file.file}" is not in the prepared content group.`); + } + for (const provider of this.providers) { try { - return provider.virtual(file); + return provider.virtual(file, item.options); } catch {} } @@ -217,40 +253,6 @@ export default class ContentManager { return (await this.group()).size === 0; } - public async entryWorlds(): Promise> { - const entries = new Map(); - - for (const [entry, items] of await this.group()) { - const worlds = new Set(Array.from(items, ({options}) => this.resolveWorld(options.world))); - const world = worlds.values().next().value; - - if (worlds.size !== 1 || !world) { - throw new Error(`Content entrypoint "${entry}" cannot mix execution worlds`); - } - - entries.set(entry, world); - } - - return entries; - } - - public async entryShadows(): Promise> { - const entries = new Map(); - - for (const [entry, items] of await this.group()) { - const shadows = new Set(Array.from(items, ({options}) => !!options.shadow)); - const shadow = shadows.values().next().value; - - if (shadows.size !== 1 || shadow === undefined) { - throw new Error(`Content entrypoint "${entry}" cannot mix Shadow DOM modes`); - } - - entries.set(entry, shadow); - } - - return entries; - } - protected resolveWorld(world?: ContentScriptWorldValue): ContentScriptWorld { switch (world) { case undefined: diff --git a/src/cli/plugins/content/ContentName.ts b/src/cli/plugins/content/ContentName.ts index 0316f87f..6ebdda3c 100644 --- a/src/cli/plugins/content/ContentName.ts +++ b/src/cli/plugins/content/ContentName.ts @@ -7,7 +7,7 @@ import {NameGenerator} from "@cli/entrypoint"; import {ReadonlyConfig} from "@typing/config"; import {EntrypointType} from "@typing/entrypoint"; -import {ContentScriptEntrypointOptions} from "@typing/content"; +import {ContentScriptIsolation, ContentScriptEntrypointOptions} from "@typing/content"; export default class ContentName extends NameGenerator implements ContentNameGenerator { protected readonly _names = new Map(); @@ -17,7 +17,10 @@ export default class ContentName extends NameGenerator implements ContentNameGen } public create(name: string, options: ContentScriptEntrypointOptions): string { - if (!this.config.concatContentScripts || options.shadow) { + if ( + !this.config.concatContentScripts || + (options.isolation !== undefined && options.isolation !== ContentScriptIsolation.None) + ) { return this.name(name); } diff --git a/src/cli/plugins/content/Relay.ts b/src/cli/plugins/content/Relay.ts index f4948c4d..672a5f27 100644 --- a/src/cli/plugins/content/Relay.ts +++ b/src/cli/plugins/content/Relay.ts @@ -4,6 +4,7 @@ import {ContentProvider} from "./types"; import {RelayFinder} from "@cli/entrypoint"; import {virtualRelayModule} from "@cli/virtual"; +import {isContentScriptFrameNavigation} from "@shared/content"; import {RelayMethod, RelayOptions} from "@typing/relay"; import {ContentScriptEntrypointOptions} from "@typing/content"; @@ -20,14 +21,14 @@ export default class extends RelayFinder implements ContentProvider> { diff --git a/src/cli/plugins/content/bundler.test.ts b/src/cli/plugins/content/bundler.test.ts new file mode 100644 index 00000000..fda669c5 --- /dev/null +++ b/src/cli/plugins/content/bundler.test.ts @@ -0,0 +1,82 @@ +import {createPageAccessRequirements, validateContentStyles} from "./bundler"; +import type {ContentScriptEntrypointOptions} from "@typing/content"; + +describe("validateContentStyles", () => { + test.each(["panel.content", "panel.relay"])("warns for missing isolated CSS in %s", entry => { + for (const isolation of ["shadow", "iframe"] as const) { + const warnings = validateContentStyles(entry, {isolation}, {document: ["host.css"], isolated: []}); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain(`[adnbn:missing-isolation-css] Entrypoint "${entry}"`); + expect(warnings[0]).toContain("?isolation"); + } + }); + + test.each([ + undefined, + {}, + {isolation: "none"}, + {isolation: "iframe", frame: {page: "panel"}}, + {isolation: "iframe", frame: {src: "https://example.com/panel"}}, + ])("does not warn for unrelated entries or embedded documents: %j", options => { + expect(validateContentStyles("entry", options, {document: ["host.css"], isolated: []})).toEqual([]); + }); + + test("allows entries without CSS and mixed document/isolated styles", () => { + for (const document of [[], ["host.css"]]) { + expect(validateContentStyles("panel", {isolation: "shadow"}, {document, isolated: ["ui.css"]})).toEqual([]); + } + expect(validateContentStyles("panel", {isolation: "shadow"}, {document: [], isolated: []})).toEqual([]); + }); + + test.each([{page: "panel"}, {src: "https://example.com/panel"}])( + "rejects isolated CSS on navigation: %j", + frame => { + expect(() => + validateContentStyles("frame", {isolation: "iframe", frame}, {document: [], isolated: ["ui.css"]}) + ).toThrow('Entrypoint "frame" uses ?isolation CSS with frame.page/frame.src'); + } + ); +}); + +describe("createPageAccessRequirements", () => { + test("resolves page aliases from supplied filenames without weakening matches using exclusions", () => { + const entries = new Map([ + [ + "frame.content", + { + isolation: "iframe", + frame: {page: "panel"}, + matches: ["https://*.example.com/*"], + excludeMatches: ["https://private.example.com/*"], + }, + ], + ["default.relay", {isolation: "iframe", frame: {page: "panel"}}], + ["source.content", {isolation: "iframe", frame: {src: "https://example.com/panel"}}], + ["shadow.content", {isolation: "shadow"}], + ]); + const requirements = createPageAccessRequirements(entries, new Map([["panel", "views/custom-panel.html"]])); + expect(requirements).toEqual([ + { + resource: "views/custom-panel.html", + matches: ["https://*.example.com/*"], + issuer: 'Content entrypoint "frame.content" embedding page "panel"', + hint: "add matches to the page or narrow the content matches", + }, + { + resource: "views/custom-panel.html", + matches: ["http://*/*", "https://*/*"], + issuer: 'Content entrypoint "default.relay" embedding page "panel"', + hint: "add matches to the page or narrow the content matches", + }, + ]); + }); + + test("reports an unknown alias with its entrypoint", () => { + expect(() => + createPageAccessRequirements( + new Map([["frame.content", {isolation: "iframe", frame: {page: "missing"}}]]), + new Map() + ) + ).toThrow('Content entrypoint "frame.content" references unknown page "missing"'); + }); +}); diff --git a/src/cli/plugins/content/bundler.ts b/src/cli/plugins/content/bundler.ts new file mode 100644 index 00000000..b7375def --- /dev/null +++ b/src/cli/plugins/content/bundler.ts @@ -0,0 +1,57 @@ +import type {ResourceAccessPluginRequirement} from "@cli/bundler/plugins/resource-access"; +import type {IsolatedStylesPluginFiles} from "@cli/bundler/plugins/isolated-styles"; +import {isContentScriptFrameNavigation} from "@shared/content"; +import {hasIsolatedTarget} from "./utils"; + +import {ContentScriptMatches, ContentScriptWorld, type ContentScriptEntrypointOptions} from "@typing/content"; +import {EntrypointType} from "@typing/entrypoint"; + +/** Reject unavailable destinations and warn about likely missing query flags without changing CSS routing. */ +export const validateContentStyles = ( + entry: string, + options: ContentScriptEntrypointOptions | undefined, + files: IsolatedStylesPluginFiles +): readonly string[] => { + if (!options) return []; + if (files.isolated.length > 0 && isContentScriptFrameNavigation(options.frame)) { + throw new Error( + `Entrypoint "${entry}" uses ?isolation CSS with frame.page/frame.src, but has no local render target. Import these styles in the embedded page instead.` + ); + } + if (hasIsolatedTarget(options) && files.document.length > 0 && files.isolated.length === 0) { + return [ + `[adnbn:missing-isolation-css] Entrypoint "${entry}" uses isolation: "${options.isolation}", but has no CSS marked with ?isolation. Imported CSS will be delivered to the page document. If these styles belong to the isolated UI, import them with ?isolation. Document-only styles are valid; this warning can be suppressed with bundler ignoreWarnings.`, + ]; + } + return []; +}; + +export const createPageAccessRequirements = ( + entries: ReadonlyMap, + pages: ReadonlyMap +): ResourceAccessPluginRequirement[] => { + const requirements: ResourceAccessPluginRequirement[] = []; + + for (const [entry, options] of entries) { + const alias = options.frame?.page; + if (alias === undefined) continue; + + const resource = pages.get(alias); + if (!resource) { + throw new Error(`Content entrypoint "${entry}" references unknown page "${alias}"`); + } + + requirements.push({ + resource, + matches: options.matches ?? ContentScriptMatches, + issuer: `Content entrypoint "${entry}" embedding page "${alias}"`, + hint: "add matches to the page or narrow the content matches", + }); + } + + return requirements; +}; + +export const getContentChunkName = (world: ContentScriptWorld): string => { + return `${world === ContentScriptWorld.Main ? "common-main" : "common"}.${EntrypointType.ContentScript}`; +}; diff --git a/src/cli/plugins/content/index.ts b/src/cli/plugins/content/index.ts index 5d078780..4bf4f4f3 100644 --- a/src/cli/plugins/content/index.ts +++ b/src/cli/plugins/content/index.ts @@ -1,159 +1,197 @@ -import {Chunk, Configuration as RspackConfig, NormalModule} from "@rspack/core"; -import {merge as mergeConfig} from "webpack-merge"; +import type {Chunk, Configuration as RspackConfig, NormalModule, Plugins} from "@rspack/core"; import ContentManager from "./ContentManager"; import Content from "./Content"; import Relay from "./Relay"; import RelayDeclaration from "./RelayDeclaration"; -import {getContentChunkName, getContentLayer} from "./utils"; +import {hasIsolatedTarget} from "./utils"; +import {createPageAccessRequirements, getContentChunkName, validateContentStyles} from "./bundler"; import {definePlugin} from "@main/plugin"; - -import {ChunkLoaderPlugin, EntrypointPlugin, onlyViaTopLevelEntry, ShadowStylesPlugin, RuntimeDataPlugin, type RuntimeDataPluginData} from "@cli/bundler"; +import {PageFinder} from "@cli/entrypoint"; +import {getContentLayer} from "@cli/bundler/utils/layers"; + +import { + appFilenameResolver, + ChunkLoaderPlugin, + EntrypointPlugin, + onlyViaTopLevelEntry, + IsolatedStylesPlugin, + ResourceAccessPlugin, + RuntimeDataPlugin, + type RuntimeDataPluginData, +} from "@cli/bundler"; import {Command} from "@typing/app"; -import {ContentScriptWorld} from "@typing/content"; +import {ContentScriptStylesRuntimeProperty, ContentScriptWorld} from "@typing/content"; import {RelayOptionsRuntimeProperty} from "@typing/relay"; export default definePlugin(() => { - let content: Content; - let relay: Relay; - let manager: ContentManager; + let contentProvider: Content; + let relayProvider: Relay; + let contentManager: ContentManager; let relayDeclaration: RelayDeclaration; return { name: "adnbn:content", - startup: async ({config}) => { - content = new Content(config); - relay = new Relay(config); + startup: ({config}) => { + contentProvider = new Content(config); + relayProvider = new Relay(config); // prettier-ignore - manager = new ContentManager(config) - .provider(content) - .provider(relay); + contentManager = new ContentManager(config) + .provider(contentProvider) + .provider(relayProvider); relayDeclaration = new RelayDeclaration(config); }, - content: () => content.files(), - relay: () => relay.files(), + content: () => contentProvider.files(), + relay: () => relayProvider.files(), bundler: async ({config}) => { - relayDeclaration.dictionary(await relay.dictionary()).build(); + relayDeclaration.dictionary(await relayProvider.dictionary()).build(); - let rspack: RspackConfig = {}; - const getRelayData = async (): Promise => - JSON.parse(JSON.stringify(await relay.getOptionsMap())); + let entryOptionsByName = await contentManager.entryOptions(); + const getRelayData = async (): Promise => { + // Preserve the previous JSON payload: absent optional values must not become undefined data. + return JSON.parse(JSON.stringify(await relayProvider.getOptionsMap())); + }; const relayDataPlugin = new RuntimeDataPlugin({ property: RelayOptionsRuntimeProperty, data: await getRelayData(), }); + const basePlugins: Plugins = [ + relayDataPlugin, + new ResourceAccessPlugin({ + requirements: async () => { + if ( + !Array.from(entryOptionsByName.values()).some(options => options.frame?.page !== undefined) + ) { + return []; + } + + const views = await new PageFinder(config).views(); + const pages = new Map(Array.from(views.values(), view => [view.alias, view.filename])); + return createPageAccessRequirements(entryOptionsByName, pages); + }, + }), + ]; - if (await manager.empty()) { + if (await contentManager.empty()) { if (config.debug) { console.warn("Content script or relay entries not found"); } - } else { - const entries = await manager.entries(); - let entryWorlds = await manager.entryWorlds(); - let entryShadows = await manager.entryShadows(); - const getEntryWorld = (name: string): ContentScriptWorld => { - const world = entryWorlds.get(name); - - if (!world) { - throw new Error(`Execution world for content entrypoint "${name}" is unavailable`); - } - return world; - }; - - // prettier-ignore - const plugin = EntrypointPlugin.from(entries) - .virtual(file => manager.virtual(file)) - .entryOptions(name => { - const world = getEntryWorld(name); - - return { - // MAIN cannot resolve an installed extension URL without crossing contexts. - // ISOLATED keeps physical async chunks and resolves them through the extension API. - asyncChunks: world === ContentScriptWorld.Isolated, - layer: getContentLayer(world), - publicPath: "", - }; - }); - - if (config.command === Command.Watch) { - plugin.watch(async () => { - manager.clear(); - - relayDeclaration.dictionary(await relay.dictionary()).build(); - relayDataPlugin.update(await getRelayData()); - - const entries = await manager.entries(); - entryWorlds = await manager.entryWorlds(); - entryShadows = await manager.entryShadows(); - - return entries; - }); - } + return {plugins: basePlugins}; + } - const entryTypeFilter = onlyViaTopLevelEntry(["content", "relay"]); + const entries = await contentManager.entries(); + const getEntryWorld = (name: string): ContentScriptWorld => { + const options = entryOptionsByName.get(name); - const contentCacheGroup = (world: ContentScriptWorld) => ({ - minChunks: 2, - name: getContentChunkName(world), - test: ( - module: Parameters[0], - context: Parameters[1] - ) => { - const normalModule = module as NormalModule; + if (!options) { + throw new Error(`Execution world for content entrypoint "${name}" is unavailable`); + } - if (!normalModule.resource) { - return false; - } + return options.world === ContentScriptWorld.Main + ? ContentScriptWorld.Main + : ContentScriptWorld.Isolated; + }; - return entryTypeFilter(module, context); - }, - chunks: (chunk: Chunk): boolean => - chunk.name !== undefined && entryWorlds.get(chunk.name) === world, - enforce: false, - reuseExistingChunk: false, - priority: 20, + // prettier-ignore + const entrypointPlugin = EntrypointPlugin.from(entries) + .virtual(file => contentManager.virtual(file)) + .entryOptions(name => { + const world = getEntryWorld(name); + + return { + // MAIN cannot resolve an installed extension URL without crossing contexts. + // ISOLATED keeps physical async chunks and resolves them through the extension API. + asyncChunks: world === ContentScriptWorld.Isolated, + layer: getContentLayer(world), + publicPath: "", + }; }); - rspack = { - plugins: [ - plugin, - new ChunkLoaderPlugin({ - test: entry => entryWorlds.get(entry) === ContentScriptWorld.Isolated, - }), - new ShadowStylesPlugin({ - test: entry => entryShadows.get(entry) === true, - }), - ], - optimization: config.commonChunks - ? { - splitChunks: { - cacheGroups: { - adnbnContentIsolated: contentCacheGroup(ContentScriptWorld.Isolated), - adnbnContentMain: contentCacheGroup(ContentScriptWorld.Main), - }, - }, - } - : undefined, - }; + if (config.command === Command.Watch) { + entrypointPlugin.watch(async () => { + contentManager.clear(); + + relayDeclaration.dictionary(await relayProvider.dictionary()).build(); + + const entries = await contentManager.entries(); + entryOptionsByName = await contentManager.entryOptions(); + + relayDataPlugin.update(await getRelayData()); + + return entries; + }); } - return mergeConfig(rspack, { - plugins: [relayDataPlugin], + const isContentModule = onlyViaTopLevelEntry(["content", "relay"]); + + const createCacheGroup = (world: ContentScriptWorld) => ({ + minChunks: 2, + name: getContentChunkName(world), + test: ( + module: Parameters[0], + context: Parameters[1] + ) => { + const normalModule = module as NormalModule; + + if (!normalModule.resource) { + return false; + } + + return isContentModule(module, context); + }, + chunks: (chunk: Chunk): boolean => + chunk.name !== undefined && + entryOptionsByName.has(chunk.name) && + getEntryWorld(chunk.name) === world, + enforce: false, + reuseExistingChunk: false, + priority: 20, }); + + return { + plugins: [ + entrypointPlugin, + new ChunkLoaderPlugin({ + test: entry => + entryOptionsByName.has(entry) && getEntryWorld(entry) === ContentScriptWorld.Isolated, + }), + new IsolatedStylesPlugin({ + cssFilename: appFilenameResolver(config.app, config.cssFilename, config.cssDir), + cssChunkFilename: appFilenameResolver(config.app, config.cssFilename, config.cssDir), + property: ContentScriptStylesRuntimeProperty, + test: entry => { + const options = entryOptionsByName.get(entry); + return options !== undefined && hasIsolatedTarget(options); + }, + validate: (entry, files) => validateContentStyles(entry, entryOptionsByName.get(entry), files), + }), + ...basePlugins, + ], + optimization: config.commonChunks + ? { + splitChunks: { + cacheGroups: { + adnbnContentIsolated: createCacheGroup(ContentScriptWorld.Isolated), + adnbnContentMain: createCacheGroup(ContentScriptWorld.Main), + }, + }, + } + : undefined, + } satisfies RspackConfig; }, manifest: async ({manifest}) => { // prettier-ignore manifest - .setContentScripts(await manager.manifest()) - .appendHostPermissions(await manager.hostPermissions()) - .appendOptionalHostPermissions(await manager.optionalHostPermissions()) - .appendPermissions(await manager.permissions()) - .appendOptionalPermissions(await manager.optionalPermissions()); + .setContentScripts(await contentManager.manifest()) + .appendHostPermissions(await contentManager.hostPermissions()) + .appendOptionalHostPermissions(await contentManager.optionalHostPermissions()) + .appendPermissions(await contentManager.permissions()) + .appendOptionalPermissions(await contentManager.optionalPermissions()); }, }; }); diff --git a/src/cli/plugins/content/tests/fixtures/relay-types/virtual.ts b/src/cli/plugins/content/tests/fixtures/relay-types/virtual.ts index f91a73cf..3716aaaa 100644 --- a/src/cli/plugins/content/tests/fixtures/relay-types/virtual.ts +++ b/src/cli/plugins/content/tests/fixtures/relay-types/virtual.ts @@ -1,7 +1,7 @@ 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 {Builder as ContentScriptBuilder} from "virtual:content-builder"; import * as definition from "virtual:relay-entrypoint"; type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; diff --git a/src/cli/plugins/content/types.ts b/src/cli/plugins/content/types.ts index 398841ba..e4af20af 100644 --- a/src/cli/plugins/content/types.ts +++ b/src/cli/plugins/content/types.ts @@ -24,7 +24,7 @@ export interface ContentDriver { } export interface ContentProvider { - virtual(file: EntrypointFile): string; + virtual(file: EntrypointFile, options?: ContentScriptEntrypointOptions): string; driver(): ContentDriver; diff --git a/src/cli/plugins/content/utils.test.ts b/src/cli/plugins/content/utils.test.ts index 1fb8e830..dd37c387 100644 --- a/src/cli/plugins/content/utils.test.ts +++ b/src/cli/plugins/content/utils.test.ts @@ -1,4 +1,4 @@ -import {getContentScriptConfigFromOptions} from "./utils"; +import {getContentScriptConfigFromOptions, hasIsolatedTarget} from "./utils"; import type {ContentScriptEntrypointOptions} from "@typing/content"; describe("content utils - getContentScriptConfigFromOptions", () => { @@ -84,3 +84,24 @@ describe("content utils - getContentScriptConfigFromOptions", () => { expect(cfgUndefined.excludeGlobs).toBeUndefined(); }); }); + +describe("content render targets", () => { + test.each([ + {isolation: "shadow"}, + {isolation: "iframe"}, + {isolation: "iframe", frame: {height: 300}}, + ])("provides an isolated local target for %j", options => { + expect(hasIsolatedTarget(options)).toBe(true); + }); + + test.each([ + {isolation: "iframe", frame: {page: "panel"}}, + {isolation: "iframe", frame: {src: "https://example.com/panel"}}, + ])("leaves document ownership to the frame for %j", options => { + expect(hasIsolatedTarget(options)).toBe(false); + }); + + test.each([{}, {isolation: "none"}])("has no isolated target for %j", options => { + expect(hasIsolatedTarget(options)).toBe(false); + }); +}); diff --git a/src/cli/plugins/content/utils.ts b/src/cli/plugins/content/utils.ts index ae46f9c0..6409072f 100644 --- a/src/cli/plugins/content/utils.ts +++ b/src/cli/plugins/content/utils.ts @@ -1,7 +1,12 @@ import _ from "lodash"; +import {isContentScriptFrameNavigation} from "@shared/content"; -import {ContentScriptConfig, ContentScriptEntrypointOptions, ContentScriptWorld} from "@typing/content"; -import {EntrypointType} from "@typing/entrypoint"; +import {ContentScriptConfig, ContentScriptEntrypointOptions, ContentScriptIsolation} from "@typing/content"; + +/** ShadowRoot or a blank iframe provides a local target for isolated styles and UI. */ +export const hasIsolatedTarget = (options: ContentScriptEntrypointOptions): boolean => + options.isolation === ContentScriptIsolation.Shadow || + (options.isolation === ContentScriptIsolation.Iframe && !isContentScriptFrameNavigation(options.frame)); export const getContentScriptConfigFromOptions = (options: ContentScriptEntrypointOptions): ContentScriptConfig => { const config = _.pick(options, [ @@ -26,11 +31,3 @@ export const getContentScriptConfigFromOptions = (options: ContentScriptEntrypoi excludeGlobs: sort(config.excludeGlobs), }; }; - -export const getContentChunkName = (world: ContentScriptWorld): string => { - return `${world === ContentScriptWorld.Main ? "common-main" : "common"}.${EntrypointType.ContentScript}`; -}; - -export const getContentLayer = (world: ContentScriptWorld): string => { - return `adnbn:content:${world.toLowerCase()}`; -}; diff --git a/src/cli/plugins/style/index.ts b/src/cli/plugins/style/index.ts index adc074ce..8f681584 100644 --- a/src/cli/plugins/style/index.ts +++ b/src/cli/plugins/style/index.ts @@ -4,6 +4,8 @@ import fs from "fs"; import {Configuration as RspackConfig, CssExtractRspackPlugin, RuleSetUse, RuleSetUseItem} from "@rspack/core"; import {mergeStyleSources} from "./utils"; +import type {StylePluginOptions} from "./types"; +import {IsolatedStylesLayer} from "@cli/bundler/utils/styles"; import {definePlugin} from "@main/plugin"; @@ -13,6 +15,11 @@ import {toPosix} from "@cli/utils/path"; import {ReadonlyConfig} from "@typing/config"; +// CssExtract also identifies dependencies by loader request, not just by layer. +// Keep isolated requests distinct from ordinary CSS and from each other's options. +const IsolatedAsIsLoaderIdent = "adnbn-isolated-asis"; +const IsolatedModulesLoaderIdent = "adnbn-isolated-modules"; + // prettier-ignore const styleMergerLoader = (config: ReadonlyConfig) => @@ -52,7 +59,7 @@ const styleMergerLoader = } }; -export default definePlugin(() => { +export default definePlugin(({isolationIssuerLayer}: StylePluginOptions = {}) => { return { name: "adnbn:styles", bundler: ({config}) => { @@ -76,6 +83,32 @@ export default definePlugin(() => { return rules; }; + const createStyleRules = (isolated = false) => [ + { + resourceQuery: /[?&]asis(?:[=&]|$)/, + use: createSassRuleSet({ + loader: "css-loader", + ...(isolated ? {ident: IsolatedAsIsLoaderIdent} : {}), + options: {esModule: true, modules: false}, + }), + }, + { + use: createSassRuleSet({ + loader: "css-loader", + ...(isolated ? {ident: IsolatedModulesLoaderIdent} : {}), + options: { + esModule: true, + modules: { + exportLocalsConvention: "as-is", + namedExport: false, + localIdentName: cssIdentName.replaceAll("[app]", kebabApp), + localIdentHashSalt: kebabApp, + }, + }, + }), + }, + ]; + return { resolve: { extensions: [".css", ".scss"], @@ -86,36 +119,37 @@ export default definePlugin(() => { chunkFilename: filename, }), ], + optimization: { + splitChunks: { + cacheGroups: { + adnbnIsolatedStyles: { + type: "css/mini-extract", + layer: IsolatedStylesLayer, + chunks: "all", + enforce: true, + name: false, + priority: 100, + }, + }, + }, + }, module: { rules: [ { test: /\.(scss|css)$/, type: "javascript/auto", oneOf: [ - { - resourceQuery: /asis/, - use: createSassRuleSet({ - loader: "css-loader", - options: { - esModule: true, - modules: false, - }, - }), - }, - { - use: createSassRuleSet({ - loader: "css-loader", - options: { - esModule: true, - modules: { - exportLocalsConvention: "as-is", - namedExport: false, - localIdentName: cssIdentName.replaceAll("[app]", kebabApp), - localIdentHashSalt: kebabApp, - }, - }, - }), - }, + ...(isolationIssuerLayer === undefined + ? [] + : [ + { + issuerLayer: isolationIssuerLayer, + resourceQuery: /[?&]isolation(?:[=&]|$)/, + layer: IsolatedStylesLayer, + oneOf: createStyleRules(true), + }, + ]), + {oneOf: createStyleRules()}, ], }, ], diff --git a/src/cli/plugins/style/types.ts b/src/cli/plugins/style/types.ts new file mode 100644 index 00000000..ad5a915b --- /dev/null +++ b/src/cli/plugins/style/types.ts @@ -0,0 +1,6 @@ +import type {RuleSetCondition} from "@rspack/core"; + +export interface StylePluginOptions { + /** Issuer layers where ?isolation selects a separate CSS destination. Omit to use ordinary CSS rules. */ + isolationIssuerLayer?: RuleSetCondition; +} diff --git a/src/cli/resolvers/config.ts b/src/cli/resolvers/config.ts index 6a1d1de0..4e3397bb 100644 --- a/src/cli/resolvers/config.ts +++ b/src/cli/resolvers/config.ts @@ -31,6 +31,7 @@ import { } from "../plugins"; import {fromRootPath, getAppPath, getAppSourcePath, getConfigFile} from "../resolvers/path"; +import {isContentLayer} from "@cli/bundler/utils/layers"; import type {Config, OptionalConfig, ReadonlyConfig, UserConfig} from "@typing/config"; import {Command, Mode, Workspace} from "@typing/app"; @@ -363,7 +364,7 @@ export default async (config: OptionalConfig): Promise => { pluginReact(), pluginIcon(), pluginAsset(), - pluginStyle(), + pluginStyle({isolationIssuerLayer: isContentLayer}), pluginLocale(), pluginMeta(), pluginContent(), diff --git a/src/cli/virtual/content.ts b/src/cli/virtual/content.ts index 17c7f175..54cbf13d 100644 --- a/src/cli/virtual/content.ts +++ b/src/cli/virtual/content.ts @@ -1,22 +1,22 @@ import type {ContentScriptDefinition} from "adnbn"; import {isContentScriptDefinition, isValidContentScriptDefinitionRenderValue} from "adnbn/entry/content"; -import contentScript from "virtual:content-framework"; +import contentScript from "virtual:content-builder"; import * as module from "virtual:content-entrypoint"; try { const {default: defaultDefinition, ...otherDefinition} = module; - let definition: ContentScriptDefinition = otherDefinition; + let definition: object = otherDefinition; if (isContentScriptDefinition(defaultDefinition)) { definition = {...definition, ...defaultDefinition}; } else if (isValidContentScriptDefinitionRenderValue(defaultDefinition)) { - definition = {...definition, render: defaultDefinition}; + definition = {...definition, render: defaultDefinition} as ContentScriptDefinition; } - contentScript(definition); + contentScript(definition as ContentScriptDefinition); } catch (e) { console.error("The content script crashed on startup:", e); } diff --git a/src/cli/virtual/index.ts b/src/cli/virtual/index.ts index d378923b..222e4c31 100644 --- a/src/cli/virtual/index.ts +++ b/src/cli/virtual/index.ts @@ -41,10 +41,10 @@ export const virtualCommandModule = (file: EntrypointFile, name: string): string return getVirtualModule(file, "command").replace("virtual:command-name", name); }; -export const virtualContentScriptModule = (file: EntrypointFile): string => { +export const virtualContentScriptModule = (file: EntrypointFile, navigation = false): string => { // prettier-ignore return getVirtualModule(file, "content") - .replace(`virtual:content-framework`, getEntryFramework(file, "content")); + .replace(`virtual:content-builder`, navigation ? `${PackageName}/entry/content/frame` : getEntryFramework(file, "content")); }; export const virtualOffscreenModule = (file: EntrypointFile, name: string): string => { @@ -57,10 +57,13 @@ export const virtualOffscreenBackgroundModule = (): string => { return offscreenBackground; }; -export const virtualRelayModule = (file: EntrypointFile, name: string): string => { +export const virtualRelayModule = (file: EntrypointFile, name: string, navigation = false): string => { return getVirtualModule(file, "relay") .replace("virtual:relay-name", name) - .replace(`virtual:content-framework`, getEntryFramework(file, "content")); + .replace( + `virtual:content-builder`, + navigation ? `${PackageName}/entry/content/frame` : getEntryFramework(file, "content") + ); }; export const virtualSandboxModule = (file: EntrypointFile, name: string): string => { diff --git a/src/cli/virtual/relay.ts b/src/cli/virtual/relay.ts index 25bb135f..11c596f4 100644 --- a/src/cli/virtual/relay.ts +++ b/src/cli/virtual/relay.ts @@ -1,8 +1,9 @@ +import type {ContentScriptDefinition} from "adnbn"; import type {TransportType} from "adnbn/transport"; import {isValidTransportDefinition, isValidTransportInitFunction} from "adnbn/entry/transport"; import {Builder as RelayBuilder, type RelayUnresolvedDefinition} from "adnbn/entry/relay"; -import {Builder as ContentScriptBuilder} from "virtual:content-framework"; +import {Builder as ContentScriptBuilder} from "virtual:content-builder"; import * as module from "virtual:relay-entrypoint"; @@ -32,7 +33,7 @@ try { new ContentScriptBuilder({ ...contentOptions, ...(allFrames === undefined ? {} : {allFrames: allFrames !== false}), - }) + } as ContentScriptDefinition) ) .build() .catch(e => { diff --git a/src/cli/virtual/virtual.d.ts b/src/cli/virtual/virtual.d.ts index 102dc9c8..838377d0 100644 --- a/src/cli/virtual/virtual.d.ts +++ b/src/cli/virtual/virtual.d.ts @@ -23,7 +23,8 @@ declare module "virtual:command-entrypoint" { declare module "virtual:content-entrypoint" { type ContentScriptDefinition = import("@typing/content").ContentScriptDefinition; - interface ModuleType extends ContentScriptDefinition { + interface ModuleType { + [name: string]: unknown; default: ContentScriptDefinition | ContentScriptDefinition["render"] | undefined; } @@ -31,14 +32,16 @@ declare module "virtual:content-entrypoint" { export = module; } -declare module "virtual:content-framework" { +declare module "virtual:content-builder" { export const Builder: | typeof import("@entry/content/adapters/vanilla").Builder - | typeof import("@entry/content/adapters/react").Builder; + | typeof import("@entry/content/adapters/react").Builder + | typeof import("@entry/content/frame").Builder; const content: | typeof import("@entry/content/adapters/vanilla").default - | typeof import("@entry/content/adapters/react").default; + | typeof import("@entry/content/adapters/react").default + | typeof import("@entry/content/frame").default; export default content; } @@ -63,6 +66,8 @@ declare module "virtual:relay-entrypoint" { main, name, method, + isolation, + frame, allFrames, matches, excludeMatches, diff --git a/src/cli/virtual/virtual.test.ts b/src/cli/virtual/virtual.test.ts index ac1b45ad..9907dba1 100644 --- a/src/cli/virtual/virtual.test.ts +++ b/src/cli/virtual/virtual.test.ts @@ -1,6 +1,7 @@ import {execFileSync} from "child_process"; -import {readFileSync} from "fs"; +import {existsSync, readFileSync} from "fs"; import path from "path"; +import {fileURLToPath} from "url"; import ts from "typescript"; type Generator = keyof typeof import("./index"); @@ -42,6 +43,7 @@ describe("Built virtual modules", () => { { generator: "virtualRelayModule", imports: [ + "adnbn", "adnbn/transport", "adnbn/entry/transport", "adnbn/entry/relay", @@ -66,10 +68,12 @@ describe("Built virtual modules", () => { }, ]; let generated: Record<"ts" | "tsx", Record>; + let navigation: Record<"ts" | "tsx", Record<"virtualContentScriptModule" | "virtualRelayModule", string>>; + let frameModule: string; beforeAll(() => { // Run the final JS artifact in Node, without Jest transforms, source aliases, or module mocks. - generated = JSON.parse( + const artifacts = JSON.parse( execFileSync( process.execPath, [ @@ -80,19 +84,38 @@ describe("Built virtual modules", () => { 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")] - )); + const modules = Object.fromEntries(Object.entries(generators).map(([name, generate]) => { + // Content's second argument is navigation, not an entrypoint name. + const source = name === "virtualContentScriptModule" + ? generate(file) + : generate(file, "example"); + return [name, source]; + })); return [extension, modules]; })); - process.stdout.write(JSON.stringify(generated)); + const navigation = Object.fromEntries(["ts", "tsx"].map(extension => { + const file = {file: "entry." + extension, import: "./entry." + extension}; + return [extension, { + virtualContentScriptModule: generators.virtualContentScriptModule(file, true), + virtualRelayModule: generators.virtualRelayModule(file, "example", true), + }]; + })); + + process.stdout.write(JSON.stringify({ + generated, + navigation, + frameModule: import.meta.resolve("adnbn/entry/content/frame"), + })); `, ], {cwd: projectDir, encoding: "utf8", timeout: 10_000} ) ); + generated = artifacts.generated; + navigation = artifacts.navigation; + frameModule = artifacts.frameModule; }); test("covers every built generator", () => { @@ -106,6 +129,13 @@ describe("Built virtual modules", () => { expect(importedFiles).toContain("../entrypoint/index.js"); }); + test("resolves the public frame entrypoint outside renderer adapters", () => { + const filename = fileURLToPath(frameModule); + expect(filename).toBe(path.join(projectDir, "dist/entry/content/frame/index.js")); + expect(existsSync(filename)).toBe(true); + expect(existsSync(filename.replace(/\.js$/, ".d.ts"))).toBe(true); + }); + describe.each([ {extension: "ts" as const, framework: "vanilla"}, {extension: "tsx" as const, framework: "react"}, @@ -122,5 +152,21 @@ describe("Built virtual modules", () => { expect(source).not.toContain("virtual:"); expect(source).not.toContain(":entry"); }); + + test.each(["virtualContentScriptModule", "virtualRelayModule"] as const)( + "%s selects the frame builder for document navigation", + generator => { + const source = navigation[extension][generator]; + const importedFiles = ts.preProcessFile(source).importedFiles.map(file => file.fileName); + const {imports} = cases.find(testCase => testCase.generator === generator)!; + + expect(importedFiles).toEqual( + imports.map(specifier => + specifier.replace("{framework}", "frame").replace("{entry}", `./entry.${extension}`) + ) + ); + expect(source).not.toContain("virtual:"); + } + ); }); }); diff --git a/src/entry/content/adapters/react/Builder.ts b/src/entry/content/adapters/react/Builder.ts index 55179b7f..488aa31c 100644 --- a/src/entry/content/adapters/react/Builder.ts +++ b/src/entry/content/adapters/react/Builder.ts @@ -1,7 +1,7 @@ import {isValidElement} from "react"; import MountBuilder from "../../core/MountBuilder"; -import EventNode from "../../core/EventNode"; +import EventNode from "../../core/nodes/EventNode"; import ReactNode from "./Node"; import {contentScriptReactRenderResolver} from "./resolvers"; diff --git a/src/entry/content/adapters/react/Node.ts b/src/entry/content/adapters/react/Node.ts index 3366d4c2..82aec867 100644 --- a/src/entry/content/adapters/react/Node.ts +++ b/src/entry/content/adapters/react/Node.ts @@ -6,6 +6,8 @@ import {ContentScriptNode} from "@typing/content"; export default class implements ContentScriptNode { protected root?: Root; + private renderedTarget?: Element; + constructor( protected readonly node: ContentScriptNode, protected readonly component?: ReactComponent @@ -26,7 +28,7 @@ export default class implements ContentScriptNode { public mount(): boolean { this.node.mount(); - if (!this.target || this.root) { + if (!this.target || (this.root && this.renderedTarget === this.target)) { return false; } @@ -36,6 +38,8 @@ export default class implements ContentScriptNode { return false; } + this.root?.unmount(); + this.renderedTarget = this.target; this.root = createRoot(this.target); this.root.render(this.component); @@ -46,6 +50,7 @@ export default class implements ContentScriptNode { public unmount(): boolean { this.root?.unmount(); this.root = undefined; + this.renderedTarget = undefined; return !!this.node.unmount(); } diff --git a/src/entry/content/adapters/vanilla/Builder.ts b/src/entry/content/adapters/vanilla/Builder.ts index 9f934ce6..3a5f21ba 100644 --- a/src/entry/content/adapters/vanilla/Builder.ts +++ b/src/entry/content/adapters/vanilla/Builder.ts @@ -1,7 +1,7 @@ import {isValidContentScriptRenderValue} from "../../core/resolvers"; import MountBuilder from "../../core/MountBuilder"; -import EventNode from "../../core/EventNode"; +import EventNode from "../../core/nodes/EventNode"; import VanillaNode from "./Node"; import {ContentScriptDefinition, ContentScriptNode} from "@typing/content"; diff --git a/src/entry/content/adapters/vanilla/Node.ts b/src/entry/content/adapters/vanilla/Node.ts index dfafb1a0..82b05491 100644 --- a/src/entry/content/adapters/vanilla/Node.ts +++ b/src/entry/content/adapters/vanilla/Node.ts @@ -1,7 +1,7 @@ import {ContentScriptNode} from "@typing/content"; export default class implements ContentScriptNode { - private mounted: boolean = false; + private renderedTarget?: Element; constructor( protected readonly node: ContentScriptNode, @@ -23,13 +23,13 @@ export default class implements ContentScriptNode { public mount(): boolean { this.node.mount(); - if (!this.target || this.mounted) { + if (!this.target || this.renderedTarget === this.target) { return false; } let result: boolean = true; - if (this.value instanceof Element) { + if (this.value && typeof this.value === "object" && this.value.nodeType === 1) { this.target.appendChild(this.value); } else if (typeof this.value === "string" || typeof this.value === "number") { this.target.textContent = String(this.value); @@ -41,13 +41,13 @@ export default class implements ContentScriptNode { result = false; } - this.mounted = true; + this.renderedTarget = this.target; return result; } public unmount(): boolean { - this.mounted = false; + this.renderedTarget = undefined; return !!this.node.unmount(); } diff --git a/src/entry/content/core/Builder.ts b/src/entry/content/core/Builder.ts index 51342ead..7e858e50 100644 --- a/src/entry/content/core/Builder.ts +++ b/src/entry/content/core/Builder.ts @@ -1,3 +1,4 @@ +import {isContentScriptFrameNavigation, validateContentScriptIsolation} from "@shared/content"; import AwaitLock from "await-lock"; import Builder from "@entry/core/Builder"; @@ -12,11 +13,8 @@ import { contentScriptRenderResolver, } from "./resolvers"; -import ManagedContext from "./ManagedContext"; -import EventEmitter from "./EventEmitter"; -import AttributeMarker from "./AttributeMarker"; -import WeakMarker from "./WeakMarker"; -import ShadowFontRegistry from "./ShadowFontRegistry"; +import {ManagedContext, EventEmitter} from "./context"; +import {AttributeMarker, WeakMarker} from "./markers"; import { ContentScriptAnchor, @@ -39,8 +37,7 @@ import { ContentScriptRenderHandler, ContentScriptRenderValue, ContentScriptResolvedDefinition, - ContentScriptShadow, - ContentScriptShadowOptions, + ContentScriptIsolation, ContentScriptWatchStrategy, } from "@typing/content"; @@ -59,8 +56,6 @@ export default abstract class extends Builder implements ContentScriptBuilder { protected unwatch?: () => void; - private readonly fonts: ShadowFontRegistry; - protected abstract createNode(anchor: Element): Promise; protected abstract cleanupNode(anchor: Element): Awaiter; @@ -68,6 +63,8 @@ export default abstract class extends Builder implements ContentScriptBuilder { protected constructor(definition: ContentScriptDefinition) { super(); + validateContentScriptIsolation(definition.isolation, definition.frame, "render" in definition); + this.definition = { ...definition, marker: this.resolveMarker(definition.marker), @@ -75,10 +72,9 @@ export default abstract class extends Builder implements ContentScriptBuilder { mount: this.resolveMount(definition.mount), container: this.resolveContainer(definition.container), render: this.resolveRender(definition.render), - shadow: this.resolveShadow(definition.shadow), + isolation: definition.isolation ?? ContentScriptIsolation.None, watch: this.resolveWatch(definition.watch), }; - this.fonts = new ShadowFontRegistry(this.definition.shadow?.fonts); } protected resolveMarker(marker: ContentScriptMarkerType | ContentScriptMarkerGetter): ContentScriptMarkerResolver { @@ -140,14 +136,6 @@ export default abstract class extends Builder implements ContentScriptBuilder { return contentScriptLocationResolver(watch); } - protected resolveShadow(shadow?: ContentScriptShadow): ContentScriptShadowOptions | undefined { - if (!shadow) { - return; - } - - return shadow === true ? {} : shadow; - } - public getContext(): ContentScriptContext { return this.context; } @@ -159,11 +147,9 @@ export default abstract class extends Builder implements ContentScriptBuilder { this.marker = await marker(options); - this.fonts.register(); - await main?.(this.context, options); - if (render !== undefined) { + if (render !== undefined || isContentScriptFrameNavigation(this.definition.frame)) { await this.processing(); this.unwatch = watch(() => { diff --git a/src/entry/content/core/MountBuilder.ts b/src/entry/content/core/MountBuilder.ts index bd332e2d..8e5387da 100644 --- a/src/entry/content/core/MountBuilder.ts +++ b/src/entry/content/core/MountBuilder.ts @@ -1,16 +1,18 @@ +import FrameNode from "./nodes/FrameNode"; +import {isContentScriptFrameNavigation} from "@shared/content"; import Builder from "./Builder"; -import Node from "./Node"; -import MountNode from "./MountNode"; -import MarkerNode from "./MarkerNode"; -import ShadowNode from "./ShadowNode"; +import Node from "./nodes/Node"; +import MountNode from "./nodes/MountNode"; +import MarkerNode from "./nodes/MarkerNode"; +import ShadowNode from "./nodes/ShadowNode"; -import {ContentScriptNode, ContentScriptProps, ContentScriptRenderValue} from "@typing/content"; +import {ContentScriptIsolation, ContentScriptNode, ContentScriptProps, ContentScriptRenderValue} from "@typing/content"; export default abstract class extends Builder { private values = new Map(); protected getProps(anchor: Element): ContentScriptProps { - const {anchor: _, mount, watch, render, container, main, shadow, ...options} = this.definition; + const {anchor: _, mount, watch, render, container, main, isolation, frame, ...options} = this.definition; return {...options, anchor}; } @@ -40,13 +42,23 @@ export default abstract class extends Builder { const value = await this.getValue(anchor); - if (typeof value !== "boolean" && value !== undefined) { + if ( + isContentScriptFrameNavigation(this.definition.frame) || + (typeof value !== "boolean" && value !== undefined) + ) { container = (await this.definition.container(this.getProps(anchor))) as Element | undefined; } const node = new MountNode(new MarkerNode(new Node(anchor, container), this.marker), this.definition.mount); - return this.definition.shadow ? new ShadowNode(node) : node; + switch (this.definition.isolation) { + case ContentScriptIsolation.Shadow: + return new ShadowNode(node); + case ContentScriptIsolation.Iframe: + return new FrameNode(node, this.definition.frame, () => this.context.mount()); + default: + return node; + } } protected cleanupNode(anchor: Element): void { diff --git a/src/entry/content/core/ShadowFontRegistry.test.ts b/src/entry/content/core/ShadowFontRegistry.test.ts deleted file mode 100644 index a1ac7e68..00000000 --- a/src/entry/content/core/ShadowFontRegistry.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import {getUrl} from "@addon-core/browser"; - -import ShadowFontRegistry from "./ShadowFontRegistry"; - -jest.mock("@addon-core/browser", () => ({ - getUrl: jest.fn((file: string) => `chrome-extension://fixture/${file}`), -})); - -class FontFaceFixture { - public static instances: FontFaceFixture[] = []; - - public static loadError?: Error; - - public readonly load = jest.fn(() => - FontFaceFixture.loadError ? Promise.reject(FontFaceFixture.loadError) : Promise.resolve(this) - ); - - public constructor( - public readonly family: string, - public readonly source: string, - public readonly descriptors?: FontFaceDescriptors - ) { - FontFaceFixture.instances.push(this); - } -} - -describe("ShadowFontRegistry", () => { - const add = jest.fn(); - - beforeEach(() => { - FontFaceFixture.instances = []; - FontFaceFixture.loadError = undefined; - add.mockReset(); - jest.clearAllMocks(); - Object.defineProperty(globalThis, "FontFace", {configurable: true, value: FontFaceFixture}); - Object.defineProperty(document, "fonts", {configurable: true, value: {add}}); - }); - - test("registers each local definition once for the lifetime of the entry runtime", () => { - const registry = new ShadowFontRegistry([ - {family: "AdnbnPanelInter", source: "assets/panel.woff2", weight: "400"}, - {family: "AdnbnPanelInter", source: "assets/panel.woff2", weight: "400"}, - ]); - - registry.register(); - registry.register(); - - expect(FontFaceFixture.instances).toHaveLength(1); - expect(FontFaceFixture.instances[0]).toMatchObject({ - family: "AdnbnPanelInter", - source: 'url("chrome-extension://fixture/assets/panel.woff2")', - descriptors: {weight: "400"}, - }); - expect(add).toHaveBeenCalledWith(FontFaceFixture.instances[0]); - expect(FontFaceFixture.instances[0].load).toHaveBeenCalledTimes(1); - expect(getUrl).toHaveBeenCalledWith("assets/panel.woff2"); - }); - - test("reports a rejected font load without throwing into UI rendering", async () => { - const error = new Error("font unavailable"); - const report = jest.spyOn(console, "error").mockImplementation(() => {}); - FontFaceFixture.loadError = error; - const registry = new ShadowFontRegistry([{family: "BrokenFont", source: "assets/broken.woff2"}]); - - registry.register(); - await Promise.resolve(); - - new ShadowFontRegistry([{family: "RemoteFont", source: "https://fonts.example/font.woff2"}]).register(); - - expect(report).toHaveBeenCalledWith( - 'Loading shadow font "BrokenFont" from "assets/broken.woff2" failed', - error - ); - expect(report).toHaveBeenCalledWith( - 'Loading shadow font "RemoteFont" from "https://fonts.example/font.woff2" failed', - expect.any(Error) - ); - report.mockRestore(); - }); -}); diff --git a/src/entry/content/core/ShadowFontRegistry.ts b/src/entry/content/core/ShadowFontRegistry.ts deleted file mode 100644 index 0e546c2a..00000000 --- a/src/entry/content/core/ShadowFontRegistry.ts +++ /dev/null @@ -1,49 +0,0 @@ -import {getUrl} from "@addon-core/browser"; - -import type {ContentScriptShadowFontOptions} from "@typing/content"; - -const RemoteSource = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i; - -export default class ShadowFontRegistry { - private registered = false; - - public constructor(private readonly fonts: readonly ContentScriptShadowFontOptions[] = []) {} - - public register(): void { - if (this.registered) { - return; - } - - this.registered = true; - const definitions = new Set(); - - for (const {family, source, ...descriptors} of this.fonts) { - const descriptorEntries = Object.entries(descriptors).sort(([left], [right]) => left.localeCompare(right)); - const key = JSON.stringify([family, source, descriptorEntries]); - - if (definitions.has(key)) { - continue; - } - - definitions.add(key); - - try { - if (!source || RemoteSource.test(source)) { - throw new Error("font source must be a local imported asset"); - } - - const url = getUrl(source); - const font = new FontFace(family, `url(${JSON.stringify(url)})`, descriptors); - - document.fonts.add(font); - font.load().catch(error => this.report(family, source, error)); - } catch (error) { - this.report(family, source, error); - } - } - } - - private report(family: string, source: string, error: unknown): void { - console.error(`Loading shadow font "${family}" from "${source}" failed`, error); - } -} diff --git a/src/entry/content/core/Context.test.ts b/src/entry/content/core/context/Context.test.ts similarity index 96% rename from src/entry/content/core/Context.test.ts rename to src/entry/content/core/context/Context.test.ts index 6e7e5f45..5ffd6a91 100644 --- a/src/entry/content/core/Context.test.ts +++ b/src/entry/content/core/context/Context.test.ts @@ -1,4 +1,4 @@ -import Context from "./Context"; +import {Context} from "./index"; import type {ContentScriptEventEmitter, ContentScriptNode} from "@typing/content"; diff --git a/src/entry/content/core/Context.ts b/src/entry/content/core/context/Context.ts similarity index 100% rename from src/entry/content/core/Context.ts rename to src/entry/content/core/context/Context.ts diff --git a/src/entry/content/core/EventEmitter.ts b/src/entry/content/core/context/EventEmitter.ts similarity index 100% rename from src/entry/content/core/EventEmitter.ts rename to src/entry/content/core/context/EventEmitter.ts diff --git a/src/entry/content/core/ManagedContext.ts b/src/entry/content/core/context/ManagedContext.ts similarity index 100% rename from src/entry/content/core/ManagedContext.ts rename to src/entry/content/core/context/ManagedContext.ts diff --git a/src/entry/content/core/context/index.ts b/src/entry/content/core/context/index.ts new file mode 100644 index 00000000..dd9c73c7 --- /dev/null +++ b/src/entry/content/core/context/index.ts @@ -0,0 +1,3 @@ +export {default as Context} from "./Context"; +export {default as ManagedContext} from "./ManagedContext"; +export {default as EventEmitter} from "./EventEmitter"; diff --git a/src/entry/content/core/AbstractMarker.ts b/src/entry/content/core/markers/AbstractMarker.ts similarity index 100% rename from src/entry/content/core/AbstractMarker.ts rename to src/entry/content/core/markers/AbstractMarker.ts diff --git a/src/entry/content/core/AttributeMarker.test.ts b/src/entry/content/core/markers/AttributeMarker.test.ts similarity index 95% rename from src/entry/content/core/AttributeMarker.test.ts rename to src/entry/content/core/markers/AttributeMarker.test.ts index 8ef097f9..ce13eaa8 100644 --- a/src/entry/content/core/AttributeMarker.test.ts +++ b/src/entry/content/core/markers/AttributeMarker.test.ts @@ -1,4 +1,4 @@ -import AttributeMarker from "@entry/content/core/AttributeMarker"; +import AttributeMarker from "@entry/content/core/markers/AttributeMarker"; import {ContentScriptMarkerContract, ContentScriptMarkerValue} from "@typing/content"; describe("AttributeMarker — specifics", () => { diff --git a/src/entry/content/core/AttributeMarker.ts b/src/entry/content/core/markers/AttributeMarker.ts similarity index 100% rename from src/entry/content/core/AttributeMarker.ts rename to src/entry/content/core/markers/AttributeMarker.ts diff --git a/src/entry/content/core/WeakMarker.test.ts b/src/entry/content/core/markers/WeakMarker.test.ts similarity index 93% rename from src/entry/content/core/WeakMarker.test.ts rename to src/entry/content/core/markers/WeakMarker.test.ts index 05149124..1fa61203 100644 --- a/src/entry/content/core/WeakMarker.test.ts +++ b/src/entry/content/core/markers/WeakMarker.test.ts @@ -1,4 +1,4 @@ -import WeakMarker from "@entry/content/core/WeakMarker"; +import WeakMarker from "@entry/content/core/markers/WeakMarker"; describe("WeakMarker — specifics", () => { test("does not mutate DOM (no attributes)", () => { diff --git a/src/entry/content/core/WeakMarker.ts b/src/entry/content/core/markers/WeakMarker.ts similarity index 100% rename from src/entry/content/core/WeakMarker.ts rename to src/entry/content/core/markers/WeakMarker.ts diff --git a/src/entry/content/core/markers/index.ts b/src/entry/content/core/markers/index.ts new file mode 100644 index 00000000..95ec18f5 --- /dev/null +++ b/src/entry/content/core/markers/index.ts @@ -0,0 +1,3 @@ +export {default as AbstractMarker} from "./AbstractMarker"; +export {default as AttributeMarker} from "./AttributeMarker"; +export {default as WeakMarker} from "./WeakMarker"; diff --git a/src/entry/content/core/Marker.test.ts b/src/entry/content/core/markers/marker.test.ts similarity index 98% rename from src/entry/content/core/Marker.test.ts rename to src/entry/content/core/markers/marker.test.ts index 4f33962b..2364730f 100644 --- a/src/entry/content/core/Marker.test.ts +++ b/src/entry/content/core/markers/marker.test.ts @@ -1,5 +1,4 @@ -import AttributeMarker from "./AttributeMarker"; -import WeakMarker from "./WeakMarker"; +import {AttributeMarker, WeakMarker} from "./index"; import {ContentScriptMarkerContract, ContentScriptMarkerValue} from "@typing/content"; diff --git a/src/entry/content/core/EventNode.ts b/src/entry/content/core/nodes/EventNode.ts similarity index 100% rename from src/entry/content/core/EventNode.ts rename to src/entry/content/core/nodes/EventNode.ts diff --git a/src/entry/content/core/nodes/FrameNode.test.ts b/src/entry/content/core/nodes/FrameNode.test.ts new file mode 100644 index 00000000..332f58c5 --- /dev/null +++ b/src/entry/content/core/nodes/FrameNode.test.ts @@ -0,0 +1,94 @@ +import {FrameNode, Node, MountNode} from "./index"; +import {getContentScriptStylesRuntime} from "./isolated-styles"; + +jest.mock("@addon-core/browser", () => ({getUrl: (file: string) => `chrome-extension://fixture/${file}`})); +jest.mock("./isolated-styles", () => ({getContentScriptStylesRuntime: jest.fn()})); + +test("FrameNode explains a disconnected host and allows mounting again once the mounter connects it", () => { + const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + jest.mocked(getContentScriptStylesRuntime).mockReturnValue(runtime); + const anchor = document.createElement("section"); + const container = document.createElement("div"); + document.body.append(anchor); + let connect = false; + const node = new FrameNode( + new MountNode(new Node(anchor, container), (anchor, container) => { + if (connect) anchor.append(container); + }), + {}, + () => {} + ); + try { + expect(() => node.mount()).toThrow(/container is not connected.*mount must attach/); + expect(container.querySelector("iframe")).toBeNull(); + expect(runtime.add).not.toHaveBeenCalled(); + connect = true; + expect(node.mount()).toBe(true); + expect(node.target?.isConnected).toBe(true); + const frame = container.querySelector("iframe")!; + frame.contentDocument!.head.remove(); + expect(() => node.mount()).toThrow(/no accessible document/); + } finally { + node.unmount(); + anchor.remove(); + } +}); + +test("FrameNode does not require a connected host for document navigation", () => { + const container = document.createElement("div"); + const node = new FrameNode( + new Node(document.createElement("section"), container), + {src: "https://example.com"}, + () => {} + ); + try { + expect(node.mount()).toBe(true); + expect(container.querySelector("iframe")?.src).toBe("https://example.com/"); + expect(node.target).toBeUndefined(); + } finally { + node.unmount(); + } +}); + +test("FrameNode replaces lost targets, unregisters old styles and releases its load callback", async () => { + const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + jest.mocked(getContentScriptStylesRuntime).mockReturnValue(runtime); + const anchor = document.createElement("section"); + document.body.append(anchor); + const recover = jest.fn(); + const node = new FrameNode( + new MountNode(new Node(anchor, document.createElement("div")), (anchor, container) => { + anchor.append(container); + }), + {height: 320}, + recover + ); + node.mount(); + const frame = node.container!.querySelector("iframe")!; + const target = node.target!; + expect(frame.hasAttribute("src")).toBe(false); + expect(frame.style.height).toBe("320px"); + expect(target.ownerDocument).toBe(frame.contentDocument); + expect(runtime.add).toHaveBeenLastCalledWith(frame.contentDocument!.head, null, false); + // An initial load on an intact document does not start recovery or enable retries. + frame.dispatchEvent(new Event("load")); + await Promise.resolve(); + expect(recover).not.toHaveBeenCalled(); + target.remove(); + frame.dispatchEvent(new Event("load")); + await Promise.resolve(); + expect(recover).toHaveBeenCalledTimes(1); + node.mount(); + expect(node.target).not.toBe(target); + expect(runtime.delete).toHaveBeenCalledTimes(1); + expect(runtime.add).toHaveBeenLastCalledWith(frame.contentDocument!.head, null, true); + node.unmount(); + frame.dispatchEvent(new Event("load")); + await Promise.resolve(); + expect(recover).toHaveBeenCalledTimes(1); + node.mount(); + expect(node.container!.querySelector("iframe")).not.toBe(frame); + expect(runtime.add).toHaveBeenLastCalledWith(node.target!.ownerDocument!.head, null, false); + node.unmount(); + anchor.remove(); +}); diff --git a/src/entry/content/core/nodes/FrameNode.ts b/src/entry/content/core/nodes/FrameNode.ts new file mode 100644 index 00000000..62ecdb19 --- /dev/null +++ b/src/entry/content/core/nodes/FrameNode.ts @@ -0,0 +1,158 @@ +import {getPageUrl} from "@main/page"; + +import {getContentScriptStylesRuntime} from "./isolated-styles"; + +import {isContentScriptFrameNavigation} from "@shared/content"; + +import type {ContentScriptStylesRuntime, ContentScriptFrame, ContentScriptNode} from "@typing/content"; + +/** Owns the child document, but never moves the content script's JavaScript into it. */ +export default class FrameNode implements ContentScriptNode { + private frame?: HTMLIFrameElement; + private head?: HTMLHeadElement; + private _target?: Element; + private runtime?: ContentScriptStylesRuntime; + private generation = 0; + private queued = false; + + public constructor( + private readonly node: ContentScriptNode, + private readonly options: ContentScriptFrame = {}, + private readonly recover: () => void + ) {} + + public get anchor(): Element { + return this.node.anchor; + } + + + public get container(): Element | undefined { + return this.node.container; + } + + public get target(): Element | undefined { + return this._target; + } + + private readonly onLoad = (): void => { + if (!this.frame || this.queued || this.intact()) { + return; + } + + const generation = this.generation; + + this.queued = true; + + queueMicrotask(() => { + if (generation !== this.generation) { + return; + } + + this.queued = false; + + if (this.container?.isConnected && this.anchor.isConnected) { + try { + this.recover(); + } catch (error) { + console.error("Restoring content iframe failed", error); + } + } + }); + }; + + private intact(): boolean { + const doc = this.frame?.contentDocument; + + return !!doc && this._target?.ownerDocument === doc && this._target.isConnected && this.head === doc.head; + } + + public mount(): boolean { + const mounted = !!this.node.mount(); + + if (!this.container) { + return mounted; + } + + if (!isContentScriptFrameNavigation(this.options) && !this.container.isConnected) { + throw new Error( + "Content iframe container is not connected to the document; mount must attach the container before returning" + ); + } + + let created = false; + + if (!this.frame) { + const frame = this.container.ownerDocument.createElement("iframe"); + + frame.style.width = this.size(this.options.width ?? "100%"); + frame.style.height = this.size(this.options.height ?? 150); + frame.style.border = "0"; + frame.style.display = "block"; + + this.frame = frame; + + if (isContentScriptFrameNavigation(this.options)) { + frame.src = this.options.page !== undefined ? getPageUrl(this.options.page) : this.options.src!; + } else { + frame.addEventListener("load", this.onLoad); + } + + this.container.append(frame); + + created = true; + } + + if (isContentScriptFrameNavigation(this.options) || this.intact()) { + return mounted || created; + } + + const doc = this.frame.contentDocument; + + if (!doc?.head || !doc.body) { + throw new Error( + "Content iframe has no accessible document; only an empty same-origin iframe can render UI" + ); + } + + const recovering = this.head !== undefined; + + this.releaseStyles(); + + const target = doc.createElement("div"); + + doc.body.append(target); + + this._target = target; + this.head = doc.head; + this.runtime = getContentScriptStylesRuntime(); + this.runtime.add(doc.head, null, recovering); + + return true; + } + + public unmount(): boolean { + this.generation++; + this.queued = false; + this.frame?.removeEventListener("load", this.onLoad); + + this.releaseStyles(); + + this.frame = undefined; + + return !!this.node.unmount(); + } + + private releaseStyles(): void { + if (this.head) { + this.runtime?.delete(this.head); + } + + this.head = undefined; + this.runtime = undefined; + this._target = undefined; + } + + private size(value: number | string): string { + return typeof value === "number" ? `${value}px` : value; + } +} diff --git a/src/entry/content/core/MarkerNode.ts b/src/entry/content/core/nodes/MarkerNode.ts similarity index 100% rename from src/entry/content/core/MarkerNode.ts rename to src/entry/content/core/nodes/MarkerNode.ts diff --git a/src/entry/content/core/MountNode.ts b/src/entry/content/core/nodes/MountNode.ts similarity index 100% rename from src/entry/content/core/MountNode.ts rename to src/entry/content/core/nodes/MountNode.ts diff --git a/src/entry/content/core/Node.ts b/src/entry/content/core/nodes/Node.ts similarity index 100% rename from src/entry/content/core/Node.ts rename to src/entry/content/core/nodes/Node.ts diff --git a/src/entry/content/core/ShadowNode.test.ts b/src/entry/content/core/nodes/ShadowNode.test.ts similarity index 58% rename from src/entry/content/core/ShadowNode.test.ts rename to src/entry/content/core/nodes/ShadowNode.test.ts index 73d84a00..eaf04b67 100644 --- a/src/entry/content/core/ShadowNode.test.ts +++ b/src/entry/content/core/nodes/ShadowNode.test.ts @@ -1,31 +1,17 @@ -import {getUrl} from "@addon-core/browser"; - -import {getEntrypointAssets} from "@main/entrypoint"; - import MountNode from "./MountNode"; import Node from "./Node"; import ShadowNode from "./ShadowNode"; -import {getShadowStylesRuntime, type ShadowStylesRuntime} from "./shadow-styles"; - -jest.mock("@addon-core/browser", () => ({ - getUrl: jest.fn((file: string) => `chrome-extension://fixture/${file}`), -})); +import {getContentScriptStylesRuntime} from "./isolated-styles"; +import type {ContentScriptStylesRuntime} from "@typing/content"; -jest.mock("@main/entrypoint", () => ({ - getEntrypointAssets: jest.fn(() => ({ - initial: {js: ["content.js"], css: ["first.css", "second.css"]}, - async: {js: [], css: []}, - })), +jest.mock("./isolated-styles", () => ({ + getContentScriptStylesRuntime: jest.fn(), })); -jest.mock("./shadow-styles", () => ({ - getShadowStylesRuntime: jest.fn(), -})); - -const createNode = (runtime: ShadowStylesRuntime, host = document.createElement("section")) => { +const createNode = (runtime: ContentScriptStylesRuntime, host = document.createElement("section")) => { const anchor = document.createElement("div"); document.body.appendChild(anchor); - jest.mocked(getShadowStylesRuntime).mockReturnValue(runtime); + jest.mocked(getContentScriptStylesRuntime).mockReturnValue(runtime); return { anchor, @@ -44,15 +30,10 @@ describe("ShadowNode", () => { jest.clearAllMocks(); }); - test("creates an open root, registers initial styles and exposes the render target", () => { - const runtime: ShadowStylesRuntime = { - add: jest.fn((root, target, styles) => { - for (const style of styles) { - const link = document.createElement("link"); - link.href = style; - root.insertBefore(link, target); - } - }), + test("creates an open root, registers it with the styles runtime and exposes the render target", () => { + const runtime: ContentScriptStylesRuntime = { + initialize: jest.fn(), + add: jest.fn(), delete: jest.fn(), load: jest.fn(async () => undefined), }; @@ -62,24 +43,16 @@ describe("ShadowNode", () => { expect(host.shadowRoot).not.toBeNull(); expect(node.target).toBe(host.shadowRoot?.lastElementChild); expect(node.target).not.toBe(host); - expect(runtime.add).toHaveBeenCalledWith(host.shadowRoot, node.target, [ - "chrome-extension://fixture/first.css", - "chrome-extension://fixture/second.css", - ]); - expect(Array.from(host.shadowRoot?.children ?? []).map(element => element.tagName)).toEqual([ - "LINK", - "LINK", - "DIV", - ]); - expect(getEntrypointAssets).toHaveBeenCalledTimes(1); - expect(getUrl).toHaveBeenCalledTimes(2); + expect(runtime.add).toHaveBeenCalledWith(host.shadowRoot, node.target); + expect(Array.from(host.shadowRoot?.children ?? []).map(element => element.tagName)).toEqual(["DIV"]); }); test("unregisters the root before removing the host and creates a new root on remount", () => { const states: boolean[] = []; - const runtime: ShadowStylesRuntime = { + const runtime: ContentScriptStylesRuntime = { + initialize: jest.fn(), add: jest.fn(), - delete: jest.fn(root => states.push(root.host.isConnected)), + delete: jest.fn(root => states.push((root as ShadowRoot).host.isConnected)), load: jest.fn(async () => undefined), }; const {anchor, node} = createNode(runtime); @@ -101,7 +74,8 @@ describe("ShadowNode", () => { }); test("rejects a container with an existing open ShadowRoot", () => { - const runtime: ShadowStylesRuntime = { + const runtime: ContentScriptStylesRuntime = { + initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn(async () => undefined), diff --git a/src/entry/content/core/ShadowNode.ts b/src/entry/content/core/nodes/ShadowNode.ts similarity index 76% rename from src/entry/content/core/ShadowNode.ts rename to src/entry/content/core/nodes/ShadowNode.ts index 63e583c3..0365ce57 100644 --- a/src/entry/content/core/ShadowNode.ts +++ b/src/entry/content/core/nodes/ShadowNode.ts @@ -1,8 +1,5 @@ -import {getUrl} from "@addon-core/browser"; - -import {getEntrypointAssets} from "@main/entrypoint"; - -import {getShadowStylesRuntime, type ShadowStylesRuntime} from "./shadow-styles"; +import {getContentScriptStylesRuntime} from "./isolated-styles"; +import type {ContentScriptStylesRuntime} from "@typing/content"; import type {ContentScriptNode} from "@typing/content"; @@ -11,7 +8,7 @@ export default class ShadowNode implements ContentScriptNode { private _target?: Element; - private runtime?: ShadowStylesRuntime; + private runtime?: ContentScriptStylesRuntime; public constructor(protected readonly node: ContentScriptNode) {} @@ -43,13 +40,11 @@ export default class ShadowNode implements ContentScriptNode { } const root = this.container.attachShadow({mode: "open"}); - const target = document.createElement("div"); + const target = this.container.ownerDocument.createElement("div"); root.appendChild(target); - const runtime = getShadowStylesRuntime(); - const initialStyles = getEntrypointAssets().initial.css.map(file => getUrl(file)); - - runtime.add(root, target, initialStyles); + const runtime = getContentScriptStylesRuntime(); + runtime.add(root, target); this.root = root; this._target = target; diff --git a/src/entry/content/core/nodes/index.ts b/src/entry/content/core/nodes/index.ts new file mode 100644 index 00000000..d9ed48cc --- /dev/null +++ b/src/entry/content/core/nodes/index.ts @@ -0,0 +1,6 @@ +export {default as Node} from "./Node"; +export {default as MountNode} from "./MountNode"; +export {default as MarkerNode} from "./MarkerNode"; +export {default as EventNode} from "./EventNode"; +export {default as ShadowNode} from "./ShadowNode"; +export {default as FrameNode} from "./FrameNode"; diff --git a/src/entry/content/core/nodes/isolated-styles.ts b/src/entry/content/core/nodes/isolated-styles.ts new file mode 100644 index 00000000..72c9daaa --- /dev/null +++ b/src/entry/content/core/nodes/isolated-styles.ts @@ -0,0 +1,23 @@ +import {getUrl} from "@addon-core/browser"; + +import {ContentScriptStylesRuntimeProperty, type ContentScriptStylesRuntime} from "@typing/content"; + +interface WebpackRuntime { + (moduleId: string | number): unknown; + [ContentScriptStylesRuntimeProperty]?: ContentScriptStylesRuntime; +} + +declare const __webpack_require__: WebpackRuntime; + +export const getContentScriptStylesRuntime = (): ContentScriptStylesRuntime => { + const runtime = + typeof __webpack_require__ === "function" ? __webpack_require__[ContentScriptStylesRuntimeProperty] : undefined; + + if (!runtime) { + throw new Error("Isolated styles runtime is unavailable in this content entrypoint"); + } + + runtime.initialize(getUrl); + + return runtime; +}; diff --git a/src/entry/content/core/shadow-styles.ts b/src/entry/content/core/shadow-styles.ts deleted file mode 100644 index 4a3b31e2..00000000 --- a/src/entry/content/core/shadow-styles.ts +++ /dev/null @@ -1,25 +0,0 @@ -export const ShadowStylesRuntimeProperty = "__adnbnShadowStyles"; - -export interface ShadowStylesRuntime { - add(root: ShadowRoot, target: Element, initialStyles: string[]): void; - delete(root: ShadowRoot): void; - load(url: string): Promise; -} - -interface WebpackRuntime { - (moduleId: string | number): unknown; - __adnbnShadowStyles?: ShadowStylesRuntime; -} - -declare const __webpack_require__: WebpackRuntime; - -export const getShadowStylesRuntime = (): ShadowStylesRuntime => { - const runtime = - typeof __webpack_require__ === "function" ? __webpack_require__[ShadowStylesRuntimeProperty] : undefined; - - if (!runtime) { - throw new Error("Shadow styles runtime is unavailable in this content entrypoint"); - } - - return runtime; -}; diff --git a/src/entry/content/frame/Builder.ts b/src/entry/content/frame/Builder.ts new file mode 100644 index 00000000..6baa8871 --- /dev/null +++ b/src/entry/content/frame/Builder.ts @@ -0,0 +1,14 @@ +import MountBuilder from "../core/MountBuilder"; +import EventNode from "../core/nodes/EventNode"; +import type {ContentScriptDefinition, ContentScriptNode} from "@typing/content"; + +/** Page/src navigation has a document owner of its own and does not use a UI renderer. */ +export default class Builder extends MountBuilder { + public constructor(definition: ContentScriptDefinition) { + super(definition); + } + + protected async createNode(anchor: Element): Promise { + return new EventNode(await super.createNode(anchor), this.emitter); + } +} diff --git a/src/entry/content/frame/index.ts b/src/entry/content/frame/index.ts new file mode 100644 index 00000000..a1fca591 --- /dev/null +++ b/src/entry/content/frame/index.ts @@ -0,0 +1,4 @@ +import Builder from "./Builder"; + +export {Builder}; +export default Builder.resolver(); diff --git a/src/shared/content/index.ts b/src/shared/content/index.ts new file mode 100644 index 00000000..ceee291f --- /dev/null +++ b/src/shared/content/index.ts @@ -0,0 +1 @@ +export {isContentScriptFrameNavigation, validateContentScriptIsolation} from "./isolation"; diff --git a/src/shared/content/isolation.test.ts b/src/shared/content/isolation.test.ts new file mode 100644 index 00000000..558d4717 --- /dev/null +++ b/src/shared/content/isolation.test.ts @@ -0,0 +1,15 @@ +/** @jest-environment node */ +import {isContentScriptFrameNavigation, validateContentScriptIsolation} from "./isolation"; +import type {ContentScriptFrame} from "@typing/content"; + +test.each<[ContentScriptFrame | undefined, boolean]>([ + [undefined, false], + [{height: 320}, false], + [{page: "panel"}, true], + [{src: "https://example.com/panel"}, true], +])("recognizes frame navigation without DOM dependencies: %j", (frame, expected) => { + expect(isContentScriptFrameNavigation(frame)).toBe(expected); + expect(() => validateContentScriptIsolation("iframe", frame, false)).not.toThrow(); + if (expected) + expect(() => validateContentScriptIsolation("iframe", frame, true)).toThrow(/cannot be combined with render/); +}); diff --git a/src/shared/content/isolation.ts b/src/shared/content/isolation.ts new file mode 100644 index 00000000..8948d92a --- /dev/null +++ b/src/shared/content/isolation.ts @@ -0,0 +1,54 @@ +import {ContentScriptIsolation, type ContentScriptFrame} from "@typing/content"; + +export const isContentScriptFrameNavigation = (frame?: ContentScriptFrame): boolean => + frame?.page !== undefined || frame?.src !== undefined; + +/** Pure structural checks shared by CLI and runtime; no DOM or build dependencies. */ +export const validateContentScriptIsolation = (isolation: unknown, frame: unknown, hasRender: boolean): void => { + const mode = isolation ?? ContentScriptIsolation.None; + if (!Object.values(ContentScriptIsolation).includes(mode as ContentScriptIsolation)) { + throw new Error("isolation must be a statically known ContentScriptIsolation value: none, shadow or iframe"); + } + if (frame !== undefined && mode !== ContentScriptIsolation.Iframe) { + throw new Error("frame requires isolation: Iframe"); + } + if (frame === undefined) return; + if (!frame || typeof frame !== "object" || Array.isArray(frame)) { + throw new Error("frame must be an object with statically known page/src properties"); + } + const {page, src, width, height} = frame as Record; + if (page !== undefined && src !== undefined) throw new Error("frame.page and frame.src are mutually exclusive"); + if (page !== undefined && (typeof page !== "string" || !page.trim())) + throw new Error("frame.page must be a statically known page alias"); + if (src !== undefined) { + if (typeof src !== "string" || !/^(https?|chrome-extension|moz-extension):\/\//.test(src)) { + throw new Error("frame.src must be an absolute HTTP(S) or extension URL"); + } + try { + new URL(src); + } catch { + throw new Error("frame.src must be a valid absolute URL"); + } + } + if ((page !== undefined || src !== undefined) && hasRender) { + throw new Error( + "frame.page/frame.src cannot be combined with render; the embedded page owns its UI and resources" + ); + } + for (const [name, value] of [ + ["width", width], + ["height", height], + ] as const) { + if ( + value !== undefined && + !( + (typeof value === "number" && Number.isFinite(value) && value >= 0) || + (typeof value === "string" && value.trim()) + ) + ) + throw new Error(`frame.${name} must be pixels or a CSS size`); + } + if (typeof height === "string" && height.trim().toLowerCase() === "auto") { + throw new Error('frame.height: "auto" is not supported yet; specify an explicit height'); + } +}; diff --git a/src/types/content.ts b/src/types/content.ts index 1ae48d5d..e90a3259 100644 --- a/src/types/content.ts +++ b/src/types/content.ts @@ -1,3 +1,4 @@ +import type {PageAlias} from "@typing/page"; import type {FC, ReactNode} from "react"; import {Optional} from "utility-types"; @@ -8,6 +9,18 @@ type RunAt = chrome.extensionTypes.RunAt; export const ContentScriptMatches = ["http://*/*", "https://*/*"]; +/** Shared property used by the bundler plugin and the content script's style runtime getter. */ +export const ContentScriptStylesRuntimeProperty = "__adnbnIsolatedStyles"; + +export interface ContentScriptStylesRuntime { + /** Resolves initial stylesheet URLs once, before the first root is registered. */ + initialize(resolveUrl: (file: string) => string): void; + /** Retry restored styles once on load error; future loads keep the default failure behavior. */ + add(root: ShadowRoot | HTMLElement, target: Element | null, retry?: boolean): void; + delete(root: ShadowRoot | HTMLElement): void; + load(url: string): Promise; +} + export enum ContentScriptWorld { Isolated = "ISOLATED", Main = "MAIN", @@ -86,28 +99,46 @@ export interface ContentScriptConfig { export type ContentScriptOptions = ContentScriptConfig & EntrypointOptions; -export type ContentScriptEntrypointOptions = Partial & { - shadow?: boolean; -}; +export enum ContentScriptIsolation { + None = "none", + Shadow = "shadow", + Iframe = "iframe", +} -/** - * Renders the content UI inside an open ShadowRoot. An object enables Shadow DOM and configures - * the document-level resources required by that UI. - */ -export type ContentScriptShadow = boolean | ContentScriptShadowOptions; +export type ContentScriptIsolationValue = ContentScriptIsolation | `${ContentScriptIsolation}`; + +export interface ContentScriptFrameOptions { + width?: number | string; + /** Defaults to 150px. Automatic height is not supported yet. */ + height?: number | string; +} -export interface ContentScriptShadowOptions { - /** Local imported font files registered for this entrypoint through FontFace. */ - fonts?: ContentScriptShadowFontOptions[]; +export interface ContentScriptFrameRenderOptions extends ContentScriptFrameOptions { + page?: never; + src?: never; } -export interface ContentScriptShadowFontOptions extends FontFaceDescriptors { - /** A family name unique enough not to collide with the host page or another entrypoint. */ - family: string; - /** A local font import emitted by the framework asset pipeline. Remote URLs are unsupported. */ - source: string; +export interface ContentScriptFramePageOptions extends ContentScriptFrameOptions { + page: PageAlias; + src?: never; } +export interface ContentScriptFrameSourceOptions extends ContentScriptFrameOptions { + src: string; + page?: never; +} + +export type ContentScriptFrame = + | ContentScriptFrameRenderOptions + | ContentScriptFramePageOptions + | ContentScriptFrameSourceOptions; + +/** Statically resolved configuration; runtime render values are never retained here. */ +export type ContentScriptEntrypointOptions = Partial & { + isolation?: ContentScriptIsolationValue; + frame?: ContentScriptFrame; +}; + // Append export enum ContentScriptAppend { Last = "last", @@ -273,20 +304,45 @@ export interface ContentScriptNode extends ContentScriptMount { export type ContentScriptNodeSet = Set; // Definition -export interface ContentScriptDefinition extends Omit { - shadow?: ContentScriptShadow; +export interface ContentScriptDefinitionBase extends Partial { marker?: ContentScriptMarkerType | ContentScriptMarkerGetter; anchor?: ContentScriptAnchor | ContentScriptAnchorGetter; mount?: ContentScriptMountFunction; - render?: ContentScriptRenderValue | ContentScriptRenderHandler; container?: ContentScriptContainerTag | ContentScriptContainerOptions | ContentScriptContainerFactory; watch?: true | ContentScriptWatchStrategy; main?: ContentScriptMainFunction; } -// prettier-ignore -export interface ContentScriptResolvedDefinition extends Omit { - shadow?: ContentScriptShadowOptions; +export type ContentScriptDefinition = ContentScriptDefinitionBase & + ( + | { + isolation?: ContentScriptIsolation.None | "none"; + frame?: never; + render?: ContentScriptRenderValue | ContentScriptRenderHandler; + } + | { + isolation: ContentScriptIsolation.Shadow | "shadow"; + frame?: never; + render?: ContentScriptRenderValue | ContentScriptRenderHandler; + } + | { + isolation: ContentScriptIsolation.Iframe | "iframe"; + frame?: ContentScriptFrameRenderOptions; + render?: ContentScriptRenderValue | ContentScriptRenderHandler; + } + | { + isolation: ContentScriptIsolation.Iframe | "iframe"; + frame: ContentScriptFramePageOptions | ContentScriptFrameSourceOptions; + render?: never; + } + ); + +export interface ContentScriptResolvedDefinition extends Omit< + ContentScriptDefinitionBase, + "anchor" | "marker" | "mount" | "container" | "watch" +> { + isolation: ContentScriptIsolationValue; + frame?: ContentScriptFrame; marker: ContentScriptMarkerResolver; anchor: ContentScriptAnchorGetter; mount: ContentScriptMountFunction; @@ -295,9 +351,8 @@ export interface ContentScriptResolvedDefinition extends Omit { - append?: ContentScriptAppend; -} +type ContentScriptAppendVariant = T extends unknown ? Omit & {append?: ContentScriptAppend} : never; +export type ContentScriptAppendDefinition = ContentScriptAppendVariant; // Builder export interface ContentScriptBuilder extends EntrypointBuilder { diff --git a/src/types/manifest.ts b/src/types/manifest.ts index bd7dfe2c..81b77f92 100644 --- a/src/types/manifest.ts +++ b/src/types/manifest.ts @@ -198,11 +198,7 @@ export interface ManifestEntry { export type ManifestBackground = ManifestEntry & BackgroundConfig; -export type ManifestContentScript = ManifestEntry & - ContentScriptConfig & { - /** Internal build flag. It is never emitted to manifest.json. */ - shadow?: boolean; - }; +export type ManifestContentScript = ManifestEntry & ContentScriptConfig; export type ManifestContentScripts = Set; export type ManifestCommand = CommandConfig; @@ -270,8 +266,11 @@ export type ManifestSandbox = string; export type ManifestSandboxes = Set; export interface ManifestDependency { + /** Initial scripts, in execution order. */ js: Set; + /** Initial styles delivered by the manifest. */ css: Set; + /** Runtime resources, including lazy JS/CSS and other emitted files. */ assets: Set; } diff --git a/src/types/relay.ts b/src/types/relay.ts index e0d976b5..2b887cbe 100644 --- a/src/types/relay.ts +++ b/src/types/relay.ts @@ -1,6 +1,17 @@ import {EntrypointOptions} from "@typing/entrypoint"; -import {ContentScriptConfig, ContentScriptContext, ContentScriptDefinition} from "@typing/content"; -import {TransportConfig, TransportDefinition, TransportType, type TransportProxyTarget, type TransportTarget} from "@typing/transport"; +import { + ContentScriptConfig, + ContentScriptContext, + ContentScriptDefinition, + ContentScriptEntrypointOptions, +} from "@typing/content"; +import { + TransportConfig, + TransportDefinition, + TransportType, + type TransportProxyTarget, + type TransportTarget, +} from "@typing/transport"; import {Awaiter} from "@typing/helpers"; import type {MessageError} from "@typing/message"; @@ -188,7 +199,8 @@ export type RelayOptions = RelayConfig & EntrypointOptions; export type RelayOptionsMap = Map; -export type RelayEntrypointOptions = Partial; +export type RelayEntrypointOptions = Partial & + Pick; export type RelayMainHandler = ( relay: T, @@ -196,12 +208,13 @@ export type RelayMainHandler = ( options: RelayEntrypointOptions ) => Awaiter; -export interface RelayDefinition - extends - Omit, "main">, - Omit, - RelayEntrypointOptions { - main?: RelayMainHandler; -} +type RelayContentDefinition = T extends unknown ? Omit : never; + +export type RelayDefinition = Omit, "main"> & + RelayContentDefinition & + RelayEntrypointOptions & { + main?: RelayMainHandler; + }; -export type RelayUnresolvedDefinition = Partial>; +/** Internal, merged runtime input. The public definition retains its discriminated union. */ +export type RelayUnresolvedDefinition = Partial, never>>; diff --git a/tests/integration/browser/content/css-fonts-utils.ts b/tests/integration/browser/content/css-fonts-utils.ts new file mode 100644 index 00000000..9918ece7 --- /dev/null +++ b/tests/integration/browser/content/css-fonts-utils.ts @@ -0,0 +1,141 @@ +import {build} from "esbuild"; +import {mkdir, mkdtemp, readFile, rm, writeFile} from "fs/promises"; +import path from "path"; + +import {waitFor} from "../utils/browser"; +import {startBrowserSession} from "../utils/session"; +import {startIntegrationSite} from "../utils/site"; + +interface CssFontsProbeState { + probe: string; + mode: "shadow" | "iframe"; + ready: string; + measurements?: string; + error?: string; +} + +export const runCssFontsProbe = async (browser: "chrome" | "firefox", manifestVersion: 2 | 3) => { + const root = path.resolve(__dirname, "../../../.."); + const fixture = path.join(__dirname, "css-fonts"); + const cache = path.join(root, ".cache/integration"); + await mkdir(cache, {recursive: true}); + const extension = await mkdtemp(path.join(cache, "css-fonts-")); + let session: Awaited> | undefined; + const measurements: unknown[] = []; + let failure: unknown; + try { + await build({ + entryPoints: { + probe: path.join(fixture, "src/probe.ts"), + "styles/isolated": path.join(fixture, "src/isolated.css"), + }, + outdir: extension, + bundle: true, + format: "iife", + platform: "browser", + assetNames: "assets/[name]-[hash]", + loader: {".woff2": "file"}, + }); + await build({ + entryPoints: {"styles/page": path.join(fixture, "src/page.css")}, + outdir: extension, + bundle: true, + assetNames: "assets/[name]-[hash]", + loader: {".woff2": "file"}, + // Chrome resolves manifest CSS URLs against the host page. Its native CSS + // localization supplies the installed extension ID, without a runtime bridge. + publicPath: browser === "chrome" ? "chrome-extension://__MSG_@@extension_id__/" : undefined, + }); + const source = await readFile(path.join(extension, "probe.js"), "utf8"); + expect(source).not.toMatch(/new FontFace\s*\(|\.fonts\.(add|load)\s*\(/); + const resources = ["styles/*.css", "assets/*.woff2"]; + await writeFile( + path.join(extension, "manifest.json"), + JSON.stringify({ + manifest_version: manifestVersion, + name: "CSS fonts without runtime registration", + version: "1.0.0", + ...(browser === "firefox" ? {browser_specific_settings: {gecko: {id: "css-fonts@adnbn.test"}}} : {}), + content_scripts: [ + { + matches: ["http://127.0.0.1/*"], + js: ["probe.js"], + css: ["styles/page.css"], + run_at: "document_end", + }, + ], + web_accessible_resources: + manifestVersion === 2 ? resources : [{resources, matches: ["http://127.0.0.1/*"]}], + }) + ); + session = await startBrowserSession(browser, root, extension); + for (const policy of [ + null, + "default-src 'none'; style-src 'none'; style-src-elem 'none'; font-src 'none'; frame-src 'self'", + ]) { + const site = await startIntegrationSite(path.join(fixture, "site"), policy); + let state: CssFontsProbeState[] = []; + try { + await session.navigate(site.origin + "/top.html"); + await waitFor(async () => { + state = await session!.evaluate( + "Array.from(document.querySelectorAll('[data-probe]'), host => ({...host.dataset}))" + ); + return state.length === 4 && state.every(probe => probe.ready !== "loading") ? state : undefined; + }, 30_000); + const result = state.map(probe => ({ + ...probe, + measurements: probe.measurements ? JSON.parse(probe.measurements) : undefined, + })); + const page = await session.evaluate(`({ + color: getComputedStyle(document.querySelector('#page-marker')).color, + outsideColor: getComputedStyle(document.querySelector('body > .scope-marker')).color, + fonts: Array.from(document.fonts, font => ({family: font.family, status: font.status})) + })`); + measurements.push({policy, result, page}); + const failed = result.filter(probe => probe.ready !== "true"); + if (failed.length) throw new Error(`CSS font probe failed: ${JSON.stringify(failed)}`); + expect(page.color).toBe("rgb(34, 102, 68)"); + expect(page.outsideColor).not.toBe("rgb(17, 85, 153)"); + for (const probe of result.filter(probe => probe.mode === "iframe")) { + const {widths} = probe.measurements; + // A parent's font registration does not cross an iframe's document boundary. + expect(widths.manifest).toBeCloseTo(widths.fallback, 1); + expect(widths.imported).toBeCloseTo(widths.fallback, 1); + expect(widths.local).toBeCloseTo(320, 1); + } + } catch (error) { + failure ??= error; + // Preserve both CSP measurements even when one policy fails. + if (state.length !== 4) measurements.push({policy, state, error: String(error)}); + } finally { + await site.close(); + } + } + expect(session.errors).toEqual([]); + if (failure) throw failure; + } finally { + try { + await writeFile( + path.join(cache, `css-fonts-${browser}-mv${manifestVersion}.json`), + JSON.stringify( + { + browser: session?.version, + manifestVersion, + measurements, + failure: failure ? String(failure) : undefined, + errors: session?.errors, + }, + null, + 2 + ) + ); + } finally { + try { + await session?.close(); + } finally { + await rm(extension, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } + } + } +}; diff --git a/tests/integration/browser/content/css-fonts.firefox.integration.test.ts b/tests/integration/browser/content/css-fonts.firefox.integration.test.ts new file mode 100644 index 00000000..62965de3 --- /dev/null +++ b/tests/integration/browser/content/css-fonts.firefox.integration.test.ts @@ -0,0 +1,8 @@ +/** @jest-environment node */ +import {runCssFontsProbe} from "./css-fonts-utils"; + +jest.setTimeout(90_000); + +test.each([2, 3] as const)("Firefox MV%s uses CSS-defined fonts without FontFace registration", async version => { + await runCssFontsProbe("firefox", version); +}); diff --git a/tests/integration/browser/content/css-fonts.integration.test.ts b/tests/integration/browser/content/css-fonts.integration.test.ts new file mode 100644 index 00000000..0ff383ef --- /dev/null +++ b/tests/integration/browser/content/css-fonts.integration.test.ts @@ -0,0 +1,8 @@ +/** @jest-environment node */ +import {runCssFontsProbe} from "./css-fonts-utils"; + +jest.setTimeout(90_000); + +test("Chrome MV3 uses CSS-defined fonts in shadow roots and blank iframes without FontFace registration", async () => { + await runCssFontsProbe("chrome", 3); +}); diff --git a/tests/integration/browser/content/css-fonts/site/top.html b/tests/integration/browser/content/css-fonts/site/top.html new file mode 100644 index 00000000..b0a617b4 --- /dev/null +++ b/tests/integration/browser/content/css-fonts/site/top.html @@ -0,0 +1,12 @@ + + + + + CSS font delivery probe + + +

Host page

+ Outside isolated UI +
+ + diff --git a/tests/integration/browser/content/css-fonts/src/imported-font.css b/tests/integration/browser/content/css-fonts/src/imported-font.css new file mode 100644 index 00000000..8ea5cee6 --- /dev/null +++ b/tests/integration/browser/content/css-fonts/src/imported-font.css @@ -0,0 +1,7 @@ +@font-face { + font-family: "AdnbnImportedFont"; + src: url("../../isolation-shadow/src/probe.content/probe.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} diff --git a/tests/integration/browser/content/css-fonts/src/isolated.css b/tests/integration/browser/content/css-fonts/src/isolated.css new file mode 100644 index 00000000..f5229f71 --- /dev/null +++ b/tests/integration/browser/content/css-fonts/src/isolated.css @@ -0,0 +1,37 @@ +@font-face { + font-family: "AdnbnLocalFont"; + src: url("../../isolation-shadow/src/probe.content/probe.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +.scope-marker { + color: rgb(17, 85, 153); +} + +.sample { + font-size: 40px; + font-weight: 400; + font-style: normal; + font-kerning: none; + font-variant-ligatures: none; + letter-spacing: 0; + white-space: pre; +} + +.manifest { + font-family: "AdnbnManifestFont", monospace; +} + +.imported { + font-family: "AdnbnImportedFont", monospace; +} + +.local { + font-family: "AdnbnLocalFont", monospace; +} + +.fallback { + font-family: monospace; +} diff --git a/tests/integration/browser/content/css-fonts/src/page.css b/tests/integration/browser/content/css-fonts/src/page.css new file mode 100644 index 00000000..3fa2ff98 --- /dev/null +++ b/tests/integration/browser/content/css-fonts/src/page.css @@ -0,0 +1,13 @@ +@import "./imported-font.css"; + +@font-face { + font-family: "AdnbnManifestFont"; + src: url("../../isolation-shadow/src/probe.content/probe.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +#page-marker { + color: rgb(34, 102, 68); +} diff --git a/tests/integration/browser/content/css-fonts/src/probe.ts b/tests/integration/browser/content/css-fonts/src/probe.ts new file mode 100644 index 00000000..5a3bfe15 --- /dev/null +++ b/tests/integration/browser/content/css-fonts/src/probe.ts @@ -0,0 +1,99 @@ +// Browser resource-loading check only: no production renderer or JS font registration. +const extensionUrl = (file: string) => chrome.runtime.getURL(file); +const measure = (element: Element): number => { + const range = element.ownerDocument.createRange(); + range.selectNodeContents(element); + return range.getBoundingClientRect().width; +}; + +const mount = async (mode: "shadow" | "iframe", index: number): Promise => { + const host = document.createElement("section"); + host.dataset.probe = `${mode}-${index}`; + host.dataset.mode = mode; + host.dataset.ready = "loading"; + document.querySelector("#probes")!.append(host); + try { + let doc = document; + let styles: ShadowRoot | HTMLHeadElement; + let target: ShadowRoot | HTMLElement; + if (mode === "shadow") { + styles = target = host.attachShadow({mode: "open"}); + } else { + const frame = document.createElement("iframe"); + await new Promise((resolve, reject) => { + const onLoad = () => { + clearTimeout(timeout); + resolve(); + }; + const timeout = setTimeout(() => { + frame.removeEventListener("load", onLoad); + reject(new Error("Blank iframe did not load within 5 seconds")); + }, 5000); + frame.addEventListener("load", onLoad, {once: true}); + host.append(frame); + }); + doc = frame.contentDocument!; + styles = doc.head; + target = doc.body; + } + const marker = doc.createElement("div"); + marker.className = "scope-marker"; + target.append(marker); + const samples = Object.fromEntries( + ["manifest", "imported", "local", "fallback"].map(name => { + const sample = doc.createElement("span"); + sample.className = `sample ${name}`; + sample.textContent = "AAAA"; + marker.append(sample); + return [name, sample]; + }) + ); + await new Promise((resolve, reject) => { + const link = doc.createElement("link"); + link.rel = "stylesheet"; + link.href = extensionUrl("styles/isolated.css"); + const timeout = setTimeout(() => reject(new Error(`CSS timeout: ${link.href}`)), 5000); + const finish = (error?: Error) => { + clearTimeout(timeout); + link.onload = link.onerror = null; + if (error) reject(error); + else resolve(); + }; + link.onload = () => finish(); + link.onerror = () => finish(new Error(`CSS error: ${link.href}`)); + styles.append(link); + }); + const deadline = performance.now() + 5000; + while (performance.now() < deadline) { + const widths = Object.fromEntries( + Object.entries(samples).map(([name, element]) => [name, measure(element)]) + ); + const fonts = Array.from(doc.fonts, font => ({family: font.family, status: font.status})); + const color = doc.defaultView!.getComputedStyle(marker).color; + host.dataset.measurements = JSON.stringify({widths, fonts, color}); + const applied = (name: string) => Math.abs(widths[name] - 320) < 0.1; + if ( + color === "rgb(17, 85, 153)" && + (mode === "shadow" ? applied("manifest") && applied("imported") : applied("local")) + ) { + host.dataset.ready = "true"; + return; + } + await new Promise(resolve => setTimeout(resolve, 25)); + } + throw new Error("CSS font width did not reach 320px without JS font registration"); + } catch (error) { + host.dataset.error = String(error); + host.dataset.ready = "error"; + } +}; + +void (async () => { + for (const mode of ["shadow", "iframe"] as const) { + await mount(mode, 1); + // A root created after the first font load must work without re-registering the font. + await mount(mode, 2); + } +})(); + +export {}; diff --git a/tests/integration/browser/content/frame-pages-utils.ts b/tests/integration/browser/content/frame-pages-utils.ts new file mode 100644 index 00000000..ab8bd915 --- /dev/null +++ b/tests/integration/browser/content/frame-pages-utils.ts @@ -0,0 +1,80 @@ +import {readFile, writeFile} from "fs/promises"; +import path from "path"; +import {createIntegrationFixture} from "../../utils/fixture"; +import {startBrowserSession} from "../utils/session"; +import {startIntegrationSite} from "../utils/site"; +import {waitFor} from "../utils/browser"; + +export const runFramePagesIntegration = async (browser: "chrome" | "firefox", manifestVersion: 2 | 3) => { + const root = path.resolve(__dirname, "../../../.."); + const fixture = await createIntegrationFixture(root, path.join(__dirname, "frame-pages")); + const site = await startIntegrationSite(path.join(fixture.directory, "site"), null); + let session: Awaited> | undefined; + try { + const source = path.join(fixture.directory, "src/source.content.ts"); + await writeFile(source, (await readFile(source, "utf8")).replace("http://127.0.0.1:1", site.origin)); + const extension = await fixture.build({browser, manifestVersion}); + const manifest = JSON.parse(await readFile(path.join(extension, "manifest.json"), "utf8")); + expect(manifest.content_scripts).toHaveLength(2); + const scripts = manifest.content_scripts.flatMap((entry: {js: string[]}) => entry.js); + const code = ( + await Promise.all(scripts.map((file: string) => readFile(path.join(extension, file), "utf8"))) + ).join("\n"); + expect(code).not.toContain("react-dom"); + session = await startBrowserSession(browser, root, extension); + await session.navigate(site.origin + "/top.html"); + const state = await waitFor(async () => { + const result = await session!.evaluate( + `Array.from(document.querySelectorAll('.page-host iframe, .source-host iframe'), frame => ({kind: frame.parentElement.className, src: frame.src, ready: frame.dataset.ready, height: frame.style.height}))` + ); + return result.length === 2 && result.every((frame: {ready?: string}) => frame.ready === "true") + ? result + : undefined; + }); + const pageState = state.find((frame: {kind: string}) => frame.kind === "page-host"); + const sourceState = state.find((frame: {kind: string}) => frame.kind === "source-host"); + expect(pageState.src).toMatch(/^(chrome|moz)-extension:\/\//); + expect(pageState.height).toBe("250px"); + expect(sourceState.src).toBe(site.origin + "/page.html"); + expect(session.errors).toEqual([]); + // Real framework build: a valid alias alone must not bypass WAR coverage. + const page = path.join(fixture.directory, "src/panel.page/index.ts"); + await writeFile( + page, + (await readFile(page, "utf8")).replace("http://127.0.0.1/*", "https://unrelated.example/*") + ); + if (manifestVersion === 3) { + await expect(fixture.build({browser, manifestVersion})).rejects.toThrow(/add matches to the page/); + + // User-supplied WAR participates in late validation; a missing page.matches rule is not final. + const config = path.join(fixture.directory, "adnbn.config.ts"); + const resources = manifest.web_accessible_resources.flatMap((rule: {resources: string[]}) => + rule.resources.filter(file => file.endsWith(".html")) + ); + const customManifest = { + web_accessible_resources: [{resources, matches: ["http://127.0.0.1/*"]}], + }; + await writeFile( + config, + (await readFile(config, "utf8")).replace( + 'version: "1.0.0",', + `version: "1.0.0", manifest: ${JSON.stringify(customManifest)},` + ) + ); + await fixture.build({browser, manifestVersion}); + const restored = JSON.parse(await readFile(path.join(extension, "manifest.json"), "utf8")); + expect(restored.web_accessible_resources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resources: expect.arrayContaining(resources), + matches: expect.arrayContaining(["http://127.0.0.1/*", "https://unrelated.example/*"]), + }), + ]) + ); + } + } finally { + await session?.close(); + await site.close(); + await fixture.dispose(); + } +}; diff --git a/tests/integration/browser/content/frame-pages.firefox.integration.test.ts b/tests/integration/browser/content/frame-pages.firefox.integration.test.ts new file mode 100644 index 00000000..a98121ef --- /dev/null +++ b/tests/integration/browser/content/frame-pages.firefox.integration.test.ts @@ -0,0 +1,9 @@ +/** @jest-environment node */ +import {runFramePagesIntegration} from "./frame-pages-utils"; +jest.setTimeout(90_000); +test.each([2, 3] as const)( + "Firefox MV%s embeds extension and external pages with production frame nodes", + async version => { + await runFramePagesIntegration("firefox", version); + } +); diff --git a/tests/integration/browser/content/frame-pages.integration.test.ts b/tests/integration/browser/content/frame-pages.integration.test.ts new file mode 100644 index 00000000..b6b5d476 --- /dev/null +++ b/tests/integration/browser/content/frame-pages.integration.test.ts @@ -0,0 +1,6 @@ +/** @jest-environment node */ +import {runFramePagesIntegration} from "./frame-pages-utils"; +jest.setTimeout(90_000); +test("Chrome MV3 embeds an extension page and MAIN external source with production frame nodes", async () => { + await runFramePagesIntegration("chrome", 3); +}); diff --git a/tests/integration/browser/content/frame-pages/adnbn.config.ts b/tests/integration/browser/content/frame-pages/adnbn.config.ts new file mode 100644 index 00000000..37992c87 --- /dev/null +++ b/tests/integration/browser/content/frame-pages/adnbn.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from "adnbn"; +export default defineConfig({ + name: "Frame page integration", + version: "1.0.0", + specific: {gecko: {id: "frame-pages@adnbn.test"}}, + jsFilename: "[name].[contenthash:8].js", +}); diff --git a/tests/integration/browser/content/shadow-styles/package.json b/tests/integration/browser/content/frame-pages/package.json similarity index 76% rename from tests/integration/browser/content/shadow-styles/package.json rename to tests/integration/browser/content/frame-pages/package.json index 044eb690..ceda674b 100644 --- a/tests/integration/browser/content/shadow-styles/package.json +++ b/tests/integration/browser/content/frame-pages/package.json @@ -1,5 +1,5 @@ { - "name": "adnbn-test-shadow-styles", + "name": "adnbn-test-frame-pages", "private": true, "type": "module", "version": "1.0.0", diff --git a/tests/integration/browser/content/frame-pages/site/page.html b/tests/integration/browser/content/frame-pages/site/page.html new file mode 100644 index 00000000..f5db7a9c --- /dev/null +++ b/tests/integration/browser/content/frame-pages/site/page.html @@ -0,0 +1,7 @@ + + + + External + + + diff --git a/tests/integration/browser/content/frame-pages/site/page.js b/tests/integration/browser/content/frame-pages/site/page.js new file mode 100644 index 00000000..06218e48 --- /dev/null +++ b/tests/integration/browser/content/frame-pages/site/page.js @@ -0,0 +1 @@ +parent.postMessage("adnbn-source-ready", "*"); diff --git a/tests/integration/browser/content/frame-pages/site/top.html b/tests/integration/browser/content/frame-pages/site/top.html new file mode 100644 index 00000000..8c511ff7 --- /dev/null +++ b/tests/integration/browser/content/frame-pages/site/top.html @@ -0,0 +1,6 @@ + + + + Host + + diff --git a/tests/integration/browser/content/frame-pages/src/contract.ts b/tests/integration/browser/content/frame-pages/src/contract.ts new file mode 100644 index 00000000..b3f3fa2c --- /dev/null +++ b/tests/integration/browser/content/frame-pages/src/contract.ts @@ -0,0 +1,24 @@ +import {ContentScriptIsolation, type ContentScriptDefinition, getPageUrl, defineRelay} from "adnbn"; +const page: ContentScriptDefinition = {isolation: "iframe", frame: {page: "panel"}}; +const frame: ContentScriptDefinition = {isolation: ContentScriptIsolation.Iframe, frame: {height: 200}, render: "UI"}; +// @ts-expect-error frame requires iframe isolation +const noIsolation: ContentScriptDefinition = {frame: {page: "panel"}}; +// @ts-expect-error embedded pages own their renderer +const render: ContentScriptDefinition = {isolation: "iframe", frame: {page: "panel"}, render: "UI"}; +// @ts-expect-error frame source variants are mutually exclusive +const both: ContentScriptDefinition = {isolation: "iframe", frame: {page: "panel", src: "https://example.com"}}; +// @ts-expect-error generated registry rejects an unknown page inside another public type +const unknown: ContentScriptDefinition = {isolation: "iframe", frame: {page: "missing"}}; +// @ts-expect-error generated registry also narrows the getter +getPageUrl("missing"); +void [page, frame, noIsolation, render, both, unknown]; + +defineRelay({isolation: "iframe", init: () => ({})}); +defineRelay({isolation: "shadow", render: "UI", init: () => ({})}); +defineRelay({isolation: "iframe", frame: {page: "panel"}, init: () => ({})}); +// @ts-expect-error Relay frame requires iframe isolation +defineRelay({frame: {page: "panel"}, init: () => ({})}); +// @ts-expect-error embedded Relay pages own their renderer +defineRelay({isolation: "iframe", frame: {page: "panel"}, render: "UI", init: () => ({})}); +// @ts-expect-error Relay page/src variants are exclusive +defineRelay({isolation: "iframe", frame: {page: "panel", src: "https://example.com"}, init: () => ({})}); diff --git a/tests/integration/browser/content/frame-pages/src/page.content.ts b/tests/integration/browser/content/frame-pages/src/page.content.ts new file mode 100644 index 00000000..29773d9e --- /dev/null +++ b/tests/integration/browser/content/frame-pages/src/page.content.ts @@ -0,0 +1,14 @@ +import {ContentScriptIsolation, defineContentScriptAppend} from "adnbn"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/top.html"], + isolation: ContentScriptIsolation.Iframe, + frame: {page: "panel", height: 250}, + container: {tagName: "section", className: "page-host"}, + main() { + window.addEventListener("message", event => { + const frame = document.querySelector(".page-host iframe"); + if (event.source === frame?.contentWindow && event.data === "adnbn-page-ready") + frame!.setAttribute("data-ready", "true"); + }); + }, +}); diff --git a/tests/integration/browser/content/frame-pages/src/panel.page/index.ts b/tests/integration/browser/content/frame-pages/src/panel.page/index.ts new file mode 100644 index 00000000..c58d9e83 --- /dev/null +++ b/tests/integration/browser/content/frame-pages/src/panel.page/index.ts @@ -0,0 +1,11 @@ +import {definePage} from "adnbn"; +export default definePage({ + name: "panel", + matches: ["http://127.0.0.1/*"], + render() { + const panel = document.createElement("strong"); + panel.textContent = "Extension page loaded"; + parent.postMessage("adnbn-page-ready", "*"); + return panel; + }, +}); diff --git a/tests/integration/browser/content/frame-pages/src/source.content.ts b/tests/integration/browser/content/frame-pages/src/source.content.ts new file mode 100644 index 00000000..8d77bf8d --- /dev/null +++ b/tests/integration/browser/content/frame-pages/src/source.content.ts @@ -0,0 +1,15 @@ +import {ContentScriptIsolation, ContentScriptWorld, defineContentScriptAppend} from "adnbn"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/top.html"], + world: ContentScriptWorld.Main, + isolation: ContentScriptIsolation.Iframe, + frame: {src: "http://127.0.0.1:1/page.html", height: 200}, + container: {tagName: "section", className: "source-host"}, + main() { + window.addEventListener("message", event => { + const frame = document.querySelector(".source-host iframe"); + if (event.source === frame?.contentWindow && event.data === "adnbn-source-ready") + frame!.setAttribute("data-ready", "true"); + }); + }, +}); diff --git a/tests/integration/browser/content/frame-pages/tsconfig.json b/tests/integration/browser/content/frame-pages/tsconfig.json new file mode 100644 index 00000000..d1b4ddb1 --- /dev/null +++ b/tests/integration/browser/content/frame-pages/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "./.adnbn/tsconfig.json" } diff --git a/tests/integration/browser/content/shadow-styles-utils.ts b/tests/integration/browser/content/isolated-styles-utils.ts similarity index 80% rename from tests/integration/browser/content/shadow-styles-utils.ts rename to tests/integration/browser/content/isolated-styles-utils.ts index 547ea270..8f74bad2 100644 --- a/tests/integration/browser/content/shadow-styles-utils.ts +++ b/tests/integration/browser/content/isolated-styles-utils.ts @@ -17,6 +17,8 @@ interface ShadowProbe { readonly font?: string; readonly frame?: string; readonly initialCss?: string; + readonly measured?: boolean; + readonly mounts?: number; readonly instance?: string; readonly kind?: string; readonly links: string[]; @@ -41,9 +43,19 @@ const DocumentStateExpression = `(document => { const view = document.defaultView; const hosts = Array.from(document.querySelectorAll("[data-shadow-probe]")); const probes = hosts.map(host => { - const root = host.shadowRoot; + const root = host.shadowRoot ?? host.querySelector("iframe")?.contentDocument; const result = root && root.querySelector("[data-shadow-result]"); + const style = result && result.ownerDocument.defaultView.getComputedStyle(result); + const primary = host.getAttribute("data-shadow-probe") === "primary"; + const sample = result?.querySelector("span"); + const range = sample && result.ownerDocument.createRange(); + if (range) range.selectNodeContents(sample); + const measured = style && style.color === (primary ? "rgb(17, 85, 153)" : "rgb(119, 51, 34)") && + style.backgroundColor === (primary ? "rgb(34, 102, 68)" : "rgb(85, 51, 119)") && + style.borderTopWidth === "3px" && (!primary || (range && Math.abs(range.getBoundingClientRect().width - 320) < 0.1)); return { + measured: !!measured, + mounts: Number(host.getAttribute("data-mounts")), ...(result ? Object.fromEntries(Object.entries(result.dataset)) : {ready: "missing"}), instance: host.getAttribute("data-instance") || undefined, kind: host.getAttribute("data-shadow-probe") || undefined, @@ -51,7 +63,7 @@ const DocumentStateExpression = `(document => { }; }); const primary = hosts.find(host => host.getAttribute("data-shadow-probe") === "primary"); - const primaryResult = primary && primary.shadowRoot && primary.shadowRoot.querySelector("[data-shadow-result]"); + const primaryResult = (primary?.shadowRoot ?? primary?.querySelector("iframe")?.contentDocument)?.querySelector("[data-shadow-result]"); const outside = document.querySelector("#outside"); if (outside && primaryResult && outside.className !== primaryResult.className) { outside.className = primaryResult.className; @@ -73,7 +85,7 @@ const isReady = (state: ShadowDocumentState | undefined, expected: number): stat return ( state !== undefined && state.probes.length === expected && - state.probes.every(probe => probe.ready === "true" && !probe.error) && + state.probes.every(probe => probe.ready === "true" && probe.measured && !probe.error) && state.normalReady ); }; @@ -100,12 +112,16 @@ const expectDocument = (state: ShadowDocumentState, frame: "top" | "child", prim expect(primary.every(probe => probe.font === "applied")).toBe(true); }; -export const runShadowStylesIntegration = async (name: "chrome" | "firefox", manifestVersion: 2 | 3): Promise => { +export const runIsolatedStylesIntegration = async ( + name: "chrome" | "firefox", + manifestVersion: 2 | 3, + fixtureName = "isolation-shadow" +): Promise => { const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); const binary = name === "chrome" ? findChromeBinary(rootDir) : findFirefoxBinary(); if (!binary || !path.isAbsolute(binary)) throw new Error("Install " + name + " or set its ADNBN_*_BIN path"); - const profile = await mkdtemp(path.join(os.tmpdir(), "adnbn-shadow-integration-")); + const profile = await mkdtemp(path.join(os.tmpdir(), "adnbn-isolation-integration-")); let browserProcess: ChildProcess | undefined; let chrome: CdpClient | undefined; let firefox: BidiClient | undefined; @@ -115,7 +131,7 @@ export const runShadowStylesIntegration = async (name: "chrome" | "firefox", man let lastState: unknown; try { - fixture = await createIntegrationFixture(rootDir, path.join(__dirname, "shadow-styles")); + fixture = await createIntegrationFixture(rootDir, path.join(__dirname, fixtureName)); const extensionDir = await fixture.build({browser: name, manifestVersion}); const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); const scripts = manifest.content_scripts as Array<{css?: string[]; js: string[]}>; @@ -123,15 +139,15 @@ export const runShadowStylesIntegration = async (name: "chrome" | "firefox", man manifestVersion === 2 ? manifest.web_accessible_resources : manifest.web_accessible_resources.flatMap((entry: {resources: string[]}) => entry.resources); - const normal = scripts.find(script => (script.css?.length ?? 0) > 0); - const shadows = scripts.filter(script => (script.css?.length ?? 0) === 0); + const normal = scripts.find(script => script.js.some(file => file.includes("normal.content"))); + const shadows = scripts.filter(script => script !== normal); expect(scripts).toHaveLength(3); expect(normal).toBeDefined(); expect(shadows).toHaveLength(2); expect(scripts.every(script => script.js.every(file => !/background/i.test(file)))).toBe(true); const sharedCss = normal!.css!.find(file => resources.includes(file)); - expect(sharedCss).toMatch(/^css\/shared-styles\.[a-f0-9]{8}\.css$/); + expect(sharedCss).toMatch(/^css\/.+\.[a-f0-9]{8}\.css$/); expect(resources.some(file => /^assets\/probe\.[a-f0-9]{8}\.woff2$/.test(file))).toBe(true); expect(resources.some(file => /^css\/.+\.[a-f0-9]{8}\.css$/.test(file))).toBe(true); @@ -223,6 +239,31 @@ export const runShadowStylesIntegration = async (name: "chrome" | "firefox", man }, 30_000); expectDocument(top, "top", 2); + if (fixtureName === "isolation-iframe") { + let mounts = top.probes.map(probe => probe.mounts!); + for (const operation of ["append", "insert", "reinsert"]) { + await evaluate(`(() => { + const destination = document.createElement("section"); + document.body.append(destination); + for (const host of document.querySelectorAll('[data-shadow-probe]')) { + if (${JSON.stringify(operation)} === 'reinsert') host.remove(); + if (${JSON.stringify(operation)} === 'insert') destination.insertBefore(host, destination.firstChild); + else destination.appendChild(host); + } + })()`); + const recovered = await waitFor(async () => { + const state = await evaluate(`${DocumentStateExpression}(document)`); + lastState = state; + return isReady(state, 3) && state.probes.every(probe => probe.mounts! > Math.max(...mounts)) + ? state + : undefined; + }, 10000); + expectDocument(recovered, "top", 2); + expect(recovered.probes.every(probe => probe.mounts === Math.max(...mounts) + 1)).toBe(true); + mounts = recovered.probes.map(probe => probe.mounts!); + } + } + const oldInstance = Number(top.probes.find(probe => probe.anchor === "first")?.instance); await evaluate(`(() => { document.querySelector('[data-shadow-primary="first"]')?.remove(); @@ -261,7 +302,7 @@ export const runShadowStylesIntegration = async (name: "chrome" | "firefox", man rootDir, ".cache", "integration", - "shadow-styles-" + name + "-mv" + manifestVersion + ".json" + fixtureName + "-" + name + "-mv" + manifestVersion + ".json" ); await writeFile(reportPath, JSON.stringify(report, null, 2) + "\n"); console.info(JSON.stringify(report, null, 2)); diff --git a/tests/integration/browser/content/isolation-iframe.firefox.integration.test.ts b/tests/integration/browser/content/isolation-iframe.firefox.integration.test.ts new file mode 100644 index 00000000..e1daff37 --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe.firefox.integration.test.ts @@ -0,0 +1,9 @@ +/** @jest-environment node */ +import {runIsolatedStylesIntegration} from "./isolated-styles-utils"; +jest.setTimeout(90_000); +test.each([2, 3] as const)( + "Firefox MV%s renders production iframe UI, CSS and fonts under strict CSP", + async version => { + await runIsolatedStylesIntegration("firefox", version, "isolation-iframe"); + } +); diff --git a/tests/integration/browser/content/isolation-iframe.integration.test.ts b/tests/integration/browser/content/isolation-iframe.integration.test.ts new file mode 100644 index 00000000..511581aa --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe.integration.test.ts @@ -0,0 +1,6 @@ +/** @jest-environment node */ +import {runIsolatedStylesIntegration} from "./isolated-styles-utils"; +jest.setTimeout(90_000); +test("Chrome MV3 renders production iframe UI, CSS and fonts under strict CSP", async () => { + await runIsolatedStylesIntegration("chrome", 3, "isolation-iframe"); +}); diff --git a/tests/integration/browser/content/isolation-iframe/adnbn.config.ts b/tests/integration/browser/content/isolation-iframe/adnbn.config.ts new file mode 100644 index 00000000..5011f48e --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/adnbn.config.ts @@ -0,0 +1,27 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Iframe Content Integration", + description: "Exercises production iframe rendering, styles, local fonts and document recovery.", + version: "1.0.0", + specific: {gecko: {id: "isolation-iframe@adnbn.test"}}, + assetsFilename: "[name].[contenthash:8][ext]", + jsFilename: "[name].[chunkhash:8].js", + cssFilename: "[name].[contenthash:8].css", + bundler: { + optimization: { + splitChunks: { + cacheGroups: { + sharedStyles: { + name: "shared-styles", + test: /shared[\\/]styles\.module\.css$/, + type: "css/mini-extract", + chunks: "all", + minChunks: 2, + enforce: true, + }, + }, + }, + }, + }, +}); diff --git a/tests/integration/browser/content/isolation-iframe/package.json b/tests/integration/browser/content/isolation-iframe/package.json new file mode 100644 index 00000000..0bef74b4 --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/package.json @@ -0,0 +1,14 @@ +{ + "name": "adnbn-test-isolation-iframe", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../..", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "scheduler": "*" + } +} diff --git a/tests/integration/browser/content/shadow-styles/site/child.html b/tests/integration/browser/content/isolation-iframe/site/child.html similarity index 100% rename from tests/integration/browser/content/shadow-styles/site/child.html rename to tests/integration/browser/content/isolation-iframe/site/child.html diff --git a/tests/integration/browser/content/shadow-styles/site/frames.html b/tests/integration/browser/content/isolation-iframe/site/frames.html similarity index 100% rename from tests/integration/browser/content/shadow-styles/site/frames.html rename to tests/integration/browser/content/isolation-iframe/site/frames.html diff --git a/tests/integration/browser/content/shadow-styles/site/top.html b/tests/integration/browser/content/isolation-iframe/site/top.html similarity index 100% rename from tests/integration/browser/content/shadow-styles/site/top.html rename to tests/integration/browser/content/isolation-iframe/site/top.html diff --git a/tests/integration/browser/content/shadow-styles/src/normal.content.ts b/tests/integration/browser/content/isolation-iframe/src/normal.content.ts similarity index 84% rename from tests/integration/browser/content/shadow-styles/src/normal.content.ts rename to tests/integration/browser/content/isolation-iframe/src/normal.content.ts index 4c160a63..8843b8de 100644 --- a/tests/integration/browser/content/shadow-styles/src/normal.content.ts +++ b/tests/integration/browser/content/isolation-iframe/src/normal.content.ts @@ -1,6 +1,6 @@ import {defineContentScript} from "adnbn"; -import sharedStyles from "./shared/styles.module.css"; +import sharedStyles from "./shared/styles.module.css?isolation"; export default defineContentScript({ matches: ["http://127.0.0.1/*"], diff --git a/tests/integration/browser/content/isolation-iframe/src/probe.content/Panel.tsx b/tests/integration/browser/content/isolation-iframe/src/probe.content/Panel.tsx new file mode 100644 index 00000000..6fecf938 --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/src/probe.content/Panel.tsx @@ -0,0 +1,63 @@ +import React, {useEffect, useRef} from "react"; +import sharedStyles from "../shared/styles.module.css?isolation"; +import styles from "./styles.module.css?isolation"; + +// Also proves that importing before the first iframe exists remembers its async stylesheet. +const Lazy = import("./lazy"); + +export default function Panel({anchor}: {anchor: Element}) { + const ref = useRef(null); + useEffect(() => { + const root = ref.current!; + let active = true; + root.dataset.ready = "false"; + root.dataset.frame = window === window.top ? "top" : "child"; + root.dataset.anchor = anchor.getAttribute("data-shadow-primary") ?? "unknown"; + void (async () => { + const lazy = await Lazy; + if (!active) return; + lazy.applyLazyStyle(root); + const doc = root.ownerDocument; + const started = performance.now(); + while (active) { + const range = doc.createRange(); + range.selectNodeContents(root.querySelector("span")!); + const style = doc.defaultView!.getComputedStyle(root); + root.dataset.measurement = JSON.stringify({ + color: style.color, + background: style.backgroundColor, + border: style.borderTopWidth, + width: range.getBoundingClientRect().width, + fonts: Array.from(doc.fonts, font => ({family: font.family, status: font.status})), + }); + if ( + style.color === "rgb(17, 85, 153)" && + style.backgroundColor === "rgb(34, 102, 68)" && + style.borderTopWidth === "3px" && + Math.abs(range.getBoundingClientRect().width - 320) < 0.1 + ) { + Object.assign(root.dataset, { + ready: "true", + initialCss: "applied", + asyncCss: "applied", + sharedCss: "applied", + font: "applied", + }); + return; + } + if (performance.now() - started > 10000) throw new Error("Iframe CSS/font did not apply"); + await new Promise(resolve => setTimeout(resolve, 20)); + } + })().catch(error => { + if (active) root.dataset.error = String(error); + }); + return () => { + active = false; + }; + }, [anchor]); + return ( +
+ AAAA +
+ ); +} diff --git a/tests/integration/browser/content/isolation-iframe/src/probe.content/fonts.css b/tests/integration/browser/content/isolation-iframe/src/probe.content/fonts.css new file mode 100644 index 00000000..84b80d94 --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/src/probe.content/fonts.css @@ -0,0 +1,6 @@ +@font-face { + font-family: "AdnbnShadowProbe"; + src: url("./probe.woff2") format("woff2"); + font-weight: 400; + font-style: normal; +} diff --git a/tests/integration/browser/content/isolation-iframe/src/probe.content/index.tsx b/tests/integration/browser/content/isolation-iframe/src/probe.content/index.tsx new file mode 100644 index 00000000..4023fae2 --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/src/probe.content/index.tsx @@ -0,0 +1,29 @@ +import {observeMounts} from "../shared/events"; +import React from "react"; +import Panel from "./Panel"; +import {ContentScriptIsolation, ContentScriptAppend, ContentScriptWorld, defineContentScriptAppend} from "adnbn"; + +import "./fonts.css?isolation&asis"; + +let instance = 0; + +export default defineContentScriptAppend({ + main: observeMounts, + matches: ["http://127.0.0.1/*"], + world: ContentScriptWorld.Isolated, + runAt: "document_end", + allFrames: true, + anchor: "[data-shadow-primary]", + append: ContentScriptAppend.Last, + watch: true, + isolation: ContentScriptIsolation.Iframe, + container: ({anchor}) => { + const host = document.createElement("section"); + host.dataset.shadowProbe = "primary"; + host.dataset.anchor = anchor.getAttribute("data-shadow-primary") ?? "unknown"; + host.dataset.instance = String(++instance); + + return host; + }, + render: ({anchor}) => , +}); diff --git a/tests/integration/browser/content/shadow-styles/src/probe.content/lazy.module.css b/tests/integration/browser/content/isolation-iframe/src/probe.content/lazy.module.css similarity index 100% rename from tests/integration/browser/content/shadow-styles/src/probe.content/lazy.module.css rename to tests/integration/browser/content/isolation-iframe/src/probe.content/lazy.module.css diff --git a/tests/integration/browser/content/shadow-styles/src/probe.content/lazy.ts b/tests/integration/browser/content/isolation-iframe/src/probe.content/lazy.ts similarity index 68% rename from tests/integration/browser/content/shadow-styles/src/probe.content/lazy.ts rename to tests/integration/browser/content/isolation-iframe/src/probe.content/lazy.ts index 469fe45a..b127d013 100644 --- a/tests/integration/browser/content/shadow-styles/src/probe.content/lazy.ts +++ b/tests/integration/browser/content/isolation-iframe/src/probe.content/lazy.ts @@ -1,4 +1,4 @@ -import styles from "./lazy.module.css"; +import styles from "./lazy.module.css?isolation"; export const applyLazyStyle = (element: HTMLElement): void => { element.classList.add(styles.loaded); diff --git a/tests/integration/browser/content/shadow-styles/src/probe.content/probe.woff2 b/tests/integration/browser/content/isolation-iframe/src/probe.content/probe.woff2 similarity index 100% rename from tests/integration/browser/content/shadow-styles/src/probe.content/probe.woff2 rename to tests/integration/browser/content/isolation-iframe/src/probe.content/probe.woff2 diff --git a/tests/integration/browser/content/shadow-styles/src/probe.content/styles.module.css b/tests/integration/browser/content/isolation-iframe/src/probe.content/styles.module.css similarity index 100% rename from tests/integration/browser/content/shadow-styles/src/probe.content/styles.module.css rename to tests/integration/browser/content/isolation-iframe/src/probe.content/styles.module.css diff --git a/tests/integration/browser/content/isolation-iframe/src/secondary.content/index.ts b/tests/integration/browser/content/isolation-iframe/src/secondary.content/index.ts new file mode 100644 index 00000000..b43d48c5 --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/src/secondary.content/index.ts @@ -0,0 +1,63 @@ +import {observeMounts} from "../shared/events"; +import {ContentScriptIsolation, ContentScriptAppend, defineContentScriptAppend} from "adnbn"; + +import sharedStyles from "../shared/styles.module.css?isolation"; +import styles from "./styles.module.css?isolation"; + +export default defineContentScriptAppend({ + main: observeMounts, + matches: ["http://127.0.0.1/*"], + allFrames: true, + anchor: "[data-shadow-secondary]", + append: ContentScriptAppend.Last, + isolation: ContentScriptIsolation.Iframe, + container: () => { + const host = document.createElement("section"); + host.dataset.shadowProbe = "secondary"; + + return host; + }, + render() { + const root = document.createElement("div"); + root.classList.add(styles.root, sharedStyles.shared); + root.dataset.shadowResult = "secondary"; + root.dataset.frame = window === window.top ? "top" : "child"; + root.dataset.ready = "false"; + + void import("./lazy") + .then(module => { + module.applyLazyStyle(root); + + return waitForStyles(root); + }) + .then(() => { + root.dataset.initialCss = "applied"; + root.dataset.asyncCss = "applied"; + root.dataset.sharedCss = "applied"; + }) + .catch(cause => { + root.dataset.error = cause instanceof Error ? cause.message : String(cause); + root.dataset.ready = "error"; + }); + + return root; + }, +}); + +const waitForStyles = async (element: HTMLElement): Promise => { + const started = performance.now(); + + while ( + getComputedStyle(element).color !== "rgb(119, 51, 34)" || + getComputedStyle(element).backgroundColor !== "rgb(85, 51, 119)" || + getComputedStyle(element).borderTopWidth !== "3px" + ) { + if (performance.now() - started > 10_000) { + throw new Error("Secondary shadow styles did not apply within 10 seconds"); + } + + await new Promise(resolve => requestAnimationFrame(() => resolve())); + } + + element.dataset.ready = "true"; +}; diff --git a/tests/integration/browser/content/shadow-styles/src/secondary.content/lazy.module.css b/tests/integration/browser/content/isolation-iframe/src/secondary.content/lazy.module.css similarity index 100% rename from tests/integration/browser/content/shadow-styles/src/secondary.content/lazy.module.css rename to tests/integration/browser/content/isolation-iframe/src/secondary.content/lazy.module.css diff --git a/tests/integration/browser/content/shadow-styles/src/secondary.content/lazy.ts b/tests/integration/browser/content/isolation-iframe/src/secondary.content/lazy.ts similarity index 68% rename from tests/integration/browser/content/shadow-styles/src/secondary.content/lazy.ts rename to tests/integration/browser/content/isolation-iframe/src/secondary.content/lazy.ts index 469fe45a..b127d013 100644 --- a/tests/integration/browser/content/shadow-styles/src/secondary.content/lazy.ts +++ b/tests/integration/browser/content/isolation-iframe/src/secondary.content/lazy.ts @@ -1,4 +1,4 @@ -import styles from "./lazy.module.css"; +import styles from "./lazy.module.css?isolation"; export const applyLazyStyle = (element: HTMLElement): void => { element.classList.add(styles.loaded); diff --git a/tests/integration/browser/content/shadow-styles/src/secondary.content/styles.module.css b/tests/integration/browser/content/isolation-iframe/src/secondary.content/styles.module.css similarity index 100% rename from tests/integration/browser/content/shadow-styles/src/secondary.content/styles.module.css rename to tests/integration/browser/content/isolation-iframe/src/secondary.content/styles.module.css diff --git a/tests/integration/browser/content/isolation-iframe/src/shared/events.ts b/tests/integration/browser/content/isolation-iframe/src/shared/events.ts new file mode 100644 index 00000000..5a1449e7 --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/src/shared/events.ts @@ -0,0 +1,11 @@ +import {ContentScriptEvent, type ContentScriptContext} from "adnbn"; +export const observeMounts = (context: ContentScriptContext): void => { + context.watch((event, node) => { + if (event === ContentScriptEvent.Mount && node.container) { + const count = Number(node.container.getAttribute("data-mounts") ?? 0) + 1; + node.container.setAttribute("data-mounts", String(count)); + // Exercise document recovery before the first stylesheet request can finish. + if (count === 1) node.container.parentElement!.append(node.container); + } + }); +}; diff --git a/tests/integration/browser/content/shadow-styles/src/shared/styles.module.css b/tests/integration/browser/content/isolation-iframe/src/shared/styles.module.css similarity index 100% rename from tests/integration/browser/content/shadow-styles/src/shared/styles.module.css rename to tests/integration/browser/content/isolation-iframe/src/shared/styles.module.css diff --git a/tests/integration/browser/content/isolation-iframe/tsconfig.json b/tests/integration/browser/content/isolation-iframe/tsconfig.json new file mode 100644 index 00000000..0be5b764 --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "./.adnbn/tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx" + } +} diff --git a/tests/integration/browser/content/shadow-styles.firefox.integration.test.ts b/tests/integration/browser/content/isolation-shadow.firefox.integration.test.ts similarity index 61% rename from tests/integration/browser/content/shadow-styles.firefox.integration.test.ts rename to tests/integration/browser/content/isolation-shadow.firefox.integration.test.ts index 4743773f..31d7dcbb 100644 --- a/tests/integration/browser/content/shadow-styles.firefox.integration.test.ts +++ b/tests/integration/browser/content/isolation-shadow.firefox.integration.test.ts @@ -1,12 +1,12 @@ /** @jest-environment node */ -import {runShadowStylesIntegration} from "./shadow-styles-utils"; +import {runIsolatedStylesIntegration} from "./isolated-styles-utils"; jest.setTimeout(90_000); test.each([2, 3] as const)( "Firefox MV%s renders production Shadow DOM styles and local fonts under strict page CSP", async version => { - await runShadowStylesIntegration("firefox", version); + await runIsolatedStylesIntegration("firefox", version); } ); diff --git a/tests/integration/browser/content/shadow-styles.integration.test.ts b/tests/integration/browser/content/isolation-shadow.integration.test.ts similarity index 58% rename from tests/integration/browser/content/shadow-styles.integration.test.ts rename to tests/integration/browser/content/isolation-shadow.integration.test.ts index e6538f43..8d53f1a7 100644 --- a/tests/integration/browser/content/shadow-styles.integration.test.ts +++ b/tests/integration/browser/content/isolation-shadow.integration.test.ts @@ -1,9 +1,9 @@ /** @jest-environment node */ -import {runShadowStylesIntegration} from "./shadow-styles-utils"; +import {runIsolatedStylesIntegration} from "./isolated-styles-utils"; jest.setTimeout(90_000); test("Chrome MV3 renders production Shadow DOM styles and local fonts under strict page CSP", async () => { - await runShadowStylesIntegration("chrome", 3); + await runIsolatedStylesIntegration("chrome", 3); }); diff --git a/tests/integration/browser/content/shadow-styles/adnbn.config.ts b/tests/integration/browser/content/isolation-shadow/adnbn.config.ts similarity index 93% rename from tests/integration/browser/content/shadow-styles/adnbn.config.ts rename to tests/integration/browser/content/isolation-shadow/adnbn.config.ts index d1c883ae..d94639ac 100644 --- a/tests/integration/browser/content/shadow-styles/adnbn.config.ts +++ b/tests/integration/browser/content/isolation-shadow/adnbn.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ name: "Shadow Content Integration", description: "Exercises production Shadow DOM rendering, styles and local fonts.", version: "1.0.0", - specific: {gecko: {id: "shadow-styles@adnbn.test"}}, + specific: {gecko: {id: "isolation-shadow@adnbn.test"}}, assetsFilename: "[name].[contenthash:8][ext]", jsFilename: "[name].[chunkhash:8].js", cssFilename: "[name].[contenthash:8].css", diff --git a/tests/integration/browser/content/shadow-styles/font.py b/tests/integration/browser/content/isolation-shadow/font.py similarity index 100% rename from tests/integration/browser/content/shadow-styles/font.py rename to tests/integration/browser/content/isolation-shadow/font.py diff --git a/tests/integration/browser/content/isolation-shadow/package.json b/tests/integration/browser/content/isolation-shadow/package.json new file mode 100644 index 00000000..d5359099 --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-isolation-shadow", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../.." + } +} diff --git a/tests/integration/browser/content/isolation-shadow/site/child.html b/tests/integration/browser/content/isolation-shadow/site/child.html new file mode 100644 index 00000000..3f10d88d --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/site/child.html @@ -0,0 +1,11 @@ + + + + + Shadow content child frame + + +
+
+ + diff --git a/tests/integration/browser/content/isolation-shadow/site/frames.html b/tests/integration/browser/content/isolation-shadow/site/frames.html new file mode 100644 index 00000000..f3b82a32 --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/site/frames.html @@ -0,0 +1,12 @@ + + + + + Shadow content frames + + +
+
+ + + diff --git a/tests/integration/browser/content/isolation-shadow/site/top.html b/tests/integration/browser/content/isolation-shadow/site/top.html new file mode 100644 index 00000000..f35ebe14 --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/site/top.html @@ -0,0 +1,14 @@ + + + + + Shadow content integration + + +

Shadow content integration

+
+
+
+

Outside page content

+ + diff --git a/tests/integration/browser/content/isolation-shadow/src/normal.content.ts b/tests/integration/browser/content/isolation-shadow/src/normal.content.ts new file mode 100644 index 00000000..8843b8de --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/src/normal.content.ts @@ -0,0 +1,15 @@ +import {defineContentScript} from "adnbn"; + +import sharedStyles from "./shared/styles.module.css?isolation"; + +export default defineContentScript({ + matches: ["http://127.0.0.1/*"], + allFrames: true, + render() { + const element = document.createElement("div"); + element.className = sharedStyles.shared; + element.dataset.normalProbe = "ready"; + + return element; + }, +}); diff --git a/tests/integration/browser/content/isolation-shadow/src/probe.content/fonts.css b/tests/integration/browser/content/isolation-shadow/src/probe.content/fonts.css new file mode 100644 index 00000000..937d9f42 --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/src/probe.content/fonts.css @@ -0,0 +1,6 @@ +@font-face { + font-family: "AdnbnShadowProbe"; + src: url("./probe.woff2?browser") format("woff2"); + font-weight: 400; + font-style: normal; +} diff --git a/tests/integration/browser/content/shadow-styles/src/probe.content/index.ts b/tests/integration/browser/content/isolation-shadow/src/probe.content/index.ts similarity index 86% rename from tests/integration/browser/content/shadow-styles/src/probe.content/index.ts rename to tests/integration/browser/content/isolation-shadow/src/probe.content/index.ts index 95e298f1..72070843 100644 --- a/tests/integration/browser/content/shadow-styles/src/probe.content/index.ts +++ b/tests/integration/browser/content/isolation-shadow/src/probe.content/index.ts @@ -1,8 +1,8 @@ -import {ContentScriptAppend, ContentScriptWorld, defineContentScriptAppend} from "adnbn"; +import {ContentScriptIsolation, ContentScriptAppend, ContentScriptWorld, defineContentScriptAppend} from "adnbn"; -import probeFont from "./probe.woff2"; -import sharedStyles from "../shared/styles.module.css"; -import styles from "./styles.module.css"; +import "./fonts.css?asis"; +import sharedStyles from "../shared/styles.module.css?isolation"; +import styles from "./styles.module.css?isolation"; const AsyncModule = import("./lazy"); const ExpectedWidth = 320; @@ -37,9 +37,7 @@ export default defineContentScriptAppend({ anchor: "[data-shadow-primary]", append: ContentScriptAppend.Last, watch: true, - shadow: { - fonts: [{family: "AdnbnShadowProbe", source: probeFont, weight: "400", style: "normal"}], - }, + isolation: ContentScriptIsolation.Shadow, container: ({anchor}) => { const host = document.createElement("section"); host.dataset.shadowProbe = "primary"; @@ -63,8 +61,6 @@ export default defineContentScriptAppend({ void AsyncModule.then(module => { module.applyLazyStyle(root); - - return document.fonts.load('40px "AdnbnShadowProbe"', "AAAA"); }) .then(() => waitForStyles( diff --git a/tests/integration/browser/content/isolation-shadow/src/probe.content/lazy.module.css b/tests/integration/browser/content/isolation-shadow/src/probe.content/lazy.module.css new file mode 100644 index 00000000..3aa1605c --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/src/probe.content/lazy.module.css @@ -0,0 +1,3 @@ +.loaded { + background-color: rgb(34, 102, 68); +} diff --git a/tests/integration/browser/content/isolation-shadow/src/probe.content/lazy.ts b/tests/integration/browser/content/isolation-shadow/src/probe.content/lazy.ts new file mode 100644 index 00000000..b127d013 --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/src/probe.content/lazy.ts @@ -0,0 +1,5 @@ +import styles from "./lazy.module.css?isolation"; + +export const applyLazyStyle = (element: HTMLElement): void => { + element.classList.add(styles.loaded); +}; diff --git a/tests/integration/browser/content/isolation-shadow/src/probe.content/probe.woff2 b/tests/integration/browser/content/isolation-shadow/src/probe.content/probe.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..00ed2579f6f9b74bed1bd002ac0218cd7d48e854 GIT binary patch literal 404 zcmXT-cQayOWB>xjDGXd7nt2b9?F|tF@lgR=0)q*cfdiu%lL|8%HyZnGhSa}%GW6W;M4d;RV5a?cg9OEY)9*=5@O@vfnV$RTAJq0XKQE3z)e z-(9v+SH#;Or1|dW#Z7Mt1eq7e7o2(GbN9)9^?5(OEa<-@HRVNmV-K%bLK3ISmc~O2 z%U`YBSjz9If3o98OiWg@}gg_LlBDx1B1jTC5IUb4wnoB6=u)o571e|vO;o;dh@QGKXttJU)U?NIOT}x zscG{AHvVhNO8d9xm(14HBL5U+<=-dehrHxGT*A#=B-T>!nW_51sYjb)yI3BcG1B<{ pFrwg)Nm^B(;uMqIZ=zG~FXS$ip38at8HaZF>CP>CuCp>Q000^xr9=P# literal 0 HcmV?d00001 diff --git a/tests/integration/browser/content/isolation-shadow/src/probe.content/styles.module.css b/tests/integration/browser/content/isolation-shadow/src/probe.content/styles.module.css new file mode 100644 index 00000000..e2b9cebf --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/src/probe.content/styles.module.css @@ -0,0 +1,13 @@ +.root { + color: rgb(17, 85, 153); +} + +.font { + display: inline-block; + font-family: "AdnbnShadowProbe", monospace; + font-size: 40px; + font-kerning: none; + font-variant-ligatures: none; + letter-spacing: 0; + white-space: pre; +} diff --git a/tests/integration/browser/content/shadow-styles/src/secondary.content/index.ts b/tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts similarity index 87% rename from tests/integration/browser/content/shadow-styles/src/secondary.content/index.ts rename to tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts index a6164860..fd0197f9 100644 --- a/tests/integration/browser/content/shadow-styles/src/secondary.content/index.ts +++ b/tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts @@ -1,14 +1,14 @@ -import {ContentScriptAppend, defineContentScriptAppend} from "adnbn"; +import {ContentScriptIsolation, ContentScriptAppend, defineContentScriptAppend} from "adnbn"; -import sharedStyles from "../shared/styles.module.css"; -import styles from "./styles.module.css"; +import sharedStyles from "../shared/styles.module.css?isolation"; +import styles from "./styles.module.css?isolation"; export default defineContentScriptAppend({ matches: ["http://127.0.0.1/*"], allFrames: true, anchor: "[data-shadow-secondary]", append: ContentScriptAppend.Last, - shadow: true, + isolation: ContentScriptIsolation.Shadow, container: () => { const host = document.createElement("section"); host.dataset.shadowProbe = "secondary"; diff --git a/tests/integration/browser/content/isolation-shadow/src/secondary.content/lazy.module.css b/tests/integration/browser/content/isolation-shadow/src/secondary.content/lazy.module.css new file mode 100644 index 00000000..807915dc --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/src/secondary.content/lazy.module.css @@ -0,0 +1,3 @@ +.loaded { + background-color: rgb(85, 51, 119); +} diff --git a/tests/integration/browser/content/isolation-shadow/src/secondary.content/lazy.ts b/tests/integration/browser/content/isolation-shadow/src/secondary.content/lazy.ts new file mode 100644 index 00000000..b127d013 --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/src/secondary.content/lazy.ts @@ -0,0 +1,5 @@ +import styles from "./lazy.module.css?isolation"; + +export const applyLazyStyle = (element: HTMLElement): void => { + element.classList.add(styles.loaded); +}; diff --git a/tests/integration/browser/content/isolation-shadow/src/secondary.content/styles.module.css b/tests/integration/browser/content/isolation-shadow/src/secondary.content/styles.module.css new file mode 100644 index 00000000..14457a9f --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/src/secondary.content/styles.module.css @@ -0,0 +1,3 @@ +.root { + color: rgb(119, 51, 34); +} diff --git a/tests/integration/browser/content/isolation-shadow/src/shared/styles.module.css b/tests/integration/browser/content/isolation-shadow/src/shared/styles.module.css new file mode 100644 index 00000000..f67dce95 --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/src/shared/styles.module.css @@ -0,0 +1,3 @@ +.shared { + border-top: 3px solid rgb(68, 51, 170); +} diff --git a/tests/integration/browser/content/shadow-styles/tsconfig.json b/tests/integration/browser/content/isolation-shadow/tsconfig.json similarity index 100% rename from tests/integration/browser/content/shadow-styles/tsconfig.json rename to tests/integration/browser/content/isolation-shadow/tsconfig.json diff --git a/tests/integration/browser/content/isolation-spike-utils.ts b/tests/integration/browser/content/isolation-spike-utils.ts new file mode 100644 index 00000000..557519a5 --- /dev/null +++ b/tests/integration/browser/content/isolation-spike-utils.ts @@ -0,0 +1,160 @@ +import {build} from "esbuild"; +import {cp, mkdir, mkdtemp, rm, writeFile} from "fs/promises"; +import path from "path"; + +import {waitFor} from "../utils/browser"; +import {startBrowserSession} from "../utils/session"; +import {startIntegrationSite} from "../utils/site"; + +const State = `Array.from(document.querySelectorAll('[data-probe]'), host => ({...host.dataset, + links: Array.from(host.querySelector('iframe').contentDocument.querySelectorAll('link'), link => link.href)}))`; + +export const runIsolationSpike = async (browser: "chrome" | "firefox", manifestVersion: 2 | 3) => { + const root = path.resolve(__dirname, "../../../.."); + const fixture = path.join(__dirname, "isolation-spike"); + const cache = path.join(root, ".cache/integration"); + await mkdir(cache, {recursive: true}); + const extension = await mkdtemp(path.join(cache, "isolation-spike-")); + let session: Awaited> | undefined; + const measurements: unknown[] = []; + let state: Array> = []; + let embedState: unknown; + try { + await build({ + entryPoints: [path.join(fixture, "src/probe.tsx")], + outfile: path.join(extension, "probe.js"), + bundle: true, + format: "iife", + platform: "browser", + define: {"process.env.NODE_ENV": '"production"'}, + }); + await build({ + entryPoints: [path.join(fixture, "src/embed.ts")], + outfile: path.join(extension, "embed.js"), + bundle: true, + format: "iife", + platform: "browser", + }); + await cp(path.join(extension, "embed.js"), path.join(extension, "main.js")); + for (const file of ["initial.css", "lazy.css", "panel.html", "panel.js"]) + await cp(path.join(fixture, "src", file), path.join(extension, file)); + await cp( + path.join(__dirname, "isolation-shadow/src/probe.content/probe.woff2"), + path.join(extension, "probe.woff2") + ); + const resources = ["initial.css", "lazy.css", "probe.woff2", "panel.html"]; + await writeFile( + path.join(extension, "manifest.json"), + JSON.stringify({ + manifest_version: manifestVersion, + name: "Iframe feasibility probe", + version: "1.0.0", + ...(browser === "firefox" + ? {browser_specific_settings: {gecko: {id: "isolation-spike@adnbn.test"}}} + : {}), + content_scripts: [ + {matches: ["http://127.0.0.1/top.html"], js: ["probe.js", "embed.js"], run_at: "document_end"}, + ...(manifestVersion === 3 + ? [ + { + matches: ["http://127.0.0.1/top.html"], + js: ["main.js"], + world: "MAIN", + run_at: "document_end", + }, + ] + : []), + ], + web_accessible_resources: + manifestVersion === 2 ? resources : [{resources, matches: ["http://127.0.0.1/*"]}], + }) + ); + session = await startBrowserSession(browser, root, extension); + for (const policy of [ + null, + "default-src 'none'; style-src 'none'; style-src-elem 'none'; font-src 'none'; frame-src 'self'", + ]) { + const site = await startIntegrationSite(path.join(fixture, "site"), policy, { + "allowed.html": {"Content-Security-Policy": "script-src 'self'; frame-ancestors *"}, + "csp-blocked.html": {"Content-Security-Policy": "script-src 'self'; frame-ancestors 'none'"}, + "xfo-blocked.html": {"Content-Security-Policy": "script-src 'self'", "X-Frame-Options": "DENY"}, + }); + try { + await session.navigate(site.origin + "/top.html"); + const ready = async (previous = 0) => + waitFor(async () => { + state = await session!.evaluate(State); + if (state.some(probe => probe.ready === "error")) throw new Error(JSON.stringify(state)); + return state.length === 2 && + state.every(probe => probe.ready === "true" && Number(probe.generation) > previous) + ? state + : undefined; + }, 8000); + // The probe moves both hosts synchronously while initial CSS is still pending. + await ready(1); + const initial = state; + for (const operation of ["append", "insert", "reinsert"]) { + const previous = Math.max(...state.map(probe => Number(probe.generation))); + await session.evaluate(`(() => { + const destination = document.querySelector('#destination'); + for (const host of document.querySelectorAll('[data-probe]')) { + if (${JSON.stringify(operation)} === 'reinsert') host.remove(); + if (${JSON.stringify(operation)} === 'insert') destination.insertBefore(host, destination.firstChild); + else destination.appendChild(host); + } + })()`); + await ready(previous); + } + expect(state.every(probe => probe.links.length === 2)).toBe(true); + expect( + await session.evaluate("getComputedStyle(document.querySelector('body > .probe')).color") + ).not.toBe("rgb(17, 85, 153)"); + const embeds = await waitFor(async () => { + const probes = await session!.evaluate( + "Array.from(document.querySelectorAll('[data-embed-probe]'), element => ({...element.dataset}))" + ); + embedState = probes; + const allowed = probes.filter((probe: Record) => + /-(allowed|page)$/.test(probe.embedProbe) + ); + return allowed.length === (manifestVersion === 3 ? 3 : 2) && + allowed.every((probe: Record) => probe.loaded === "true") + ? probes + : undefined; + }, 5000); + expect( + embeds + .filter((probe: Record) => /blocked$/.test(probe.embedProbe)) + .every((probe: Record) => probe.loaded !== "true") + ).toBe(true); + measurements.push({policy, initial, recovered: state, embeds}); + } finally { + await site.close(); + } + } + expect(session.errors).toEqual([]); + } catch (error) { + throw new Error( + `${String(error)}; last state: ${JSON.stringify(state)}; embed state: ${JSON.stringify(embedState)}; runtime errors: ${JSON.stringify(session?.errors)}`, + {cause: error} + ); + } finally { + await writeFile( + path.join(cache, `isolation-spike-${browser}-mv${manifestVersion}.json`), + JSON.stringify( + { + browser: session?.version, + manifestVersion, + measurements, + lastState: state, + embedState, + errors: session?.errors, + }, + null, + 2 + ) + ); + await session?.close(); + await rm(extension, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } +}; diff --git a/tests/integration/browser/content/isolation-spike.firefox.integration.test.ts b/tests/integration/browser/content/isolation-spike.firefox.integration.test.ts new file mode 100644 index 00000000..a094ddda --- /dev/null +++ b/tests/integration/browser/content/isolation-spike.firefox.integration.test.ts @@ -0,0 +1,11 @@ +/** @jest-environment node */ +import {runIsolationSpike} from "./isolation-spike-utils"; + +jest.setTimeout(90_000); + +test.each([2, 3] as const)( + "Firefox MV%s applies iframe CSS and fonts and recovers through context.mount after host moves", + async version => { + await runIsolationSpike("firefox", version); + } +); diff --git a/tests/integration/browser/content/isolation-spike.integration.test.ts b/tests/integration/browser/content/isolation-spike.integration.test.ts new file mode 100644 index 00000000..76bdc1e4 --- /dev/null +++ b/tests/integration/browser/content/isolation-spike.integration.test.ts @@ -0,0 +1,8 @@ +/** @jest-environment node */ +import {runIsolationSpike} from "./isolation-spike-utils"; + +jest.setTimeout(90_000); + +test("Chrome MV3 applies iframe CSS and fonts and recovers through context.mount after host moves", async () => { + await runIsolationSpike("chrome", 3); +}); diff --git a/tests/integration/browser/content/isolation-spike/site/allowed.html b/tests/integration/browser/content/isolation-spike/site/allowed.html new file mode 100644 index 00000000..c3eadfad --- /dev/null +++ b/tests/integration/browser/content/isolation-spike/site/allowed.html @@ -0,0 +1,7 @@ + + + + External page + + + diff --git a/tests/integration/browser/content/isolation-spike/site/csp-blocked.html b/tests/integration/browser/content/isolation-spike/site/csp-blocked.html new file mode 100644 index 00000000..c3eadfad --- /dev/null +++ b/tests/integration/browser/content/isolation-spike/site/csp-blocked.html @@ -0,0 +1,7 @@ + + + + External page + + + diff --git a/tests/integration/browser/content/isolation-spike/site/page.js b/tests/integration/browser/content/isolation-spike/site/page.js new file mode 100644 index 00000000..a10ad1bc --- /dev/null +++ b/tests/integration/browser/content/isolation-spike/site/page.js @@ -0,0 +1 @@ +parent.postMessage(location.hash.slice(1), "*"); diff --git a/tests/integration/browser/content/isolation-spike/site/top.html b/tests/integration/browser/content/isolation-spike/site/top.html new file mode 100644 index 00000000..cd8ecc9e --- /dev/null +++ b/tests/integration/browser/content/isolation-spike/site/top.html @@ -0,0 +1,8 @@ + + + +
+
+

Outside

+ + diff --git a/tests/integration/browser/content/isolation-spike/site/xfo-blocked.html b/tests/integration/browser/content/isolation-spike/site/xfo-blocked.html new file mode 100644 index 00000000..c3eadfad --- /dev/null +++ b/tests/integration/browser/content/isolation-spike/site/xfo-blocked.html @@ -0,0 +1,7 @@ + + + + External page + + + diff --git a/tests/integration/browser/content/isolation-spike/src/embed.ts b/tests/integration/browser/content/isolation-spike/src/embed.ts new file mode 100644 index 00000000..42c3173a --- /dev/null +++ b/tests/integration/browser/content/isolation-spike/src/embed.ts @@ -0,0 +1,14 @@ +// MAIN has no extension APIs. The isolated version supplies only its own page URL. +const pageUrl = + typeof chrome !== "undefined" && chrome.runtime?.getURL ? chrome.runtime.getURL("panel.html") : undefined; +const world = pageUrl ? "isolated" : "main"; +for (const kind of ["allowed", "csp-blocked", "xfo-blocked", ...(pageUrl ? ["page"] : [])]) { + const frame = document.createElement("iframe"); + frame.dataset.embedProbe = `${world}-${kind}`; + const expected = `${world}-${kind}`; + window.addEventListener("message", event => { + if (event.source === frame.contentWindow && event.data === expected) frame.dataset.loaded = "true"; + }); + frame.src = (kind === "page" ? pageUrl! : location.origin + "/" + kind + ".html") + "#" + expected; + document.body.append(frame); +} diff --git a/tests/integration/browser/content/isolation-spike/src/initial.css b/tests/integration/browser/content/isolation-spike/src/initial.css new file mode 100644 index 00000000..f0faa0e4 --- /dev/null +++ b/tests/integration/browser/content/isolation-spike/src/initial.css @@ -0,0 +1,15 @@ +@font-face { + font-family: "IframeCssProbe"; + src: url("probe.woff2") format("woff2"); +} +.probe { + color: rgb(17, 85, 153); +} +.font { + font-family: "IframeCssProbe", monospace; + font-size: 40px; + font-kerning: none; + font-variant-ligatures: none; + letter-spacing: 0; + white-space: pre; +} diff --git a/tests/integration/browser/content/isolation-spike/src/lazy.css b/tests/integration/browser/content/isolation-spike/src/lazy.css new file mode 100644 index 00000000..9e14ec4e --- /dev/null +++ b/tests/integration/browser/content/isolation-spike/src/lazy.css @@ -0,0 +1,3 @@ +.probe { + background-color: rgb(34, 102, 68); +} diff --git a/tests/integration/browser/content/isolation-spike/src/panel.html b/tests/integration/browser/content/isolation-spike/src/panel.html new file mode 100644 index 00000000..d205ba25 --- /dev/null +++ b/tests/integration/browser/content/isolation-spike/src/panel.html @@ -0,0 +1,7 @@ + + + + Extension page + + + diff --git a/tests/integration/browser/content/isolation-spike/src/panel.js b/tests/integration/browser/content/isolation-spike/src/panel.js new file mode 100644 index 00000000..a10ad1bc --- /dev/null +++ b/tests/integration/browser/content/isolation-spike/src/panel.js @@ -0,0 +1 @@ +parent.postMessage(location.hash.slice(1), "*"); diff --git a/tests/integration/browser/content/isolation-spike/src/probe.tsx b/tests/integration/browser/content/isolation-spike/src/probe.tsx new file mode 100644 index 00000000..a96a148f --- /dev/null +++ b/tests/integration/browser/content/isolation-spike/src/probe.tsx @@ -0,0 +1,162 @@ +import {createElement} from "react"; +import {createRoot, type Root} from "react-dom/client"; + +import Context from "../../../../../../src/entry/content/core/context/ManagedContext"; +import EventEmitter from "../../../../../../src/entry/content/core/context/EventEmitter"; + +// Browser feasibility probe only. No production iframe implementation is imported. +const context = new Context(new EventEmitter()); +const url = (file: string): string => chrome.runtime.getURL(file); +const delay = () => new Promise(resolve => setTimeout(resolve, 20)); + +for (const renderer of ["vanilla", "react"]) { + const anchor = document.createElement("section"); + document.querySelector("#anchors")!.append(anchor); + const host = document.createElement("div"); + host.dataset.probe = renderer; + const frame = document.createElement("iframe"); + host.append(frame); + anchor.append(host); + let target: HTMLElement | undefined; + let root: Root | undefined; + let generation = 0; + let queued = false; + let active = true; + let resources: AbortController | undefined; + + const link = (doc: Document, file: string, signal: AbortSignal, retry = false): Promise => + new Promise((resolve, reject) => { + if (signal.aborted) return resolve(); + let element: HTMLLinkElement; + const finish = (error?: Error) => { + clearTimeout(timeout); + element.onload = element.onerror = null; + signal.removeEventListener("abort", cancel); + if (error || signal.aborted) element.remove(); + if (error) reject(error); + else resolve(); + }; + const cancel = () => finish(); + const timeout = setTimeout(() => finish(new Error("CSS timeout: " + file)), 4000); + signal.addEventListener("abort", cancel, {once: true}); + const start = () => { + element = doc.createElement("link"); + element.rel = "stylesheet"; + element.href = url(file); + element.onload = () => finish(); + element.onerror = () => { + if (retry && !signal.aborted) { + retry = false; + element.onload = element.onerror = null; + element.remove(); + start(); + } else finish(new Error("CSS error: " + element.href)); + }; + doc.head.append(element); + }; + start(); + }); + const recover = () => { + if (queued || !active) return; + queued = true; + queueMicrotask(() => { + queued = false; + if (active && host.isConnected) context.mount(); + }); + }; + frame.addEventListener("load", recover); + context.add({ + anchor, + container: host, + get target() { + return target; + }, + mount() { + const doc = frame.contentDocument; + if (!doc) throw new Error("Empty iframe document is inaccessible"); + if (target?.ownerDocument === doc && target.isConnected) return false; + resources?.abort(); + resources = new AbortController(); + const {signal} = resources; + root?.unmount(); + target = doc.createElement("div"); + doc.body.append(target); + const current = ++generation; + host.dataset.generation = String(current); + host.dataset.ready = "loading"; + delete host.dataset.error; + const initial = link(doc, "initial.css", signal, current > 1); + if (renderer === "react") { + root = createRoot(target); + root.render( + createElement( + "div", + {className: "probe"}, + createElement("span", {className: "font"}, "AAAA") + ) + ); + } else { + const probe = doc.createElement("div"); + probe.className = "probe"; + const text = doc.createElement("span"); + text.className = "font"; + text.textContent = "AAAA"; + probe.append(text); + target.append(probe); + } + void (async () => { + await initial; + if (signal.aborted) return; + // CSS requested after initial render, not eagerly inserted with initial styles. + await link(doc, "lazy.css", signal); + if (signal.aborted) return; + await doc.fonts.load('40px "IframeCssProbe"', "AAAA"); + const started = Date.now(); + while (active && generation === current && Date.now() - started < 4000) { + const probe = target!.querySelector(".probe"); + const widths = Array.from(target!.querySelectorAll(".font"), element => { + const range = doc.createRange(); + range.selectNodeContents(element); + return range.getBoundingClientRect().width; + }); + const style = probe && doc.defaultView!.getComputedStyle(probe); + host.dataset.measurements = JSON.stringify({ + widths, + color: style?.color, + background: style?.backgroundColor, + }); + if ( + style?.color === "rgb(17, 85, 153)" && + style.backgroundColor === "rgb(34, 102, 68)" && + widths.length === 1 && + widths.every(width => Math.abs(width - 320) < 0.1) + ) { + host.dataset.ready = "true"; + return; + } + await delay(); + } + if (generation === current) throw new Error("CSS or real font measurement did not match"); + })().catch(error => { + if (active && generation === current) { + host.dataset.error = String(error); + host.dataset.ready = "error"; + } + }); + return true; + }, + unmount() { + active = false; + generation++; + frame.removeEventListener("load", recover); + resources?.abort(); + root?.unmount(); + host.remove(); + return true; + }, + }); +} +context.mount(); + +// Move in the same task that starts initial CSS, before any resource load callback can run. +for (const node of context.nodes) document.querySelector("#destination")!.append(node.container!); diff --git a/tests/integration/browser/content/relay-styles.integration.test.ts b/tests/integration/browser/content/relay-styles.integration.test.ts new file mode 100644 index 00000000..0a6c02d4 --- /dev/null +++ b/tests/integration/browser/content/relay-styles.integration.test.ts @@ -0,0 +1,44 @@ +/** @jest-environment node */ +import path from "path"; +import {readFile} from "fs/promises"; +import {createIntegrationFixture} from "../../utils/fixture"; +import {startBrowserSession} from "../utils/session"; +import {startIntegrationSite} from "../utils/site"; +import {waitFor} from "../utils/browser"; + +jest.setTimeout(90_000); +test.each([ + ["chrome", 3], + ["firefox", 2], + ["firefox", 3], +] as const)("%s MV%s routes Relay CSS and calls its UI through real RPC", async (browser, manifestVersion) => { + const root = path.resolve(__dirname, "../../../.."); + const fixture = await createIntegrationFixture(root, path.join(__dirname, "relay-styles")); + const site = await startIntegrationSite(path.join(fixture.directory, "site"), null); + let session: Awaited> | undefined; + try { + const extension = await fixture.build({browser, manifestVersion}); + const manifest = JSON.parse(await readFile(path.join(extension, "manifest.json"), "utf8")); + expect(manifest.content_scripts).toHaveLength(3); + session = await startBrowserSession(browser, root, extension); + await session.navigate(site.origin + "/top.html"); + const result = await waitFor(async () => { + const value = await session!.evaluate("document.querySelector('.control-host')?.dataset.result"); + return value ? JSON.parse(value) : undefined; + }).catch(async error => { + throw new Error( + `${String(error)}; state: ${JSON.stringify(await session!.evaluate("({html: document.body.innerHTML, url: location.href})"))}; errors: ${JSON.stringify(session!.errors)}` + ); + }); + expect(result).toEqual({ + shadow: {color: "rgb(17, 85, 153)", background: "rgb(34, 102, 68)", host: "3px", isolated: true}, + iframe: {color: "rgb(17, 85, 153)", background: "rgb(34, 102, 68)", host: "3px", isolated: true}, + page: "rgb(17, 85, 153)", + }); + expect(session.errors).toEqual([]); + } finally { + await session?.close(); + await site.close(); + await fixture.dispose(); + } +}); diff --git a/tests/integration/browser/content/relay-styles/adnbn.config.ts b/tests/integration/browser/content/relay-styles/adnbn.config.ts new file mode 100644 index 00000000..eb03e910 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/adnbn.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "adnbn"; +export default defineConfig({ + name: "Relay CSS routing", + version: "1.0.0", + manifest: {permissions: ["tabs"]}, + specific: {gecko: {id: "relay-styles@adnbn.test"}}, + jsFilename: "[name].[chunkhash:8].js", + cssFilename: "[name].[contenthash:8].css", +}); diff --git a/tests/integration/browser/content/relay-styles/package.json b/tests/integration/browser/content/relay-styles/package.json new file mode 100644 index 00000000..eaf827f0 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-relay-styles", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../.." + } +} diff --git a/tests/integration/browser/content/relay-styles/site/top.html b/tests/integration/browser/content/relay-styles/site/top.html new file mode 100644 index 00000000..477d46f0 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/site/top.html @@ -0,0 +1,6 @@ + + + +
Host
+ + diff --git a/tests/integration/browser/content/relay-styles/src/background.ts b/tests/integration/browser/content/relay-styles/src/background.ts new file mode 100644 index 00000000..2bbed37a --- /dev/null +++ b/tests/integration/browser/content/relay-styles/src/background.ts @@ -0,0 +1,45 @@ +import {defineBackground, getRelay} from "adnbn"; + +export default defineBackground({ + main() { + const started = new Set(); + const run = (tabId: number, url?: string) => { + if (!url?.startsWith("http://127.0.0.1:") || started.has(tabId)) return; + started.add(tabId); + void (async () => { + const shadow = getRelay("shadowPanel", tabId); + const iframe = getRelay("iframePanel", tabId); + const control = getRelay("control", tabId); + const start = Date.now(); + while (true) { + try { + if ((await shadow.ready()) && (await iframe.ready()) && (await control.pageColor())) break; + } catch { + /* The browser can finish navigation before declarative scripts start. */ + } + if (Date.now() - start > 10000) throw new Error("Relay panels did not mount"); + await new Promise(resolve => setTimeout(resolve, 20)); + } + await control.report({ + shadow: await shadow.load(), + iframe: await iframe.load(), + page: await control.pageColor(), + }); + })().catch(async error => { + await getRelay("control", tabId) + .report({error: String(error)}) + .catch(() => {}); + console.error("Relay browser probe failed", error); + }); + }; + chrome.tabs.onUpdated.addListener((tabId, change, tab) => { + if (change.status === "complete") run(tabId, tab.url); + }); + // Loading an unpacked MV3 worker can race the first host navigation. + void chrome.tabs.query({}).then(tabs => + tabs.forEach(tab => { + if (tab.id !== undefined) run(tab.id, tab.url); + }) + ); + }, +}); diff --git a/tests/integration/browser/content/relay-styles/src/control.page/index.ts b/tests/integration/browser/content/relay-styles/src/control.page/index.ts new file mode 100644 index 00000000..bda053c8 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/src/control.page/index.ts @@ -0,0 +1,14 @@ +import {definePage} from "adnbn"; +import {createPanel} from "../shared/panel"; + +export default definePage({ + name: "control", + matches: ["http://127.0.0.1/*"], + render() { + const panel = createPanel("page"); + requestAnimationFrame(() => + parent.postMessage({type: "relay-page-color", color: getComputedStyle(panel).color}, "*") + ); + return panel; + }, +}); diff --git a/tests/integration/browser/content/relay-styles/src/control.relay.ts b/tests/integration/browser/content/relay-styles/src/control.relay.ts new file mode 100644 index 00000000..21174e01 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/src/control.relay.ts @@ -0,0 +1,22 @@ +import {defineRelay} from "adnbn"; +export default defineRelay({ + name: "control", + matches: ["http://127.0.0.1/*"], + isolation: "iframe", + frame: {page: "control"}, + container: {tagName: "section", className: "control-host"}, + init: () => ({ + // An explicit serializable value keeps the readiness probe false until the page reports its CSS. + pageColor: () => document.querySelector(".control-host iframe")?.dataset.color ?? null, + report(result: object) { + document.querySelector(".control-host")!.dataset.result = JSON.stringify(result); + }, + }), + main() { + window.addEventListener("message", event => { + const frame = document.querySelector(".control-host iframe"); + if (event.source === frame?.contentWindow && event.data?.type === "relay-page-color") + frame!.dataset.color = event.data.color; + }); + }, +}); diff --git a/tests/integration/browser/content/relay-styles/src/iframe.relay.ts b/tests/integration/browser/content/relay-styles/src/iframe.relay.ts new file mode 100644 index 00000000..77a92e08 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/src/iframe.relay.ts @@ -0,0 +1,12 @@ +import {defineRelay} from "adnbn"; +import {createPanel, createApi} from "./shared/panel"; +let panel: HTMLElement | undefined; +const api = createApi(() => panel); +export default defineRelay({ + name: "iframePanel", + matches: ["http://127.0.0.1/*"], + isolation: "iframe", + frame: {height: 100}, + init: () => ({ready: () => api.ready(), load: () => api.load()}), + render: () => (panel = createPanel("iframe")), +}); diff --git a/tests/integration/browser/content/relay-styles/src/shadow.relay.ts b/tests/integration/browser/content/relay-styles/src/shadow.relay.ts new file mode 100644 index 00000000..65446a9e --- /dev/null +++ b/tests/integration/browser/content/relay-styles/src/shadow.relay.ts @@ -0,0 +1,11 @@ +import {defineRelay} from "adnbn"; +import {createPanel, createApi} from "./shared/panel"; +let panel: HTMLElement | undefined; +const api = createApi(() => panel); +export default defineRelay({ + name: "shadowPanel", + matches: ["http://127.0.0.1/*"], + isolation: "shadow", + init: () => ({ready: () => api.ready(), load: () => api.load()}), + render: () => (panel = createPanel("shadow")), +}); diff --git a/tests/integration/browser/content/relay-styles/src/shared/host-lazy.css b/tests/integration/browser/content/relay-styles/src/shared/host-lazy.css new file mode 100644 index 00000000..f9d250c7 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/src/shared/host-lazy.css @@ -0,0 +1,3 @@ +#host-probe { + border-top: 3px solid black; +} diff --git a/tests/integration/browser/content/relay-styles/src/shared/host.css b/tests/integration/browser/content/relay-styles/src/shared/host.css new file mode 100644 index 00000000..72c335a4 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/src/shared/host.css @@ -0,0 +1,3 @@ +#host-probe { + color: rgb(100, 10, 20); +} diff --git a/tests/integration/browser/content/relay-styles/src/shared/lazy.module.css b/tests/integration/browser/content/relay-styles/src/shared/lazy.module.css new file mode 100644 index 00000000..cec2fe9c --- /dev/null +++ b/tests/integration/browser/content/relay-styles/src/shared/lazy.module.css @@ -0,0 +1,3 @@ +.loaded { + background: rgb(34, 102, 68); +} diff --git a/tests/integration/browser/content/relay-styles/src/shared/lazy.ts b/tests/integration/browser/content/relay-styles/src/shared/lazy.ts new file mode 100644 index 00000000..b3960dae --- /dev/null +++ b/tests/integration/browser/content/relay-styles/src/shared/lazy.ts @@ -0,0 +1,3 @@ +import "./host-lazy.css?asis"; +import styles from "./lazy.module.css?isolation"; +export const apply = (panel: HTMLElement) => panel.classList.add(styles.loaded); diff --git a/tests/integration/browser/content/relay-styles/src/shared/panel.module.css b/tests/integration/browser/content/relay-styles/src/shared/panel.module.css new file mode 100644 index 00000000..6bfed070 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/src/shared/panel.module.css @@ -0,0 +1,3 @@ +.panel { + color: rgb(17, 85, 153); +} diff --git a/tests/integration/browser/content/relay-styles/src/shared/panel.ts b/tests/integration/browser/content/relay-styles/src/shared/panel.ts new file mode 100644 index 00000000..caf17a33 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/src/shared/panel.ts @@ -0,0 +1,27 @@ +import "./host.css?asis"; +import styles from "./panel.module.css?isolation"; + +export const createPanel = (kind: string) => { + const panel = document.createElement("section"); + panel.dataset.relayPanel = kind; + panel.className = styles.panel; + panel.textContent = kind; + return panel; +}; + +export const createApi = (getPanel: () => HTMLElement | undefined) => ({ + ready: () => !!getPanel()?.isConnected, + async load() { + const panel = getPanel(); + if (!panel) throw new Error("Panel is not mounted"); + const lazy = await import("./lazy"); + lazy.apply(panel); + const view = panel.ownerDocument.defaultView!; + return { + color: view.getComputedStyle(panel).color, + background: view.getComputedStyle(panel).backgroundColor, + host: getComputedStyle(document.querySelector("#host-probe")!).borderTopWidth, + isolated: panel.getRootNode() !== document, + }; + }, +}); diff --git a/tests/integration/browser/content/relay-styles/tsconfig.json b/tests/integration/browser/content/relay-styles/tsconfig.json new file mode 100644 index 00000000..983db6c9 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./.adnbn/tsconfig.json", + "include": [ + "src", + ".adnbn/**/*.d.ts", + "adnbn.config.ts" + ] +} diff --git a/tests/integration/browser/utils/session.ts b/tests/integration/browser/utils/session.ts new file mode 100644 index 00000000..8f7cb286 --- /dev/null +++ b/tests/integration/browser/utils/session.ts @@ -0,0 +1,101 @@ +import {spawn} from "child_process"; +import {mkdtemp, rm} from "fs/promises"; +import os from "os"; +import path from "path"; + +import {getFreePort, stop, waitFor} from "./browser"; +import {browserVersion, CdpClient, findChromeBinary} from "./chrome"; +import BidiClient from "./BidiClient"; +import {findFirefoxBinary} from "./firefox"; + +/** A real extension browser session; owns its process, connection and temporary profile. */ +export const startBrowserSession = async (name: "chrome" | "firefox", rootDir: string, extensionDir: string) => { + const binary = name === "chrome" ? findChromeBinary(rootDir) : findFirefoxBinary(); + if (!binary || !path.isAbsolute(binary)) throw new Error(`Install ${name} or set its ADNBN_*_BIN path`); + const port = await getFreePort(); + const profile = await mkdtemp(path.join(os.tmpdir(), "adnbn-isolation-")); + const args = + name === "chrome" + ? [ + "--headless=new", + "--no-sandbox", + "--no-first-run", + "--no-default-browser-check", + `--remote-debugging-port=${port}`, + `--user-data-dir=${profile}`, + "about:blank", + ] + : [ + "--headless", + "--no-remote", + "--profile", + profile, + "--remote-debugging-port", + String(port), + "about:blank", + ]; + const process = spawn(binary, args, {stdio: ["ignore", "ignore", "pipe"]}); + let output = ""; + process.stderr?.on("data", chunk => { + output += chunk; + }); + let chrome: CdpClient | undefined; + let firefox: BidiClient | undefined; + const close = async () => { + try { + await chrome?.close(); + if (firefox) { + await firefox.send("session.end", {}, 2000).catch(() => undefined); + await firefox.close(); + } + } finally { + await stop(process); + await rm(profile, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } + }; + try { + let version: string; + let navigate: (url: string) => Promise; + let evaluate: (expression: string) => Promise; + if (name === "chrome") { + const {webSocketDebuggerUrl} = await waitFor(() => browserVersion(port)); + chrome = await CdpClient.connect(webSocketDebuggerUrl); + version = (await chrome.send("Browser.getVersion")).product; + await chrome.send("Extensions.loadUnpacked", {path: extensionDir}); + const {targetId} = await chrome.send("Target.createTarget", {url: "about:blank"}); + const {sessionId} = await chrome.send("Target.attachToTarget", {targetId, flatten: true}); + await chrome.send("Runtime.enable", {}, sessionId); + await chrome.send("Page.enable", {}, sessionId); + navigate = url => chrome!.send("Page.navigate", {url}, sessionId); + evaluate = async expression => { + const result = await chrome!.send( + "Runtime.evaluate", + {expression, awaitPromise: true, returnByValue: true}, + sessionId + ); + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + } else { + firefox = await waitFor(() => BidiClient.connect(`ws://127.0.0.1:${port}/session`)); + const session = await firefox.send("session.new", {capabilities: {alwaysMatch: {}}}); + version = "Firefox/" + session.capabilities.browserVersion; + await firefox.send("webExtension.install", {extensionData: {path: extensionDir, type: "path"}}); + const {context} = await firefox.send("browsingContext.create", {type: "tab"}); + navigate = url => firefox!.send("browsingContext.navigate", {context, url, wait: "complete"}); + evaluate = expression => firefox!.evaluate(context, expression); + } + return { + version, + navigate, + evaluate, + close, + get errors() { + return chrome?.runtimeErrors ?? firefox?.runtimeErrors ?? []; + }, + }; + } catch (error) { + await close(); + throw new Error(`${String(error)}; browser output: ${output}`, {cause: error}); + } +}; diff --git a/tests/integration/browser/utils/site.ts b/tests/integration/browser/utils/site.ts index 84830eb0..544724e0 100644 --- a/tests/integration/browser/utils/site.ts +++ b/tests/integration/browser/utils/site.ts @@ -9,7 +9,8 @@ export interface IntegrationSite { export const startIntegrationSite = async ( directory: string, - contentSecurityPolicy: string | null = "default-src 'none'; frame-src 'self'; img-src 'self'" + contentSecurityPolicy: string | null = "default-src 'none'; frame-src 'self'; img-src 'self'", + headers: Readonly>>> = {} ): Promise => { const server = createServer(async (request, response) => { try { @@ -26,8 +27,11 @@ export const startIntegrationSite = async ( const content = await readFile(path.join(directory, filename)); response.writeHead(200, { - "Content-Type": "text/html; charset=utf-8", + "Content-Type": filename.endsWith(".js") + ? "text/javascript; charset=utf-8" + : "text/html; charset=utf-8", ...(contentSecurityPolicy === null ? {} : {"Content-Security-Policy": contentSecurityPolicy}), + ...headers[filename], }); response.end(content); } catch { diff --git a/tests/integration/build/isolation/isolation-watch.integration.test.ts b/tests/integration/build/isolation/isolation-watch.integration.test.ts new file mode 100644 index 00000000..9e55dc7a --- /dev/null +++ b/tests/integration/build/isolation/isolation-watch.integration.test.ts @@ -0,0 +1,146 @@ +/** @jest-environment node */ +import {spawn, type ChildProcess} from "child_process"; +import {copyFile, readFile, rm, writeFile} from "fs/promises"; +import path from "path"; +import {createIntegrationFixture} from "../../utils/fixture"; +import {waitFor, stop} from "../../browser/utils/browser"; + +jest.setTimeout(90_000); + +test("real CLI watch changes isolation and page aliases and removes outdated WAR matches", async () => { + const root = path.resolve(__dirname, "../../../.."); + const fixture = await createIntegrationFixture( + root, + path.join(root, "tests/integration/browser/content/frame-pages") + ); + let watcher: ChildProcess | undefined; + let output = ""; + try { + await rm(path.join(fixture.directory, "src/source.content.ts")); + await copyFile( + path.join(root, "tests/integration/browser/content/isolation-shadow/src/probe.content/probe.woff2"), + path.join(fixture.directory, "src/probe.woff2") + ); + await writeFile(path.join(fixture.directory, "src/watch.css"), ".watch-ui { color: red; }\n"); + await copyFile(path.join(__dirname, "states/fonts.css"), path.join(fixture.directory, "src/watch-fonts.css")); + const entry = path.join(fixture.directory, "src/page.content.ts"); + const setState = async (state: string) => copyFile(path.join(__dirname, "states", state + ".ts"), entry); + await setState("page-isolated"); + await expect(fixture.build({browser: "chrome"})).rejects.toThrow( + "uses ?isolation CSS with frame.page/frame.src, but has no local render target" + ); + await setState("none"); + const directory = await fixture.build({browser: "chrome"}); + await rm(path.join(directory, "manifest.json")); + watcher = spawn(process.execPath, [path.join(root, "bin/adnbn.js"), "watch", ".", "-b", "chrome"], { + cwd: fixture.directory, + stdio: ["ignore", "pipe", "pipe"], + }); + watcher.stdout?.on("data", chunk => { + output += chunk; + }); + watcher.stderr?.on("data", chunk => { + output += chunk; + }); + const observe = (state: string) => + waitFor(async () => { + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const script = manifest.content_scripts.find((script: {js: string[]}) => + script.js.some(file => /page\.content/.test(file)) + ); + if (!script) return undefined; + const source = ( + await Promise.all(script.js.map((file: string) => readFile(path.join(directory, file), "utf8"))) + ).join("\n"); + if (!source.includes(JSON.stringify("isolation-watch-" + state))) return undefined; + return {manifest, script, source}; + }, 15000); + for (const state of [ + "none", + "shadow", + "shadow-document", + "shadow", + "iframe", + "fonts", + "iframe", + "page", + "source", + "none", + ]) { + const beforeBuild = output.length; + if (state !== "none" || output) await setState(state); + const {manifest, script, source} = await observe(state); + if (state === "shadow-document") { + await waitFor( + async () => output.slice(beforeBuild).includes("[adnbn:missing-isolation-css]") || undefined, + 15000 + ); + } + const isolated = ["shadow", "shadow-document", "iframe", "fonts"].includes(state); + expect(!!script.css?.length).toBe(!isolated || state === "shadow-document"); + expect( + /__webpack_require__(?:\.__adnbnIsolatedStyles|\["__adnbnIsolatedStyles"\])\s*=\s*\{/.test(source) + ).toBe(isolated); + expect(script.js.some((file: string) => /background/.test(file))).toBe(false); + const resources = manifest.web_accessible_resources.flatMap( + (rule: {resources: string[]}) => rule.resources + ); + expect(resources.some((file: string) => file.endsWith(".woff2"))).toBe(state === "fonts"); + } + // Start broad, then narrow: the old WAR rule must disappear, not accumulate in the builder. + const page = path.join(fixture.directory, "src/panel.page/index.ts"); + const pageSource = await readFile(page, "utf8"); + await writeFile( + page, + pageSource.replace('name: "panel"', 'name: "renamed-panel"').replace("http://127.0.0.1/*", "http://*/*") + ); + await setState("renamed"); + const renamed = await observe("renamed"); + expect(renamed.source).toContain("renamed-panel"); + const compiled = output; + await writeFile(page, pageSource.replace('name: "panel"', 'name: "renamed-panel"')); + await waitFor(async () => { + if (output === compiled) return undefined; + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const pageRules = manifest.web_accessible_resources.filter((rule: {resources: string[]}) => + rule.resources.some(file => file.endsWith(".html")) + ); + return pageRules.length && + pageRules.every((rule: {matches: string[]}) => !rule.matches.includes("http://*/*")) + ? true + : undefined; + }, 15000); + + // Validation is late, but an invalid build must not replace the last valid manifest or stop watch. + const validManifest = await readFile(path.join(directory, "manifest.json"), "utf8"); + const beforeFailure = output.length; + await writeFile( + page, + pageSource + .replace('name: "panel"', 'name: "renamed-panel"') + .replace("http://127.0.0.1/*", "https://unrelated.example/*") + ); + await waitFor(async () => output.slice(beforeFailure).includes("add matches to the page") || undefined, 15000); + expect(await readFile(path.join(directory, "manifest.json"), "utf8")).toBe(validManifest); + await writeFile( + page, + pageSource + .replace('name: "panel"', 'name: "renamed-panel"') + .replace("http://127.0.0.1/*", "*://127.0.0.1/*") + ); + await waitFor(async () => { + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + return manifest.web_accessible_resources.some( + (rule: {resources: string[]; matches: string[]}) => + rule.resources.some(file => file.endsWith(".html")) && rule.matches.includes("*://127.0.0.1/*") + ) + ? true + : undefined; + }, 15000); + } catch (error) { + throw new Error(`${String(error)}\n${output}`, {cause: error}); + } finally { + if (watcher) await stop(watcher); + await fixture.dispose(); + } +}); diff --git a/tests/integration/build/isolation/states/fonts.css b/tests/integration/build/isolation/states/fonts.css new file mode 100644 index 00000000..52ac0afd --- /dev/null +++ b/tests/integration/build/isolation/states/fonts.css @@ -0,0 +1,4 @@ +@font-face { + font-family: WatchFont; + src: url("./probe.woff2"); +} diff --git a/tests/integration/build/isolation/states/fonts.ts b/tests/integration/build/isolation/states/fonts.ts new file mode 100644 index 00000000..86137d40 --- /dev/null +++ b/tests/integration/build/isolation/states/fonts.ts @@ -0,0 +1,11 @@ +import {defineContentScriptAppend} from "adnbn"; +import "./watch.css?isolation"; +import "./watch-fonts.css?isolation"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + isolation: "iframe", + render: "Watch UI", + main() { + console.info("isolation-watch-fonts"); + }, +}); diff --git a/tests/integration/build/isolation/states/iframe.ts b/tests/integration/build/isolation/states/iframe.ts new file mode 100644 index 00000000..ce5c1d1e --- /dev/null +++ b/tests/integration/build/isolation/states/iframe.ts @@ -0,0 +1,11 @@ +import {defineContentScriptAppend} from "adnbn"; +import "./watch.css?isolation"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + isolation: "iframe", + frame: {height: 220}, + render: "Watch UI", + main() { + console.info("isolation-watch-iframe"); + }, +}); diff --git a/tests/integration/build/isolation/states/none.ts b/tests/integration/build/isolation/states/none.ts new file mode 100644 index 00000000..732855b7 --- /dev/null +++ b/tests/integration/build/isolation/states/none.ts @@ -0,0 +1,9 @@ +import {defineContentScriptAppend} from "adnbn"; +import "./watch.css"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + render: "Watch UI", + main() { + console.info("isolation-watch-none"); + }, +}); diff --git a/tests/integration/build/isolation/states/page-isolated.ts b/tests/integration/build/isolation/states/page-isolated.ts new file mode 100644 index 00000000..d28b4893 --- /dev/null +++ b/tests/integration/build/isolation/states/page-isolated.ts @@ -0,0 +1,8 @@ +import {defineContentScriptAppend} from "adnbn"; +import "./watch.css?isolation"; + +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + isolation: "iframe", + frame: {page: "panel"}, +}); diff --git a/tests/integration/build/isolation/states/page.ts b/tests/integration/build/isolation/states/page.ts new file mode 100644 index 00000000..d0a2249a --- /dev/null +++ b/tests/integration/build/isolation/states/page.ts @@ -0,0 +1,10 @@ +import {defineContentScriptAppend} from "adnbn"; +import "./watch.css"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + isolation: "iframe", + frame: {page: "panel"}, + main() { + console.info("isolation-watch-page"); + }, +}); diff --git a/tests/integration/build/isolation/states/renamed.ts b/tests/integration/build/isolation/states/renamed.ts new file mode 100644 index 00000000..a0f5ad9e --- /dev/null +++ b/tests/integration/build/isolation/states/renamed.ts @@ -0,0 +1,10 @@ +import {defineContentScriptAppend} from "adnbn"; +import "./watch.css"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + isolation: "iframe", + frame: {page: "renamed-panel"}, + main() { + console.info("isolation-watch-renamed"); + }, +}); diff --git a/tests/integration/build/isolation/states/shadow-document.ts b/tests/integration/build/isolation/states/shadow-document.ts new file mode 100644 index 00000000..52b4f3b1 --- /dev/null +++ b/tests/integration/build/isolation/states/shadow-document.ts @@ -0,0 +1,10 @@ +import {defineContentScriptAppend} from "adnbn"; +import "./watch.css"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + isolation: "shadow", + render: "Watch UI", + main() { + console.info("isolation-watch-shadow-document"); + }, +}); diff --git a/tests/integration/build/isolation/states/shadow.ts b/tests/integration/build/isolation/states/shadow.ts new file mode 100644 index 00000000..1660dbc5 --- /dev/null +++ b/tests/integration/build/isolation/states/shadow.ts @@ -0,0 +1,10 @@ +import {defineContentScriptAppend} from "adnbn"; +import "./watch.css?isolation"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + isolation: "shadow", + render: "Watch UI", + main() { + console.info("isolation-watch-shadow"); + }, +}); diff --git a/tests/integration/build/isolation/states/source.ts b/tests/integration/build/isolation/states/source.ts new file mode 100644 index 00000000..19163c92 --- /dev/null +++ b/tests/integration/build/isolation/states/source.ts @@ -0,0 +1,10 @@ +import {defineContentScriptAppend} from "adnbn"; +import "./watch.css"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + isolation: "iframe", + frame: {src: "https://example.com/widget"}, + main() { + console.info("isolation-watch-source"); + }, +}); diff --git a/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts b/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts new file mode 100644 index 00000000..19815f53 --- /dev/null +++ b/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts @@ -0,0 +1,95 @@ +/** @jest-environment node */ +import {spawn, type ChildProcess} from "child_process"; +import {copyFile, readFile, rm} from "fs/promises"; +import path from "path"; +import vm from "vm"; + +import {createIntegrationFixture} from "../../utils/fixture"; +import {stop, waitFor} from "../../browser/utils/browser"; + +jest.setTimeout(90_000); + +test("content plugin delivers Relay options and refreshes names, methods and optional values in CLI watch", async () => { + const root = path.resolve(__dirname, "../../../.."); + // Reuse the browser application: the same production configuration also exercises real Relay RPC. + const fixture = await createIntegrationFixture( + root, + path.join(root, "tests/integration/browser/content/relay-styles") + ); + let watcher: ChildProcess | undefined; + let output = ""; + const expectedMessaging = { + observer: { + name: "observer", + matches: ["http://*/*", "https://*/*"], + runAt: "document_idle", + method: "messaging", + }, + }; + const expectedScripting = { + collector: { + name: "collector", + matches: ["https://example.com/*"], + runAt: "document_idle", + method: "scripting", + declarative: "required", + allFrames: false, + }, + }; + + try { + await rm(path.join(fixture.directory, "src/control.relay.ts")); + await rm(path.join(fixture.directory, "src/iframe.relay.ts")); + await copyFile(path.join(__dirname, "states/background.ts"), path.join(fixture.directory, "src/background.ts")); + const entry = path.join(fixture.directory, "src/shadow.relay.ts"); + const setState = (state: string) => copyFile(path.join(__dirname, "states", `${state}.ts`), entry); + await setState("messaging"); + const directory = await fixture.build({browser: "chrome"}); + const inspect = async () => { + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const background = manifest.background.service_worker; + const source = await readFile(path.join(directory, background), "utf8"); + const sandbox = {readRelayOptions: undefined as undefined | (() => unknown)}; + vm.runInNewContext(source, sandbox); + for (const script of manifest.content_scripts ?? []) expect(script.js).not.toContain(background); + return sandbox.readRelayOptions!(); + }; + + expect(await inspect()).toEqual(expectedMessaging); + await rm(path.join(directory, "manifest.json")); + watcher = spawn(process.execPath, [path.join(root, "bin/adnbn.js"), "watch", ".", "-b", "chrome"], { + cwd: fixture.directory, + stdio: ["ignore", "pipe", "pipe"], + }); + watcher.stdout?.on("data", chunk => (output += chunk)); + watcher.stderr?.on("data", chunk => (output += chunk)); + await waitFor(inspect); + + for (const [state, expected] of [ + ["scripting", expectedScripting], + ["messaging", expectedMessaging], + ] as const) { + await setState(state); + await waitFor(async () => { + const options = await inspect(); + expect(options).toEqual(expected); + return options; + }, 15000); + const declaration = await readFile(path.join(fixture.directory, ".adnbn/relay.d.ts"), "utf8"); + const name = Object.keys(expected)[0]; + expect(declaration).toContain(name); + expect(declaration).not.toContain(state === "scripting" ? "observer" : "collector"); + } + + await stop(watcher); + watcher = undefined; + await rm(entry); + await fixture.build({browser: "chrome"}); + expect(await inspect()).toEqual({}); + } catch (error) { + throw new Error(`${String(error)}\n${output}`, {cause: error}); + } finally { + if (watcher) await stop(watcher); + await fixture.dispose(); + } +}); diff --git a/tests/integration/build/relay-runtime/states/background.ts b/tests/integration/build/relay-runtime/states/background.ts new file mode 100644 index 00000000..cbe9b4c7 --- /dev/null +++ b/tests/integration/build/relay-runtime/states/background.ts @@ -0,0 +1,7 @@ +// Expose only the compiled runtime payload; no browser APIs are needed to inspect it in the build test. +declare const __webpack_require__: {__adnbnRelayOptions?: Record}; + +(globalThis as typeof globalThis & {readRelayOptions: () => unknown}).readRelayOptions = () => + __webpack_require__.__adnbnRelayOptions; + +export default () => {}; diff --git a/tests/integration/build/relay-runtime/states/messaging.ts b/tests/integration/build/relay-runtime/states/messaging.ts new file mode 100644 index 00000000..1b1226f3 --- /dev/null +++ b/tests/integration/build/relay-runtime/states/messaging.ts @@ -0,0 +1,6 @@ +import {defineRelay} from "adnbn"; + +export default defineRelay({ + name: "observer", + init: () => ({ready: () => true}), +}); diff --git a/tests/integration/build/relay-runtime/states/scripting.ts b/tests/integration/build/relay-runtime/states/scripting.ts new file mode 100644 index 00000000..53112bdc --- /dev/null +++ b/tests/integration/build/relay-runtime/states/scripting.ts @@ -0,0 +1,10 @@ +import {defineRelay, RelayMethod, ContentScriptDeclarative} from "adnbn"; + +export default defineRelay({ + name: "collector", + method: RelayMethod.Scripting, + declarative: ContentScriptDeclarative.Required, + matches: ["https://example.com/*"], + allFrames: false, + init: () => ({ready: () => true}), +}); diff --git a/tests/integration/build/style-routing/routing-spike.integration.test.ts b/tests/integration/build/style-routing/routing-spike.integration.test.ts new file mode 100644 index 00000000..31c0b628 --- /dev/null +++ b/tests/integration/build/style-routing/routing-spike.integration.test.ts @@ -0,0 +1,242 @@ +/** @jest-environment node */ + +import fs from "fs"; +import os from "os"; +import path from "path"; +import vm from "vm"; +import {rspack, CssExtractRspackPlugin, type Compiler, type Filename, type Stats} from "@rspack/core"; + +const project = path.resolve(__dirname, "../../../.."); +const fixtures = path.join(__dirname, "src"); +const layer = "adnbn:css:isolation"; +const boundary = "// object to store loaded CSS chunks"; + +// Deliberately independent of production routing: this is the feasibility gate. +const createCompiler = (output: string, filename: Filename, enabled: () => boolean = () => true): Compiler => + rspack({ + context: fixtures, + mode: "production", + devtool: false, + entry: { + content: {import: "./entry.js", layer: "adnbn:content:isolated"}, + relay: {import: "./entry.js", layer: "adnbn:content:isolated"}, + popup: "./entry.js", + }, + output: {path: output, filename, chunkFilename: filename, publicPath: "/", uniqueName: "routingSpike"}, + resolveLoader: {modules: [path.join(project, "node_modules")]}, + module: { + rules: [ + { + test: /\.css$/, + type: "javascript/auto", + oneOf: [ + { + issuerLayer: /^adnbn:content:/, + resourceQuery: /[?&]isolation(?:[=&]|$)/, + layer, + use: [ + CssExtractRspackPlugin.loader, + {loader: "css-loader", ident: "isolation", options: {modules: false}}, + ], + }, + { + use: [ + CssExtractRspackPlugin.loader, + {loader: "css-loader", ident: "document", options: {modules: false}}, + ], + }, + ], + }, + ], + }, + optimization: { + minimize: false, + splitChunks: { + cacheGroups: { + isolated: { + type: "css/mini-extract", + layer, + chunks: "all", + enforce: true, + name: false, + priority: 100, + }, + }, + }, + }, + plugins: [ + new CssExtractRspackPlugin({filename: "css/[name].[contenthash:8].css"}), + { + apply(compiler) { + compiler.hooks.thisCompilation.tap("RoutingSpike", compilation => { + compilation.hooks.runtimeModule.tap("RoutingSpike", (module, chunk) => { + if ( + !enabled() || + chunk.name === "popup" || + module.identifier() !== "webpack/runtime/css loading" + ) + return; + const source = module.source; + if (!source || typeof source.source !== "string") + throw new Error("CSS runtime source unavailable"); + if (!source.source) return; + const ids = Object.fromEntries( + [...compilation.chunks] + .filter(chunk => + [...compilation.chunkGraph.getChunkModulesIterable(chunk)].some( + m => m.type === "css/mini-extract" && m.layer === layer + ) + ) + .map(chunk => [chunk.id, true]) + ); + const position = source.source.indexOf(boundary); + if (position < 0) throw new Error("CSS runtime seam unavailable"); + source.source = + source.source.slice(0, position) + + `var loadPageStylesheet = loadStylesheet; + loadStylesheet = function(id) { + return ${JSON.stringify(ids)}[id] + ? globalThis.loadIsolatedCss(__webpack_require__.p + __webpack_require__.miniCssF(id)) + : loadPageStylesheet(id); + };\n` + + source.source.slice(position); + }); + }); + }, + }, + ], + }); + +const run = (compiler: Compiler): Promise => + new Promise((resolve, reject) => + compiler.run((error, stats) => { + if (error || !stats || stats.hasErrors()) + reject(error ?? new Error(stats?.toString({all: false, errors: true}))); + else resolve(stats); + }) + ); +const close = (compiler: Compiler): Promise => + new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); +const css = (stats: Stats, files: Iterable): string => + [...files] + .filter(file => file.endsWith(".css")) + .map(file => stats.compilation.getAsset(file)!.source.source()) + .join("\n"); + +test.each([ + "[name].[contenthash:8].js", + "[name].[chunkhash:8].js", + "[name].[fullhash:8].js", + () => "custom/[name].[contenthash:8].js", +])("partitions CSS before rendering chunks and leaves popup extraction intact (%s)", async filename => { + const output = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-routing-")); + const compiler = createCompiler(output, filename); + try { + const stats = await run(compiler); + const popup = stats.compilation.entrypoints.get("popup")!; + const content = stats.compilation.entrypoints.get("content")!; + const relay = stats.compilation.entrypoints.get("relay")!; + expect(popup.getFiles().filter(file => file.endsWith(".css"))).toHaveLength(1); + expect(css(stats, popup.getFiles())).toContain(".nested"); + const uiChunks = [...stats.compilation.chunks].filter(chunk => + [...stats.compilation.chunkGraph.getChunkModulesIterable(chunk)].some( + m => m.type === "css/mini-extract" && m.layer === layer + ) + ); + expect(uiChunks).toHaveLength(2); + for (const chunk of uiChunks) { + expect(css(stats, chunk.files)).not.toContain(".page"); + expect(css(stats, chunk.files)).not.toContain(".lazy-page"); + expect([...chunk.files].some(file => file.endsWith(".js"))).toBe(false); + } + expect(content.getFiles()).toHaveLength(3); + expect( + content.getFiles().filter(file => file.endsWith(".css") && relay.getFiles().includes(file)) + ).toHaveLength(1); + expect(content.getFiles().some(file => file.includes("background"))).toBe(false); + + const requested: {target: string; resolve(): void}[] = []; + let context: vm.Context; + const head = { + appendChild(node: any) { + node.parentNode = head; + if (node.tagName === "SCRIPT") { + vm.runInContext(fs.readFileSync(path.join(output, node.src.slice(1)), "utf8"), context); + queueMicrotask(() => node.onload?.({type: "load", target: node})); + } else requested.push({target: "page", resolve: () => node.onload({type: "load", target: node})}); + }, + removeChild() {}, + }; + const sandbox = { + setTimeout, + clearTimeout, + console, + document: { + head, + getElementsByTagName: () => [], + createElement: (tag: string) => ({ + tagName: tag.toUpperCase(), + getAttribute: () => null, + setAttribute() {}, + }), + }, + loadIsolatedCss: () => new Promise(resolve => requested.push({target: "isolation", resolve})), + }; + context = vm.createContext(sandbox); + vm.runInContext("self = globalThis", context); + for (const file of content.getFiles().filter(file => file.endsWith(".js"))) + vm.runInContext(fs.readFileSync(path.join(output, file), "utf8"), context); + const loaded = vm.runInContext("loadPanel()", context) as Promise<{loaded: boolean}>; + let resolved = false; + void loaded.then(() => { + resolved = true; + }); + expect(requested.map(item => item.target).sort()).toEqual(["isolation", "page"]); + requested[0].resolve(); + await new Promise(resolve => setImmediate(resolve)); + expect(resolved).toBe(false); + requested[1].resolve(); + await expect(loaded).resolves.toEqual(expect.objectContaining({loaded: true})); + } finally { + await close(compiler); + fs.rmSync(output, {recursive: true, force: true}); + } +}); + +test("recomputes selective CSS routing on watch rebuilds", async () => { + const output = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-routing-watch-")); + let enabled = true; + const compiler = createCompiler(output, "[name].[contenthash:8].js", () => enabled); + let next: {resolve(stats: Stats): void; reject(error: Error): void}; + const result = () => + new Promise((resolve, reject) => { + next = {resolve, reject}; + }); + let pending = result(); + const watching = compiler.watch({}, (error, stats) => { + if (error || !stats || stats.hasErrors()) + next.reject(error ?? new Error(stats?.toString({all: false, errors: true}))); + else next.resolve(stats); + }); + try { + for (const selection of [true, false, true]) { + if (selection !== enabled) { + enabled = selection; + pending = result(); + watching.invalidate(); + } + const stats = await pending; + const entry = stats.compilation.entrypoints.get("content")!; + const source = entry + .getFiles() + .filter(file => file.endsWith(".js")) + .map(file => stats.compilation.getAsset(file)!.source.source()) + .join("\n"); + expect(source.includes("loadIsolatedCss")).toBe(selection); + } + } finally { + await new Promise(resolve => watching.close(resolve)); + await close(compiler); + fs.rmSync(output, {recursive: true, force: true}); + } +}); diff --git a/tests/integration/build/style-routing/src/assets.js b/tests/integration/build/style-routing/src/assets.js new file mode 100644 index 00000000..733fc984 --- /dev/null +++ b/tests/integration/build/style-routing/src/assets.js @@ -0,0 +1,7 @@ +import "./resources/styles.css?asis"; +import documentFile from "./resources/document.custom?browser"; +import inline from "./resources/inline.woff2?base64"; +import font from "./resources/direct.woff2"; +import data from "./resources/data.json"; +import {value} from "./resources/code.js?browser"; +globalThis.resources = {documentFile, inline, font, data, value}; diff --git a/tests/integration/build/style-routing/src/background.js b/tests/integration/build/style-routing/src/background.js new file mode 100644 index 00000000..980b3ed3 --- /dev/null +++ b/tests/integration/build/style-routing/src/background.js @@ -0,0 +1 @@ +globalThis.assets = __webpack_require__.__adnbnBuildAssets; diff --git a/tests/integration/build/style-routing/src/entry.js b/tests/integration/build/style-routing/src/entry.js new file mode 100644 index 00000000..3f3fefcb --- /dev/null +++ b/tests/integration/build/style-routing/src/entry.js @@ -0,0 +1,3 @@ +import "./shared.js"; + +globalThis.loadPanel = () => import("./lazy.js"); diff --git a/tests/integration/build/style-routing/src/lazy-page.css b/tests/integration/build/style-routing/src/lazy-page.css new file mode 100644 index 00000000..b0ac8e58 --- /dev/null +++ b/tests/integration/build/style-routing/src/lazy-page.css @@ -0,0 +1,3 @@ +.lazy-page { + color: red; +} diff --git a/tests/integration/build/style-routing/src/lazy-panel.css b/tests/integration/build/style-routing/src/lazy-panel.css new file mode 100644 index 00000000..6a64ad00 --- /dev/null +++ b/tests/integration/build/style-routing/src/lazy-panel.css @@ -0,0 +1,3 @@ +.lazy-panel { + color: blue; +} diff --git a/tests/integration/build/style-routing/src/lazy.js b/tests/integration/build/style-routing/src/lazy.js new file mode 100644 index 00000000..66425407 --- /dev/null +++ b/tests/integration/build/style-routing/src/lazy.js @@ -0,0 +1,4 @@ +import "./lazy-page.css"; +import "./lazy-panel.css?isolation"; + +export const loaded = true; diff --git a/tests/integration/build/style-routing/src/nested.css b/tests/integration/build/style-routing/src/nested.css new file mode 100644 index 00000000..0fc0bcef --- /dev/null +++ b/tests/integration/build/style-routing/src/nested.css @@ -0,0 +1,3 @@ +.nested { + color: purple; +} diff --git a/tests/integration/build/style-routing/src/other-assets.js b/tests/integration/build/style-routing/src/other-assets.js new file mode 100644 index 00000000..8b52f7c4 --- /dev/null +++ b/tests/integration/build/style-routing/src/other-assets.js @@ -0,0 +1,2 @@ +import font from "./resources/inline.woff2"; +globalThis.otherFont = font; diff --git a/tests/integration/build/style-routing/src/package.json b/tests/integration/build/style-routing/src/package.json new file mode 100644 index 00000000..43288d05 --- /dev/null +++ b/tests/integration/build/style-routing/src/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "sideEffects": true, + "type": "module" +} diff --git a/tests/integration/build/style-routing/src/page.css b/tests/integration/build/style-routing/src/page.css new file mode 100644 index 00000000..5add4958 --- /dev/null +++ b/tests/integration/build/style-routing/src/page.css @@ -0,0 +1,3 @@ +.page { + color: red; +} diff --git a/tests/integration/build/style-routing/src/panel.css b/tests/integration/build/style-routing/src/panel.css new file mode 100644 index 00000000..cf3dba23 --- /dev/null +++ b/tests/integration/build/style-routing/src/panel.css @@ -0,0 +1,4 @@ +@import "./nested.css"; +.panel { + color: blue; +} diff --git a/tests/integration/build/style-routing/src/resources/code.js b/tests/integration/build/style-routing/src/resources/code.js new file mode 100644 index 00000000..89d4c8f5 --- /dev/null +++ b/tests/integration/build/style-routing/src/resources/code.js @@ -0,0 +1 @@ +export const value = "still JavaScript"; diff --git a/tests/integration/build/style-routing/src/resources/css-font.woff2 b/tests/integration/build/style-routing/src/resources/css-font.woff2 new file mode 100644 index 00000000..4581f498 --- /dev/null +++ b/tests/integration/build/style-routing/src/resources/css-font.woff2 @@ -0,0 +1 @@ +CSS font build fixture diff --git a/tests/integration/build/style-routing/src/resources/data.json b/tests/integration/build/style-routing/src/resources/data.json new file mode 100644 index 00000000..36591a19 --- /dev/null +++ b/tests/integration/build/style-routing/src/resources/data.json @@ -0,0 +1 @@ +{ "loaded": true } diff --git a/tests/integration/build/style-routing/src/resources/direct.woff2 b/tests/integration/build/style-routing/src/resources/direct.woff2 new file mode 100644 index 00000000..aa701128 --- /dev/null +++ b/tests/integration/build/style-routing/src/resources/direct.woff2 @@ -0,0 +1 @@ +Direct font build fixture diff --git a/tests/integration/build/style-routing/src/resources/document.custom b/tests/integration/build/style-routing/src/resources/document.custom new file mode 100644 index 00000000..b54053d3 --- /dev/null +++ b/tests/integration/build/style-routing/src/resources/document.custom @@ -0,0 +1 @@ +Explicit custom binary asset build fixture diff --git a/tests/integration/build/style-routing/src/resources/image.svg b/tests/integration/build/style-routing/src/resources/image.svg new file mode 100644 index 00000000..81967ecc --- /dev/null +++ b/tests/integration/build/style-routing/src/resources/image.svg @@ -0,0 +1 @@ + diff --git a/tests/integration/build/style-routing/src/resources/inline.woff2 b/tests/integration/build/style-routing/src/resources/inline.woff2 new file mode 100644 index 00000000..7d7eed38 --- /dev/null +++ b/tests/integration/build/style-routing/src/resources/inline.woff2 @@ -0,0 +1 @@ +Inline font build fixture diff --git a/tests/integration/build/style-routing/src/resources/movie.mp4 b/tests/integration/build/style-routing/src/resources/movie.mp4 new file mode 100644 index 00000000..0deb8d8d --- /dev/null +++ b/tests/integration/build/style-routing/src/resources/movie.mp4 @@ -0,0 +1 @@ +Media build fixture diff --git a/tests/integration/build/style-routing/src/resources/styles.css b/tests/integration/build/style-routing/src/resources/styles.css new file mode 100644 index 00000000..68c55950 --- /dev/null +++ b/tests/integration/build/style-routing/src/resources/styles.css @@ -0,0 +1,13 @@ +@font-face { + font-family: ResourceProbe; + src: url("./css-font.woff2?browser"); +} +.image { + background-image: url("./image.svg?chrome#icon"); +} +.media { + --movie: url("./movie.mp4?browser"); +} +.inline { + --font: url("./inline.woff2?base64"); +} diff --git a/tests/integration/build/style-routing/src/shared.js b/tests/integration/build/style-routing/src/shared.js new file mode 100644 index 00000000..d0433ea0 --- /dev/null +++ b/tests/integration/build/style-routing/src/shared.js @@ -0,0 +1,2 @@ +import "./page.css"; +import "./panel.css?isolation"; diff --git a/tests/integration/build/style-routing/style-routing.integration.test.ts b/tests/integration/build/style-routing/style-routing.integration.test.ts new file mode 100644 index 00000000..fab9df5e --- /dev/null +++ b/tests/integration/build/style-routing/style-routing.integration.test.ts @@ -0,0 +1,207 @@ +/** @jest-environment node */ +import fs from "fs"; +import os from "os"; +import path from "path"; +import {rspack, type Configuration, type RuleSetCondition, type Stats} from "@rspack/core"; +import stylePlugin from "@cli/plugins/style"; +import assetPlugin from "@cli/plugins/asset"; +import optimizationPlugin from "@cli/plugins/optimization"; +import {merge} from "webpack-merge"; +import IsolatedStylesPlugin from "@cli/bundler/plugins/isolated-styles"; +import BuildAssetsMapPlugin from "@cli/bundler/plugins/build-assets-map"; +import {getCompilationBuildAssets} from "@cli/bundler/utils/output"; +import {getContentLayer, isContentLayer} from "@cli/bundler/utils/layers"; +import ManifestPlugin from "@cli/bundler/plugins/manifest"; +import ManifestV3 from "@cli/builders/manifest/ManifestV3"; +import type {ReadonlyConfig} from "@typing/config"; +import {Browser} from "@typing/browser"; +import {ContentScriptStylesRuntimeProperty, ContentScriptWorld} from "@typing/content"; + +test.each<{ + browser: Browser; + commonChunks: boolean; + routing: string; + layer: string; + isolationIssuerLayer?: RuleSetCondition; +}>([ + ...[ + {browser: Browser.Chrome, commonChunks: true}, + {browser: Browser.Chrome, commonChunks: false}, + {browser: Browser.Chromium, commonChunks: true}, + {browser: Browser.Edge, commonChunks: true}, + {browser: Browser.Firefox, commonChunks: true}, + {browser: Browser.Opera, commonChunks: true}, + {browser: Browser.Safari, commonChunks: true}, + ].map(options => ({ + ...options, + routing: "content", + layer: getContentLayer(ContentScriptWorld.Isolated), + isolationIssuerLayer: isContentLayer, + })), + { + browser: Browser.Chrome, + commonChunks: true, + routing: "main", + layer: getContentLayer(ContentScriptWorld.Main), + isolationIssuerLayer: isContentLayer, + }, + { + browser: Browser.Chrome, + commonChunks: true, + routing: "disabled", + layer: getContentLayer(ContentScriptWorld.Isolated), + }, + { + browser: Browser.Chrome, + commonChunks: true, + routing: "custom", + layer: "fixture:ui", + isolationIssuerLayer: /^fixture:ui$/, + }, +])( + "production CSS routing preserves entry assets and CSS/WAR ($browser, commonChunks=$commonChunks, routing=$routing)", + async ({browser, commonChunks, layer, isolationIssuerLayer}) => { + const root = path.resolve(__dirname, "../../../.."); + const output = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-css-routing-")); + const config = { + rootDir: path.join(__dirname, "src"), + app: "test", + browser, + commonChunks, + mergeStyles: false, + cssDir: "css", + cssFilename: "[name].[contenthash:8].css", + cssIdentName: "[local]", + assetsDir: "assets", + assetsFilename: "[name].[contenthash:8][ext]", + } as ReadonlyConfig; + const isolatedStyles = isolationIssuerLayer !== undefined; + const styleHandler = stylePlugin({isolationIssuerLayer}).bundler!; + const assetHandler = assetPlugin().bundler!; + const styles = ( + typeof styleHandler === "function" ? await styleHandler({config, rspack: {}}) : styleHandler + ) as Configuration; + const assets = ( + typeof assetHandler === "function" ? await assetHandler({config, rspack: {}}) : assetHandler + ) as Configuration; + const optimizationHandler = optimizationPlugin().bundler!; + const optimization = ( + typeof optimizationHandler === "function" + ? await optimizationHandler({config, rspack: {}}) + : optimizationHandler + ) as Configuration; + const compiler = rspack({ + context: path.join(__dirname, "src"), + mode: "production", + devtool: false, + entry: { + background: "./background.js", + popup: "./entry.js", + assets: "./assets.js", + other: "./other-assets.js", + content: {import: "./entry.js", layer}, + relay: {import: "./entry.js", layer}, + }, + output: {...assets.output, path: output, filename: "js/[name].[chunkhash:8].js", publicPath: ""}, + resolveLoader: {modules: [path.join(root, "node_modules")]}, + module: {rules: [...styles.module!.rules!, ...assets.module!.rules!]}, + optimization: { + ...merge(optimization, styles).optimization, + minimize: false, + ...(commonChunks + ? {splitChunks: {...(merge(optimization, styles).optimization!.splitChunks as object), minSize: 0}} + : {}), + }, + plugins: [ + ...styles.plugins!, + ...assets.plugins!, + new IsolatedStylesPlugin({ + cssFilename: "css/[name].[contenthash:8].css", + cssChunkFilename: "css/[name].[contenthash:8].css", + property: ContentScriptStylesRuntimeProperty, + test: entry => entry === "content", + }), + new BuildAssetsMapPlugin({ + fullMapEntrypoint: "background", + cssFilename: "css/[name].[contenthash:8].css", + cssChunkFilename: "css/[name].[contenthash:8].css", + }), + new ManifestPlugin( + new ManifestV3(browser).setContentScripts( + new Set([ + {entry: "content", matches: ["https://example.com/*"]}, + {entry: "relay", matches: ["https://example.com/*"]}, + ]) + ) + ), + ], + }); + try { + const stats = await new Promise((resolve, reject) => + compiler.run((error, stats) => { + if (error || !stats || stats.hasErrors()) + reject(error ?? new Error(stats?.toString({all: false, errors: true}))); + else resolve(stats); + }) + ); + const map = getCompilationBuildAssets(stats.compilation)!; + expect(map.assets.assets).toEqual( + expect.arrayContaining([ + expect.stringMatching(/assets\/css-font\.[a-f0-9]+\.woff2$/), + expect.stringMatching(/assets\/direct\.[a-f0-9]+\.woff2$/), + expect.stringMatching(/assets\/image\.[a-f0-9]+\.svg$/), + expect.stringMatching(/assets\/movie\.[a-f0-9]+\.mp4$/), + expect.stringMatching(/assets\/document\.[a-f0-9]+\.custom$/), + ]) + ); + expect(map.assets.assets.some(file => file.includes("inline") || /\.(js|json)$/.test(file))).toBe(false); + expect(map.other.assets.some(file => file.includes("inline"))).toBe(true); + const css = map.assets.initial.css + .map(file => stats.compilation.getAsset(file)!.source.source()) + .join("\n"); + const scheme = browser === Browser.Firefox ? "moz-extension" : "chrome-extension"; + for (const name of ["css-font", "image", "movie"]) { + const file = map.assets.assets.find(file => file.startsWith(`assets/${name}.`))!; + // Both ?browser and ?chrome opt into an extension URL for the build target. + expect(css).toContain(`${scheme}://__MSG_@@extension_id__/${file}`); + } + expect(css).toContain("data:"); + for (const entry of [map.content, map.relay]) { + expect(entry.initial.js.some(file => file.includes("common"))).toBe(commonChunks); + expect(Object.keys(entry.initial).sort()).toEqual(["css", "js"]); + expect(Object.keys(entry.async).sort()).toEqual(["css", "js"]); + expect(entry.initial.css).toHaveLength(isolatedStyles ? 2 : 1); + expect(entry.async.css).toHaveLength(isolatedStyles ? 2 : 1); + expect(entry.initial.js).not.toEqual(expect.arrayContaining(map.background.initial.js)); + } + + // The same ?isolation import stays ordinary CSS outside the selected issuer layers. + expect(map.popup.initial.css).toHaveLength(1); + expect(map.popup.async.css).toHaveLength(1); + const popupCss = stats.compilation.getAsset(map.popup.initial.css[0])!.source.source().toString(); + expect(popupCss).toMatch(/color:\s*red/); + expect(popupCss).toMatch(/color:\s*blue/); + const manifest: chrome.runtime.ManifestV3 = JSON.parse( + stats.compilation.getAsset("manifest.json")!.source.source().toString() + ); + const panel = map.content.initial.css.filter(file => + /color:\s*blue/.test(stats.compilation.getAsset(file)!.source.source().toString()) + ); + expect(panel).toHaveLength(1); + const isolated = isolatedStyles ? panel : []; + if (isolatedStyles) { + expect(stats.compilation.getAsset(panel[0])!.source.source().toString()).not.toMatch(/color:\s*red/); + } + expect(manifest.content_scripts?.[0].css).toEqual( + map.content.initial.css.filter(file => !isolated.includes(file)) + ); + expect(manifest.content_scripts?.[1].css).toEqual(map.relay.initial.css); + const war = manifest.web_accessible_resources?.flatMap(rule => rule.resources) ?? []; + expect(war).toEqual(expect.arrayContaining(isolated)); + expect(war).toEqual(expect.arrayContaining(map.content.async.css)); + } finally { + await new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); + fs.rmSync(output, {recursive: true, force: true}); + } + } +); diff --git a/tsconfig.json b/tsconfig.json index 164b8cf4..2359aa26 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,9 @@ "baseUrl": "./src", "paths": { "adnbn": ["index.ts"], + "adnbn/entry/content/frame": [ + "entry/content/frame/index.ts" + ], "adnbn/entry/content/*": [ "entry/content/adapters/*/index.ts" ], @@ -67,6 +70,7 @@ "@relay/*": ["relay/*"], "@sandbox/*": ["sandbox/*"], "@service/*": ["service/*"], + "@shared/*": ["shared/*"], "@storage/*": ["storage/*"], "@transport/*": ["transport/*"], "@typing/*": ["types/*"] diff --git a/tsup.config.ts b/tsup.config.ts index 570c5ffc..2dc45cd6 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -70,7 +70,7 @@ const virtualEntrypointImportPlugin = (): Plugin => ({ const runtimeTemplateEntries = [ "src/cli/bundler/plugins/runtime-data/templates.ts", "src/cli/bundler/plugins/chunk-loader/templates.ts", - "src/cli/bundler/plugins/shadow-styles/templates.ts", + "src/cli/bundler/plugins/isolated-styles/templates.ts", ]; export default defineConfig([ diff --git a/types/client.d.ts b/types/client.d.ts index 708d6386..2a73bb04 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -309,3 +309,18 @@ declare module "*?asis" { const src: string; export default src; } + +declare module "*?isolation" { + const classes: Readonly>; + export default classes; +} + +declare module "*?isolation&asis" { + const src: string; + export default src; +} + +declare module "*?asis&isolation" { + const src: string; + export default src; +} From d8d82026514d971ad2f070756a1d9e48851202ec Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:28:52 +0300 Subject: [PATCH 16/38] test: remove obsolete assertions and normalize fixture names Remove guards for discarded owner runtime implementations and replace Plan A fixture filenames and suite labels with neutral entrypoint names. Keep assertions for current placeholders and background independence. --- .../build-assets-map/BuildAssetsMapPlugin.test.ts | 15 ++++++--------- .../fixtures/{alpha.plan-a.js => alpha.entry.js} | 0 ...-async.plan-a.js => background-async.entry.js} | 0 ...hared.plan-a.js => background-shared.entry.js} | 0 .../{background.plan-a.js => background.entry.js} | 0 .../fixtures/{beta.plan-a.js => beta.entry.js} | 0 6 files changed, 6 insertions(+), 9 deletions(-) rename src/cli/bundler/plugins/build-assets-map/tests/fixtures/{alpha.plan-a.js => alpha.entry.js} (100%) rename src/cli/bundler/plugins/build-assets-map/tests/fixtures/{background-async.plan-a.js => background-async.entry.js} (100%) rename src/cli/bundler/plugins/build-assets-map/tests/fixtures/{background-shared.plan-a.js => background-shared.entry.js} (100%) rename src/cli/bundler/plugins/build-assets-map/tests/fixtures/{background.plan-a.js => background.entry.js} (100%) rename src/cli/bundler/plugins/build-assets-map/tests/fixtures/{beta.plan-a.js => beta.entry.js} (100%) diff --git a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts index bfc7921a..2036d924 100644 --- a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts +++ b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts @@ -265,10 +265,10 @@ const compile = async ( const assetFilename = appFilenameResolver(AppName, () => "[name].[app].[contenthash:8][ext]", "custom/assets"); const background = options.background === "async" - ? "./background-async.plan-a.js" + ? "./background-async.entry.js" : options.background === "shared" - ? "./background-shared.plan-a.js" - : "./background.plan-a.js"; + ? "./background-shared.entry.js" + : "./background.entry.js"; const compiler = rspack({ context: fixtures, mode: "production", @@ -276,8 +276,8 @@ const compile = async ( devtool: "source-map", entry: { ...(options.includeBackground === false ? {} : {background}), - alpha: "./alpha.plan-a.js", - beta: "./beta.plan-a.js", + alpha: "./alpha.entry.js", + beta: "./beta.entry.js", }, output: { path: outputPath, @@ -642,8 +642,6 @@ const expectCompleteMap = (build: BuildResult, commonChunks: boolean): void => { .map(([, source]) => source); expect(emittedCode.every(source => !source.includes(FullMapPlaceholder))).toBe(true); - expect(emittedCode.every(source => !source.includes("scheduleEntrypoint"))).toBe(true); - expect(emittedCode.every(source => !source.includes("__adnbnScheduleEntrypoint"))).toBe(true); }; const expectNoChangedBytesUnderStableNames = (before: BuildResult, after: BuildResult): void => { @@ -656,7 +654,7 @@ const expectNoChangedBytesUnderStableNames = (before: BuildResult, after: BuildR jest.setTimeout(120_000); -describe("BuildAssetsMapPlugin Plan A", () => { +describe("BuildAssetsMapPlugin", () => { test.each(["chunkhash", "contenthash", "fullhash"] as const)( "exposes isolated runtime maps with %s filenames", async jsHash => { @@ -712,7 +710,6 @@ describe("BuildAssetsMapPlugin Plan A", () => { const background = findNamedFile(build.assets.background.initial.js, "background", "js"); expect(scripts.map(normalizeUrl)).not.toContain(background); - expect(scripts.every(script => !script.includes("__adnbn_build_assets__"))).toBe(true); }); test("rejects async chunks in the background entrypoint", async () => { diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.plan-a.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.entry.js similarity index 100% rename from src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.plan-a.js rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.entry.js diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-async.plan-a.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-async.entry.js similarity index 100% rename from src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-async.plan-a.js rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-async.entry.js diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-shared.plan-a.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-shared.entry.js similarity index 100% rename from src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-shared.plan-a.js rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-shared.entry.js diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background.plan-a.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background.entry.js similarity index 100% rename from src/cli/bundler/plugins/build-assets-map/tests/fixtures/background.plan-a.js rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/background.entry.js diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.plan-a.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.entry.js similarity index 100% rename from src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.plan-a.js rename to src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.entry.js From f4fe39e69ce7e1ab5edd7c17ea317e5cebdc49ad Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:29:19 +0300 Subject: [PATCH 17/38] docs: document isolation behavior and architecture conventions Document isolation modes, CSS routing, font delivery, iframe recovery and browser validation fixtures. Record responsibility boundaries, shared contract ownership and source import conventions in AGENTS.md. Leave CHANGELOG.md generation to the publication workflow. --- AGENTS.md | 60 ++++- src/entry/content/README.md | 247 ++++++++++++++---- src/relay/README.md | 4 +- tests/integration/README.md | 16 +- .../browser/content/css-fonts/README.md | 81 ++++++ .../browser/content/frame-pages/README.md | 13 + .../content/isolation-iframe/README.md | 23 ++ .../README.md | 16 +- .../browser/content/isolation-spike/README.md | 55 ++++ .../browser/content/relay-styles/README.md | 16 ++ .../integration/build/style-routing/README.md | 29 ++ 11 files changed, 491 insertions(+), 69 deletions(-) create mode 100644 tests/integration/browser/content/css-fonts/README.md create mode 100644 tests/integration/browser/content/frame-pages/README.md create mode 100644 tests/integration/browser/content/isolation-iframe/README.md rename tests/integration/browser/content/{shadow-styles => isolation-shadow}/README.md (71%) create mode 100644 tests/integration/browser/content/isolation-spike/README.md create mode 100644 tests/integration/browser/content/relay-styles/README.md create mode 100644 tests/integration/build/style-routing/README.md diff --git a/AGENTS.md b/AGENTS.md index 9895869d..349a17d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,63 @@ # Repository conventions +## Generated release files + +- `CHANGELOG.md` is generated by the release pipeline during publication. Do not edit it manually; document behavior and migration guidance in the relevant documentation instead. + +## Responsibility boundaries + +### Layer ownership + +- `src/main` is the public API surface. It exposes functions and re-exports contracts; it must not become the internal source of shared types or reusable implementation helpers. +- `src/types` owns shared domain contracts, enums, and constants that genuinely belong to those contracts. Keep these modules free of implementation dependencies and initialization side effects. Internal code imports shared types from `@typing/*`, not from `@main/*`. +- `src/shared` owns pure implementation helpers shared by CLI and extension runtime, grouped by domain (for example, `shared/content`). Such helpers may use domain contracts, but must not import CLI/runtime implementations or depend on DOM state. Keep them separate from public exports and declaration-only contracts. +- Generated, augmentable contracts follow the same rule: for example, `PageAliasRegistry`, `PageAlias`, and `PageMap` belong in `types/page.ts` and are re-exported through `main/page.ts`. Preserve public module augmentation and verify both public and internal consumers when moving them. +- `src/cli/entrypoint` owns discovery, parsing, naming, and normalized entrypoint descriptions. Reuse its finders instead of reaching into a feature plugin for the same information. +- `src/cli/plugins` owns framework feature policy and composition: interpreting entrypoint options, configuring the bundler, supplying normalized data, and declaring manifest requirements. +- `src/cli/bundler/plugins` owns Rspack integration: compiler hooks, runtime modules, emitted assets, and generic build validation. A class using Rspack hooks belongs here even when only one framework feature currently uses it. +- `src/entry` and the other extension runtime modules own execution, rendering adapters, and lifecycle behavior. Build code must not import runtime implementations to obtain constants, types, or helpers. +- Public functions intentionally used as APIs, such as `definePlugin`, are not forbidden imports. Distinguish calling a public API from making the public facade the owner of a shared internal contract. If implementation logic needs multiple internal consumers, extract it to the appropriate shared layer and keep its public export. + +### Dependencies between plugins + +- A framework feature plugin must not import or instantiate another feature plugin's manager or implementation. For example, content uses `PageFinder` from `@cli/entrypoint`, not `Page` from `plugins/page`, to resolve `frame.page` aliases. +- Framework plugins configure Rspack plugins by passing data, selectors, or callbacks with explicit contracts. Do not pass a manager instance, including a disguised dependency such as `Pick`. +- Give Rspack plugins names and options that describe their build responsibility, not their current caller. They should not know about content options such as `frame.page`, page aliases, or specific managers when they only need filenames and access requirements. +- Caller-specific policy and diagnostic context belong to the framework plugin. Pass a validation callback, issuer, or hint instead of hardcoding the caller's options and error explanation inside a generic Rspack plugin. +- Rspack plugins must not import sibling plugin implementations or their private helpers. Extract a genuinely shared contract or algorithm below both consumers; do not hide the dependency behind a barrel re-export. +- Use `bundler/plugins/utils` and `bundler/plugins/types.ts` for shared plugin infrastructure such as compilation hooks and option contracts. Use `bundler/utils` for general asset graph, classification, and filename utilities. Keep feature-private helpers inside their owning plugin. +- Prefer an existing shared abstraction over a new service, registry, or global context introduced only to avoid one import. Importing the correct shared finder is sufficient when its data is what the consumer needs. + +### Shared contracts and runtime delivery + +- Separate a shared protocol from its implementation. A runtime property name used by both CLI and extension runtime belongs in a side-effect-free shared contract; each side imports it independently. The generic Rspack plugin receives the property through its options, using the shared `property` contract. +- Do not move build-only details into public domain types merely because a feature uses them. For example, `IsolatedStylesLayer` is a bundler classification detail; a property used to communicate with the extension's styles runtime is a shared contract. +- Keep each Rspack plugin encapsulated in its own directory with its class, tests, helpers, templates, and an `index.ts` exporting the necessary surface. Runtime JavaScript templates belong beside their owning plugin and use the existing raw-template pipeline; do not mix unrelated plugin runtimes in a common directory or embed large JavaScript strings in plugin classes. +- `RuntimeDataPlugin` embeds data known before filename hashing. It is not a universal replacement for `DefinePlugin`, which remains appropriate for compile-time constants and substitutions. Data depending on final asset names needs the bundler's filename and hashing lifecycle, not an early snapshot or an ad hoc post-build rewrite. +- Refactoring must not introduce background dependencies into other entrypoints, hidden global registries, message-based metadata delivery, or extra carrier files. Such behavior requires a separate, explicit design decision. + +### Asset inventory, delivery, and access + +- The entrypoint asset map describes which files belong to an entrypoint. Keep `js` and `css` complete; do not add feature-specific delivery subsets such as `isolatedCss` to this public inventory. +- Delivery belongs to the relevant runtime/plugin. For example, the isolated styles runtime owns its initial CSS list and loading state; Shadow/iframe nodes register their render roots without reading or filtering the public asset map. +- Manifest contracts represent manifest data and generic dependencies, not feature configuration. Do not add routing fields such as `isolatedStyles`, `isolatedCss`, or `framePage` to transport policy through the manifest builder. Use the appropriate builder methods and shared preparation/validation hooks. +- Resource registration and access validation are separate responsibilities. The page plugin declares WAR from `page.matches`; the consumer supplies its required resource and origins; the generic validator checks the final merged manifest. Validation must not silently grant or broaden access. +- Perform checks that depend on emitted files or user manifest overrides at the appropriate final compilation stage. Refresh derived data per compilation in watch mode so removed entries, aliases, styles, and WAR rules do not survive through stale state. + +### Refactoring checks + +- Before adding an import or moving a symbol, identify who owns the behavior, who needs the data, and which dependency direction follows from that ownership. A directory move alone does not fix a dependency if the original implementation is still imported through a helper or re-export. +- When changing a shared boundary, update declarations, consumers, templates, tests, and documentation together. Treat removal of public metadata as an API change, not a cosmetic cleanup. +- Preserve behavior while separating responsibilities: public exports, generated declarations, loading order, lifecycle, user filename templates, manifest/WAR semantics, and watch updates. Verify at the owning layer and use existing integration tests for cross-layer guarantees. +- Apply these rules to new and changed code. Report unrelated existing violations rather than expanding a focused task into an unrequested architectural rewrite. + +## Internal imports + +- Import internal TypeScript modules through configured aliases or relative paths without a file extension. Do not write the future `.js` output path into a TypeScript source import. +- Output extensions and external-module paths belong to the build configuration. When a bundled entry needs an external dependency, resolve its source import to the emitted ESM path during the build. +- Imports of actual JavaScript files, package subpaths, and loader-specific resources such as `.ts?raw` keep the syntax required by their owner. This rule does not rewrite raw template contents or generated JavaScript imports. +- Apply this convention to new and changed imports; do not mass-rewrite unrelated files. + ## Symbol naming - Apply these conventions throughout the project: shared types, runtime APIs, entrypoints, CLI, bundler plugins, and tests. @@ -16,7 +74,7 @@ - Use **verb + domain concept** for exported functions. Prefer explicit names over ambiguous shortcuts, and use the same terminology as the associated types. - For getters returning a named domain contract, align the function and result names: `getX(): X`. Preserve required wrappers such as `Promise`. A getter returning a collection should also express that collection in its name. -- For example, use `getEntrypointAssets(): EntrypointAssets` and `getEntrypointAssetsMap(): EntrypointAssetsMap`. Avoid an unrelated pair such as `getBuildAssets(): EntrypointAssetsMap`. +- For example, use `getEntrypointAssets(): EntrypointAssets` and `getEntrypointAssetsMap(): EntrypointAssetsMap`. - Add qualifiers such as `Current`, `Runtime`, or `Build` only when they identify a meaningful distinction. Do not add them merely to resolve a naming collision; first clarify the base contract and its related shapes. - Keep access restrictions and behavior explicit in the API documentation and errors. A naming cleanup must not change the returned data, execution context, or loading behavior. diff --git a/src/entry/content/README.md b/src/entry/content/README.md index 9686d31f..52a9fbfe 100644 --- a/src/entry/content/README.md +++ b/src/entry/content/README.md @@ -1,79 +1,220 @@ # Content entrypoints -## Shadow DOM +## Internal organization -Set `shadow: true` to render a content entrypoint inside an open `ShadowRoot`. The framework keeps -the existing host element and its `mount` or `append` placement, creates an inner render target, -and points the React or Vanilla adapter at that container. +`core/Builder.ts` and `core/MountBuilder.ts` coordinate the content lifecycle. Related implementations +and their tests live together: -```tsx -import {ContentScriptAppend, ContentScriptWorld, defineContentScriptAppend} from "adnbn"; +- `core/nodes`: host mounting, node decorators, ShadowRoot/iframe targets, and the styles-runtime helper. +- `core/markers`: anchor marking and lookup strategies. +- `core/context`: the node collection, lifecycle operations, and event subscriptions. +- `core/resolvers`: normalization of content definitions and option handlers. +- `adapters/react` and `adapters/vanilla`: UI rendering implementations. +- `frame`: document navigation for `frame.page` and `frame.src`, without a UI renderer. +Pure structural validation and the frame-navigation predicate live in `src/shared/content/isolation.ts`. +CLI and runtime import this shared module independently; the parser does not import runtime resolvers. + +A blank iframe still uses its React/Vanilla adapter with the shared `FrameNode`. The public +`adnbn/entry/content/frame` import resolves to the separate frame builder. Internally, +`virtual:content-builder` selects either that builder or a renderer adapter. + +## Isolation + +`isolation` accepts `ContentScriptIsolation.None`, `Shadow`, or `Iframe`, or their string values +`"none"`, `"shadow"`, and `"iframe"`. The default is `None`. Isolation changes the render target; +existing anchors, containers, mount/append placement, and context methods remain available. + +```tsx title="src/panel.content/index.tsx" +import {ContentScriptIsolation, defineContentScriptAppend} from "adnbn"; +import "./panel.css?isolation"; import Panel from "./Panel"; export default defineContentScriptAppend({ matches: ["https://example.com/*"], - world: ContentScriptWorld.Isolated, - shadow: true, anchor: ".product", - append: ContentScriptAppend.After, + isolation: ContentScriptIsolation.Iframe, + frame: {height: 320}, render: Panel, }); ``` -Shadow content entrypoints always remain separate bundler entrypoints, even when -`concatContentScripts` is enabled. `commonChunks` remains supported, so ordinary and shadow -entrypoints may still consume the same dependency or CSS chunk. The manifest loads CSS normally for -ordinary content scripts and exposes the same file through web accessible resources when a shadow -entrypoint needs it. +| Mode | Target | `frame` | +| ----------------------- | ---------------------------------------------------- | -------------------- | +| None | Host container | Forbidden | +| Shadow | Inner element in an open ShadowRoot | Forbidden | +| Iframe without page/src | Inner element in a blank iframe document | Optional dimensions | +| Iframe with page/src | Embedded document owns its UI; `render` is forbidden | Required page or src | -Shadow DOM is supported in the effective `ISOLATED` world. `MAIN` with `shadow` fails in Manifest V3. -Manifest V2 normalizes a requested `MAIN` world to `ISOLATED` and prints a build warning before -grouping and bundling the entrypoint. +`frame.width` and `frame.height` accept pixels as numbers or CSS strings. Defaults are `100%` and +`150px`, with no border and `display: block`. Automatic height is not implemented; `height: "auto"` +produces an error. The blank iframe has no `src` attribute. The framework creates the host and target, +so a custom `container` factory is optional. -Initial styles are extension files linked inside every root. Rendering starts immediately, so a -brief unstyled frame is possible while those files load. Lazy CSS is requested with its `import()`; -the import waits for the roots active at the start of that request. A late root receives initial CSS -and every lazy stylesheet already requested by its entrypoint. Failed and timed-out lazy styles reject -the import and can be retried. The timeout follows `output.chunkLoadTimeout`. +Import UI styles with `?isolation` and declare fonts in CSS as described below. Relay supports the +same isolation and frame variants; its RPC transport and all-frame addressing remain independent +of UI isolation. -Each entrypoint owns its style registry on its Rspack runtime. No background bundle, global `window` -registry, JSON map, or carrier file is added to another entrypoint. +## Embedding a document -## Local fonts +```ts title="src/panel-frame.content.ts" +import {defineContentScriptAppend} from "adnbn"; -Browsers do not reliably register `@font-face` declared only inside a shadow stylesheet. Import a -local font and list it in `shadow.fonts` instead: +export default defineContentScriptAppend({ + matches: ["https://example.com/*"], + isolation: "iframe", + frame: {page: "panel", height: 320}, +}); +``` -```tsx -import panelFont from "./panel.woff2"; +`page` is a generated, typed page alias. Its HTML must already be accessible through the final +manifest's web accessible resources. In MV3 those rules must cover every content-script match origin; +paths, exclusions and globs do not reduce this check. In MV2 the resource must be in the flat WAR list. +Rules can come from `page.matches` or custom manifest configuration and jointly provide coverage. +Insufficient coverage fails the build with a hint to add page matches or narrow content matches; the +framework does not broaden access automatically. + +Use `frame: {src: "https://example.com/panel"}` for an absolute HTTP(S) URL. Absolute extension URLs +are also accepted. Relative paths and other schemes are unsupported. `page` and `src` are mutually +exclusive and cannot be combined with `render`. The child page loads its own scripts, +styles and fonts. A frame `load` event or the context's `Mount` event does not prove that embedding +succeeded: host CSP, destination CSP/frame-ancestors or X-Frame-Options can block it. + +For content scripts, a default-exported component or render function also conflicts with +`frame.page`/`frame.src` and is checked during the build. A default-exported options object remains +configuration. A Relay's default function is `init`, not an implicit renderer. + +Isolation values and frame routing must be statically known. Write `frame` as an explicit object +or a local constant holding one, without spreads or methods; page/src can reference statically resolvable string constants. + +## Execution worlds and lifecycle + +Shadow, blank iframe, extension URLs and `frame.page` require effective `ISOLATED`. HTTP(S) +`frame.src` also supports `MAIN`. MV2 normalizes requested `MAIN` to `ISOLATED` with a build warning +before grouping and bundling. Unsupported MV3 combinations fail the build. + +In a blank iframe, React/Vanilla JavaScript still executes in the content-script runtime and renders +into the child document. This is visual/document isolation, not a separate JavaScript security +boundary. Code using `document`, portals or document-level listeners must deliberately use +`node.target.ownerDocument` when it intends to address the child document. + +A blank iframe needs a connected host before its render target can be initialized. A custom `mount` +must attach the container to the document before returning. If it does not, mounting reports the +disconnected container explicitly; the framework does not attach it automatically or wait for it. + +Moving or detaching/reinserting a host can reset the iframe document. On iframe `load`, the framework +checks both the document and render target and coalesces recovery through `context.mount()`. CSS, +fonts and the renderer are restored, including when the initial anchor search has already stopped. +Recovery does not add a duplicate node or emit Add/Remove; it emits one Mount. Initial load of an +intact document does not remount. There is no background polling. Unmount cancels pending recovery +and releases style waits before removing the host. + +When restoring an iframe document, each reconnected stylesheet may retry once on a load error. +This handles Firefox cancelling a shared in-flight CSS request when the previous document is +discarded. The retry replaces the failed link in the same cascade position and shares its original +`output.chunkLoadTimeout` budget. A timeout or second failure is reported; unmount cancels both attempts. +Only styles reconnected during recovery (initial and previously requested lazy CSS) receive this +retry. Initial mounting, Shadow DOM, and future lazy imports retain their usual failure behavior. +The browser's link error event does not identify cancellation, so any load error during recovery +can receive this one retry. The framework does not change URLs or use inline CSS to recover. + +React local state is lost after a real document reset. Keep persistent state outside the component +if it must survive. For page/src, reload remains ordinary browser navigation behavior. + +## Styles and fonts + +Shadow and all iframe entries bypass `concatContentScripts`. `commonChunks` is still supported. +Plain CSS/SCSS imports keep document delivery: initial CSS goes into `content_scripts.css`; lazy CSS +loads into the page only when its `import()` runs. UI isolation does not change plain imports. + +```ts title="src/panel.content/index.ts" +import "./host.css?asis"; +import styles from "./panel.module.css?isolation"; +``` -export default defineContentScriptAppend({ - shadow: { - fonts: [ - { - family: "AdnbnPanelInter", - source: panelFont, - weight: "400", - style: "normal", - }, - ], +`?isolation` sends CSS to ShadowRoot or the blank iframe document. Combine it with `?asis` as +`?isolation&asis` to disable CSS Modules; the two flags have independent roles. Local CSS imports and +Sass dependencies inherit their stylesheet's destination. A separate CSS import in JavaScript chooses +its own destination. + +Initial isolated CSS is excluded from `content_scripts.css` and exposed through WAR. Each root or +iframe head receives its own links in asset-map order. A shared CSS file can remain in an ordinary +consumer's manifest and also be linked by an isolated consumer. `getEntrypointAssets()` retains every +CSS file in `initial.css` / `async.css`. The isolated styles runtime owns CSS routing; the asset map does not expose delivery-specific subsets. + +With `isolation: None`, marked CSS loads normally into the page. Outside content and Relay, including +popup and page entries, `?isolation` has no routing effect and adds no isolated-style runtime. +`frame.page`/`frame.src` entries have no local render target: importing `?isolation` CSS there is a build +error. Import the styles in the embedded page instead. That page's own CSS behaves normally. + +The build reports `[adnbn:missing-isolation-css]` when a Shadow or blank-iframe content/Relay entry +has CSS dependencies but none are marked `?isolation`. The check includes initial, shared and lazy CSS; +it reports once per entry per compilation and is refreshed in watch mode. It does not change asset +delivery. Entries without CSS, non-isolated entries, embedded pages and unrelated entrypoints are +not warned. Document-only CSS is legitimate, for example when only a font is imported and the UI uses +inline styles. To silence this diagnostic intentionally, use the existing bundler warning filter: + +```ts title="adnbn.config.ts" +import {defineConfig} from "adnbn"; + +export default defineConfig({ + bundler: { + ignoreWarnings: [/\[adnbn:missing-isolation-css\]/], }, - render: Panel, }); ``` -Supported inputs are local `.woff`, `.woff2`, `.eot`, `.ttf`, and `.otf` imports. The emitted file -keeps the configured asset filename and hash pattern. At runtime the framework creates a `FontFace`, -adds it to `document.fonts`, and starts loading it without delaying the UI. Duplicate definitions in -one entrypoint are ignored. - -Use a family name that is unlikely to collide with the page or another content entrypoint. Registered -faces remain observable in `document.fonts` until the document ends, including after the UI unmounts. -External URLs, Google Fonts, and external `@import` are unsupported: they depend on page CSP, expose a -third-party request, and do not solve shadow font registration. Self-host a local `.woff2` instead. +This is a heuristic: a single correctly marked dependency prevents the warning even if another UI +stylesheet is missing its query. Initial document CSS still goes into the manifest; lazy document +CSS still waits for its import. + +Rspack separates CSS destinations before emitting assets. Preserve the `adnbnIsolatedStyles` cache +group if customizing `splitChunks`: a chunk mixing destinations fails the build rather than injecting +styles into the wrong document. This partition is necessary even with `commonChunks: false`. + +Rendering starts immediately, so briefly unstyled UI is possible. Lazy CSS is requested with +`import()`; mixed imports wait for both document CSS and targets active at that request's start. Late targets receive initial +and already requested lazy CSS. Imports before the first target do not wait for future UI. Failed +or timed-out lazy links reject the import and permit retry; timeout follows `output.chunkLoadTimeout`. +Initial CSS errors identify the entrypoint and URL but do not remove UI. There is no inline-CSS or +constructed-stylesheet fallback. Registries belong to each entry runtime, not `window`; no carrier, +JSON map or background bundle is injected into other entries. + +For Shadow, declare `@font-face` in ordinary document CSS, then use that family inside `?isolation` +CSS. `@font-face` inside a shadow stylesheet does not reliably register the face. Use a unique family +name: the document declaration is visible to the host page and outlives UI unmount. + +```css title="src/panel.content/host.css" +@font-face { + font-family: "AdnbnPanelInter"; + src: url("./panel.woff2?browser") format("woff2"); + font-weight: 400; +} +``` -File stylesheets and local fonts are covered in Chrome 155 MV3 and Firefox 155 MV2/MV3, including a -page that rejects document styles and fonts through `style-src`, `style-src-elem`, and `font-src`. -Those checks also cover top documents, iframes, multiple roots, two shadow entrypoints, shared CSS, -lazy CSS, unmount, and remount. +Both `?browser` and `?chrome` explicitly emit an extension URL for the build target, including +`moz-extension://__MSG_@@extension_id__/` for Firefox and `chrome-extension://__MSG_@@extension_id__/` +for Chromium targets. Without either query, assets retain normal bundler URL handling. +These queries support fonts, images, media, +documents and explicitly imported binary files; `?base64` keeps inline asset delivery. Code, JSON, +stylesheets and specialized `?react`/`?raw` imports retain their own loaders. The localization token +is interpreted in CSS, not JavaScript: use the browser `getUrl()` helper for ordinary JS asset +imports rather than treating that token as a ready runtime URL. User asset names and hashes remain intact. + +For a blank iframe, put `@font-face` directly in its `?isolation` CSS and use a local relative font URL. +It belongs to that iframe document; a host-page font declaration does not register it there. If both +destinations need the face, import a small shared font stylesheet from both destination stylesheets. +After document recovery, reconnecting the iframe CSS restores its font declarations too. + +There is no `FontFace` registration, global font registry, or eager font loading in framework runtime. +The browser loads fonts when used. Self-host local WOFF2 files; external font `@import` and Google Fonts +depend on network, privacy choices and page CSP and are not a supported replacement in this contract. + +## Verification + +Chrome 155 MV3 and Firefox 155 MV2/MV3 integration fixtures exercise strict CSP, initial and lazy CSS, +React and Vanilla, multiple entries/targets, real font use measured by text width, iframe movement, +cleanup/remount, extension pages, and allowed/blocked external embedding. Reports record exact browser +versions under `.cache/integration`. The build watch test covers isolation transitions, fonts, page +alias changes and removal of outdated WAR rules. The local `addon` playground has native Shadow and +iframe panels with separate resource statuses. diff --git a/src/relay/README.md b/src/relay/README.md index 61dedb90..ee369c04 100644 --- a/src/relay/README.md +++ b/src/relay/README.md @@ -180,7 +180,9 @@ RelayParser / RelayFinder `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. +The content plugin supplies the options map to `RuntimeDataPlugin`, which embeds it in each entry runtime under the shared `RelayOptionsRuntimeProperty` key. `getRelay()` reads that runtime data; no separate file or background dependency is added. Watch rebuilds refresh the map together with Relay declarations, including changed names and removed optional values. + +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 diff --git a/tests/integration/README.md b/tests/integration/README.md index d785586f..ce40f239 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -15,10 +15,13 @@ tests/integration/ │ │ ├── entrypoint-assets.firefox.integration.test.ts │ │ ├── utils.ts │ │ ├── entrypoint-assets/ -│ │ ├── shadow-styles.integration.test.ts -│ │ ├── shadow-styles.firefox.integration.test.ts -│ │ ├── shadow-styles-utils.ts -│ │ └── shadow-styles/ +│ │ ├── isolated-styles-utils.ts +│ │ ├── isolation-shadow.integration.test.ts +│ │ ├── isolation-shadow.firefox.integration.test.ts +│ │ ├── isolation-shadow/ +│ │ ├── isolation-iframe.integration.test.ts +│ │ ├── isolation-iframe.firefox.integration.test.ts +│ │ └── isolation-iframe/ │ ├── offscreen/ │ │ ├── service.integration.test.ts │ │ └── service/ @@ -51,7 +54,7 @@ Install dependencies once at the repository root, then run: npm run prepare:integration ``` -This builds the framework, links each fixture's declared dependencies to the local framework or root `node_modules`, and builds all six applications. The generated `.adnbn` configuration and declarations remain beside their sources, so the editor can resolve `adnbn`, virtual imports such as `adnbn/browser`, CSS/SVG modules, and generated transport contracts. +This builds the framework, links each fixture's declared dependencies to the local framework or root `node_modules`, and builds all fixture applications. The generated `.adnbn` configuration and declarations remain beside their sources, so the editor can resolve `adnbn`, virtual imports such as `adnbn/browser`, CSS/SVG modules, and generated transport contracts. No separate install or lockfile is needed in each fixture. Dependency versions come from the root installation. Preparation can be repeated; matching links are reused, while conflicting existing dependency locations produce an error instead of being overwritten. @@ -86,7 +89,8 @@ Tests copy application inputs to unique directories under `.cache/integration`. - `browser/options`: two Chrome MV3 cases covering Vanilla and React rendering, CSS, state/events, opening Options from background, and a View chunk shared with a Page. - `browser/offscreen/service`: one Chrome MV3 round trip from background through Offscreen to a registered background service. - `browser/content/entrypoint-assets`: one Chrome MV3 case and two Firefox cases (MV2 and MV3) using the same application and probe assertions. They cover current asset getters, rejecting the full-map getter outside background, common chunks, dynamic imports, CSS/SVG resource URLs, world separation, and top/child frames. The Chrome case also reads the full-map readiness flag in background. -- `browser/content/shadow-styles`: production Shadow DOM coverage in Chrome MV3 and Firefox MV2/MV3. Two shadow entrypoints and one ordinary entrypoint verify file-backed initial/lazy/shared CSS, independent runtime registries, strict CSP, local `FontFace` registration, watch-driven remount, top documents, and child iframes. The shared CSS remains manifest CSS for the ordinary consumer and a web accessible resource for the shadow consumers. +- `browser/content/isolation-shadow`: production Shadow DOM coverage in Chrome MV3 and Firefox MV2/MV3. Two shadow entrypoints and one ordinary entrypoint verify file-backed initial/lazy/shared CSS, independent runtime registries, strict CSP, local fonts declared with CSS `@font-face`, watch-driven remount, top documents, and child iframes. The shared CSS remains manifest CSS for the ordinary consumer and a web accessible resource for the shadow consumers. +- `browser/content/isolation-iframe`: production blank-iframe coverage in the same browser matrix. React and Vanilla renderers verify initial/lazy/shared CSS, local CSS fonts, strict CSP, and recovery after moving or detaching a host, including while its initial styles are loading. In MV3, ISOLATED retains physical async chunks and MAIN includes dynamic dependencies in its initial graph. In Addon Bone's MV2 pipeline, every content entry uses ISOLATED, including entries requesting MAIN. Such requests emit a build warning before entrypoint grouping; the runtime test verifies async JS/CSS and the absence of page-visible globals for the downgraded entries. This is a framework policy, not a claim that all Firefox versions lack native MAIN support in MV2. diff --git a/tests/integration/browser/content/css-fonts/README.md b/tests/integration/browser/content/css-fonts/README.md new file mode 100644 index 00000000..520b63d8 --- /dev/null +++ b/tests/integration/browser/content/css-fonts/README.md @@ -0,0 +1,81 @@ +# CSS font delivery probe + +Browser check for local CSS font declarations used inside ShadowRoot and iframe documents. +This fixture verifies browser resource loading independently of mixed CSS routing in the +production Rspack pipeline. + +## Mechanism + +- `page.css` is included in `content_scripts.css`. It declares one local font directly and + imports another declaration through `imported-font.css` (bundled into the same output CSS). +- `isolated.css` is loaded as a file through a link in each ShadowRoot or blank iframe head. +- The script creates no FontFace objects and never calls `document.fonts.add()` or + `document.fonts.load()`. Rendering text triggers the browser's normal CSS font loading. +- Two shadow roots use the families declared in manifest CSS. Two iframe documents use their + own CSS family. The second root/document is created after the first completes. +- Each isolated document also attempts to use the parent's families. Their fallback widths + demonstrate that parent font registration does not cross the iframe boundary. +- The synthetic font is reused from `isolation-shadow`. `AAAA` at 40px measures exactly 320px; + the monospace fallback measures about 96.33px. A font-family string alone is not evidence. + +The fixture uses esbuild only to bundle the probe, resolve the local CSS import and emit the +font with a hashed filename. Its manifest is explicit so this browser check is independent +of the framework routing implementation. No generated bundle is patched afterward. + +## Results + +Verified locally on Chrome 155.0.8043.4 MV3 and Firefox 155.0.1 MV2/MV3. +All three targets pass both without page CSP and with: + +```text +default-src 'none'; style-src 'none'; style-src-elem 'none'; font-src 'none'; frame-src 'self' +``` + +| Declaration and consumer | Chrome MV3 | Firefox MV2/MV3 | +| --------------------------------------------------------- | ---------- | --------------- | +| Manifest CSS font, used inside ShadowRoot | 320px | 320px | +| Locally imported font declaration, used inside ShadowRoot | 320px | 320px | +| Iframe CSS font, used inside the iframe | 320px | 320px | +| Parent font, referenced inside an iframe | Fallback | Fallback | + +The shadow-only `@font-face` measurement also remains at the fallback width in these browser +versions; the working shadow fonts come from the document's manifest CSS. + +### Chrome URL requirement + +The initial run with relative font URLs in manifest CSS failed in Chrome: the faces had status +`error` and text remained at the fallback width. Firefox resolved the same paths successfully. +Relative URLs in CSS linked inside the iframe worked in both browsers. + +For Chrome's manifest CSS, the test build now emits +`chrome-extension://__MSG_@@extension_id__/assets/.woff2` via esbuild's `publicPath`. +Chrome substitutes the ID natively when loading extension CSS. This is its documented resource +URL mechanism, not a DOM marker, font runtime, post-build string rewrite, or fixed extension ID. +Firefox keeps CSS-relative URLs. Both use explicit web-accessible resources. + +Sources: [Chrome content-script resources](https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts#access-other-files), +[Firefox injected CSS URL resolution](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/content_scripts#css). + +## Reproduce + +From the repository root, with dependencies and supported browser binaries installed: + +```sh +npx jest --runTestsByPath tests/integration/browser/content/css-fonts.integration.test.ts tests/integration/browser/content/css-fonts.firefox.integration.test.ts --runInBand +``` + +Override detection with `ADNBN_CHROME_BIN` and `ADNBN_FIREFOX_BIN` if needed. The test uses the +existing CDP/BiDi harness, temporary profiles and a local HTTP server. It writes measurements +under `.cache/integration/css-fonts--mv.json` and removes temporary extensions +and browser profiles. Google Fonts and external services are not used. + +## Architectural conclusion and scope + +CSS supplies the fonts in the tested matrix, provided each declaration reaches the right document +and its resource URLs are correct. + +Production routing, manifest/WAR, asset maps and watch are tested separately +by `build/style-routing`, `build/isolation`, and the `isolation-shadow`, `isolation-iframe`, and `relay-styles` +browser fixtures. This probe alone does not establish those guarantees, support for native remote +CSS imports, or compatibility with every browser version. It also does not test iframe recovery +after a document reset or lazy loading of document-level font CSS. diff --git a/tests/integration/browser/content/frame-pages/README.md b/tests/integration/browser/content/frame-pages/README.md new file mode 100644 index 00000000..89cb37ea --- /dev/null +++ b/tests/integration/browser/content/frame-pages/README.md @@ -0,0 +1,13 @@ +# Embedded page integration + +Two content entries embed a typed extension page alias and an external HTTP page. The external +entry requests MAIN; MV2 normalizes it to ISOLATED. Known child messages prove the expected document +executed; iframe load alone is not treated as success. The separate site is the receiving document. + +The runner also verifies that insufficient page WAR origin coverage fails an MV3 build. `contract.ts` +contains positive and negative public type checks, including generated page aliases. + +```sh +npm run build +npx jest --testPathPatterns=frame-pages --runInBand +``` diff --git a/tests/integration/browser/content/isolation-iframe/README.md b/tests/integration/browser/content/isolation-iframe/README.md new file mode 100644 index 00000000..d270aef9 --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/README.md @@ -0,0 +1,23 @@ +# Iframe content integration + +This fixture uses the production `isolation: Iframe` API with a React primary entry, a Vanilla +secondary entry and an ordinary content entry. Shared CSS verifies manifest and WAR delivery to +both kinds of consumers. Each isolated entry has its own style registry. + +UI CSS uses `?isolation`. The local font is declared with `@font-face` in iframe CSS, with no +FontFace registration in JavaScript. Ordinary CSS remains in the host document. + +The site directory contains the receiving page and child frame, not extension entrypoints. Tests +run with and without strict CSP. They measure actual colors and font width, check early/lazy imports, +and move, detach and reinsert hosts. The first Mount moves the host synchronously while initial CSS +is pending, exercising the one permitted stylesheet retry during document recovery. Later moves +exercise loaded documents. Mount counts verify recovery without duplicate initial mounts; +the secondary entry tests recovery after the normal anchor search stops. + +```sh +npm run build +npx jest --testPathPatterns='isolation-iframe(\.firefox)?\.integration.test' --runInBand +``` + +Chrome MV3 and Firefox MV2/MV3 reports, including versions, are written to +`.cache/integration/isolation-iframe--mv.json`. diff --git a/tests/integration/browser/content/shadow-styles/README.md b/tests/integration/browser/content/isolation-shadow/README.md similarity index 71% rename from tests/integration/browser/content/shadow-styles/README.md rename to tests/integration/browser/content/isolation-shadow/README.md index c9b846a3..dffb213a 100644 --- a/tests/integration/browser/content/shadow-styles/README.md +++ b/tests/integration/browser/content/isolation-shadow/README.md @@ -1,18 +1,18 @@ # Shadow DOM content integration -This fixture exercises the production `shadow` content-entrypoint API with the native Vanilla +This fixture exercises the production `isolation: Shadow` content-entrypoint API with the native Vanilla renderer. It contains two shadow entrypoints and one ordinary entrypoint. All three consume a forced shared CSS-only chunk, while each shadow entrypoint also loads its own initial and lazy styles. The primary entrypoint mounts two roots, starts one `import()` before the first root exists, watches -for anchor replacement, and registers a local WOFF2 through `shadow.fonts`. The secondary entrypoint +for anchor replacement, and uses a local WOFF2 declared in document CSS with `@font-face`. The secondary entrypoint has an independent runtime registry and lazy chunk. The ordinary entrypoint proves that shared CSS continues to load through `content_scripts.css` outside Shadow DOM. ## What the runner verifies -- Shadow entrypoints have no manifest-level CSS; their initial, lazy and shared CSS files are in web - accessible resources. +- Plain document CSS remains in the manifest, including the font declaration. Initial, lazy and shared + `?isolation` CSS files are in web accessible resources and delivered only to the selected roots. - The ordinary entrypoint retains manifest-level CSS, including the shared chunk. - No content entrypoint receives a background file. - Every root contains file-backed extension `` elements and receives initial and requested lazy @@ -31,9 +31,9 @@ The strict page response uses: default-src 'none'; style-src 'none'; style-src-elem 'none'; font-src 'none'; frame-src 'self'; img-src 'self' ``` -Passing results were reproduced on Chrome 155.0.8041.0 with Manifest V3 and Firefox 155.0 with +Passing results were reproduced on Chrome 155.0.8043.4 with Manifest V3 and Firefox 155.0.1 with Manifest V2 and Manifest V3. Reports are written to -`.cache/integration/shadow-styles--mv.json`, outside the extension output. +`.cache/integration/isolation-shadow--mv.json`, outside the extension output. ## Run @@ -41,8 +41,8 @@ From the repository root: ```sh npm run build -npx jest tests/integration/browser/content/shadow-styles.integration.test.ts --runInBand -npx jest tests/integration/browser/content/shadow-styles.firefox.integration.test.ts --runInBand +npx jest tests/integration/browser/content/isolation-shadow.integration.test.ts --runInBand +npx jest tests/integration/browser/content/isolation-shadow.firefox.integration.test.ts --runInBand ``` Set `ADNBN_CHROME_BIN` or `ADNBN_FIREFOX_BIN` when automatic browser discovery selects the wrong diff --git a/tests/integration/browser/content/isolation-spike/README.md b/tests/integration/browser/content/isolation-spike/README.md new file mode 100644 index 00000000..0e233af0 --- /dev/null +++ b/tests/integration/browser/content/isolation-spike/README.md @@ -0,0 +1,55 @@ +# Iframe feasibility probe + +This small esbuild extension establishes browser behavior independently of the production iframe +node. It uses the real context mount lifecycle with a probe node. React/Vanilla, file CSS, CSS +font-face, strict CSP, and host movement are measured in Chrome MV3 and Firefox +MV2/MV3. Font use is proven by text width. + +The site also serves allowed, CSP-blocked and X-Frame-Options-blocked embedding destinations. +MAIN and ISOLATED use distinct physical test entry files. Reports are saved outside extension +output in `.cache/integration/isolation-spike-*.json`. + +```sh +npm run build +npx jest --testPathPatterns=isolation-spike --runInBand +``` + +## Pending stylesheet regression (2026-09-08) + +The first host move now runs synchronously immediately after `context.mount()`, before any initial +CSS load callback can run. The harness requires generation 2 before accepting the initial result. +Previously, a WebDriver command issued after navigation could run before the content script existed +(moving nothing) or after its resources had loaded. A passing run did not prove the pending-load case. + +Before recovery retries were added, Chrome 155.0.8046.0 passed this deterministic scenario while +Firefox 155.0.1 failed in both MV2 and MV3. +Gecko's `nsCSSLoader` trace shows the new document joining the still-loading `initial.css` request; +that shared request then completes with `0x804b0002` (`NS_BINDING_ABORTED`) after the old iframe +document is discarded. This reproduces without page CSP and without any production bundler plugins. +The production `isolation` fixture also reproduces it when its first Mount immediately moves its host: +the target is recreated, but its CSS fails to apply. Clearing old link handlers alone does not fix it. + +The agreed recovery behavior allows one retry of a failed stylesheet when rebuilding the iframe +document. Both attempts share the original timeout; a second error or timeout remains a failure. +The probe applies this rule to initial CSS when its generation is greater than 1 and cancels pending +work when that document is replaced. Later lazy requests do not inherit the retry policy. +The production fixture exercises the same case through `FrameNode` and the isolated styles runtime. +A link error does not expose Gecko's internal cancellation status to JavaScript, so the retry cannot +identify cancellation alone. No delays, cache-busting URLs, text stylesheets, or skipped assertions +are used. + +With this recovery rule, both the probe and production iframe tests pass in Chrome 155.0.8046.0 +MV3 and Firefox 155.0.1 MV2/MV3, including strict CSP and measured font use. + +To reproduce the browser's loader diagnostics on macOS/Linux: + +```sh +MOZ_LOG=nsCSSLoader:5 MOZ_LOG_FILE=/tmp/adnbn-iframe-css.log \ + npx jest --runInBand --runTestsByPath \ + tests/integration/browser/content/isolation-spike.firefox.integration.test.ts +``` + +Mozilla defines the cancellation result in +[ErrorList.py](https://github.com/mozilla/gecko-dev/blob/master/xpcom/base/ErrorList.py) +and implements stylesheet request coalescing in +[Loader.cpp](https://github.com/mozilla/gecko-dev/blob/master/layout/style/Loader.cpp). diff --git a/tests/integration/browser/content/relay-styles/README.md b/tests/integration/browser/content/relay-styles/README.md new file mode 100644 index 00000000..f48050d2 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/README.md @@ -0,0 +1,16 @@ +# Relay style routing + +Two native Relay panels share a component and use Shadow/blank iframe rendering. An extension page +imports that same component (where `?isolation` is inert). Background calls both Relays through Messaging RPC. +Each call imports lazy document and UI CSS and checks their actual application before returning. +A third Relay embeds this page through `frame.page`, exercising the navigation adapter and WAR check. +The caller is a privileged background context: a web-accessible iframe is not a portable +privileged extension context in Firefox. The page reports only its measured style to its parent; +all panel commands and results go through the production Relay transport. + +The `site` directory is the host website. Run with the existing Chrome CDP / Firefox BiDi harness: + +```sh +npm run build +npx jest --runTestsByPath tests/integration/browser/content/relay-styles.integration.test.ts --runInBand +``` diff --git a/tests/integration/build/style-routing/README.md b/tests/integration/build/style-routing/README.md new file mode 100644 index 00000000..817a9532 --- /dev/null +++ b/tests/integration/build/style-routing/README.md @@ -0,0 +1,29 @@ +# CSS destination routing + +`routing-spike.integration.test.ts` is the independent Rspack feasibility check. It partitions +document and isolated CSS before asset emission, replaces only the selected CSS runtime module, +and proves that a mixed `import()` waits for both destinations. It covers content/Relay sharing, +a popup consuming the same module, filename hashes/callbacks, and watch selection changes. + +The spike found that a bundler layer alone is insufficient: CssExtract also deduplicates by +loader request. The isolated branch therefore needs its own css-loader `ident`. Neither check +rewrites emitted bundles or CSS files. + +`style-routing.integration.test.ts` uses the production style, asset, optimization, asset-map, +and isolated-style plugins. It checks manifest/WAR routing, common chunks on/off, runtime-only +entry chunks, browser-specific CSS URLs, binary assets and inline exclusions. Small resource +files in `src/resources` are build fixtures, not usable fonts or media; real WOFF2 rendering is +checked by the browser fixtures. + +The config resolver supplies the style plugin's `isolationIssuerLayer` condition from the +shared build-layer contract used by content/Relay entries. The style plugin does not identify +entrypoint types itself. Production routing checks cover both execution-world layers, an +unrelated custom issuer selector, and no selector (all CSS stays on the ordinary path). + +```sh +npx jest --runTestsByPath tests/integration/build/style-routing/routing-spike.integration.test.ts tests/integration/build/style-routing/style-routing.integration.test.ts --runInBand +``` + +Production lifecycle and browser coverage lives in `browser/content/isolation-shadow`, `isolation-iframe`, +and `relay-styles`. `build/isolation/isolation-watch.integration.test.ts` exercises actual CLI +watch transitions, including changing the stylesheet query and adding/removing CSS font imports. From e1ad88641343340806a29538547b269520ec8e76 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:54:54 +0300 Subject: [PATCH 18/38] test: fix Windows watch fixture updates --- tests/integration/browser/utils/browser.ts | 8 +- .../isolation-watch.integration.test.ts | 89 +++++++++++-------- .../relay-runtime.integration.test.ts | 22 +++-- 3 files changed, 74 insertions(+), 45 deletions(-) diff --git a/tests/integration/browser/utils/browser.ts b/tests/integration/browser/utils/browser.ts index 5885ce8d..80a03289 100644 --- a/tests/integration/browser/utils/browser.ts +++ b/tests/integration/browser/utils/browser.ts @@ -3,7 +3,11 @@ import type {ChildProcess} from "child_process"; const delay = (milliseconds: number) => new Promise(resolve => setTimeout(resolve, milliseconds)); -export const waitFor = async (callback: () => Promise, timeout = 15_000): Promise => { +export const waitFor = async ( + callback: () => Promise, + timeout = 15_000, + description = "the browser" +): Promise => { const deadline = Date.now() + timeout; let lastError: unknown; @@ -23,7 +27,7 @@ export const waitFor = async (callback: () => Promise, timeout const detail = lastError instanceof Error ? `: ${lastError.message}` : ""; - throw new Error(`Timed out waiting for the browser${detail}`); + throw new Error(`Timed out waiting for ${description}${detail}`); }; export const getFreePort = (): Promise => { diff --git a/tests/integration/build/isolation/isolation-watch.integration.test.ts b/tests/integration/build/isolation/isolation-watch.integration.test.ts index 9e55dc7a..3f5a0000 100644 --- a/tests/integration/build/isolation/isolation-watch.integration.test.ts +++ b/tests/integration/build/isolation/isolation-watch.integration.test.ts @@ -24,7 +24,9 @@ test("real CLI watch changes isolation and page aliases and removes outdated WAR await writeFile(path.join(fixture.directory, "src/watch.css"), ".watch-ui { color: red; }\n"); await copyFile(path.join(__dirname, "states/fonts.css"), path.join(fixture.directory, "src/watch-fonts.css")); const entry = path.join(fixture.directory, "src/page.content.ts"); - const setState = async (state: string) => copyFile(path.join(__dirname, "states", state + ".ts"), entry); + // Save like an editor: Windows copyFile preserves the fixture's old mtime, so watch can miss the edit. + const setState = async (state: string) => + writeFile(entry, await readFile(path.join(__dirname, "states", state + ".ts"))); await setState("page-isolated"); await expect(fixture.build({browser: "chrome"})).rejects.toThrow( "uses ?isolation CSS with frame.page/frame.src, but has no local render target" @@ -43,18 +45,22 @@ test("real CLI watch changes isolation and page aliases and removes outdated WAR output += chunk; }); const observe = (state: string) => - waitFor(async () => { - const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); - const script = manifest.content_scripts.find((script: {js: string[]}) => - script.js.some(file => /page\.content/.test(file)) - ); - if (!script) return undefined; - const source = ( - await Promise.all(script.js.map((file: string) => readFile(path.join(directory, file), "utf8"))) - ).join("\n"); - if (!source.includes(JSON.stringify("isolation-watch-" + state))) return undefined; - return {manifest, script, source}; - }, 15000); + waitFor( + async () => { + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const script = manifest.content_scripts.find((script: {js: string[]}) => + script.js.some(file => /page\.content/.test(file)) + ); + if (!script) return undefined; + const source = ( + await Promise.all(script.js.map((file: string) => readFile(path.join(directory, file), "utf8"))) + ).join("\n"); + if (!source.includes(JSON.stringify("isolation-watch-" + state))) return undefined; + return {manifest, script, source}; + }, + 15000, + `CLI watch state "${state}"` + ); for (const state of [ "none", "shadow", @@ -73,7 +79,8 @@ test("real CLI watch changes isolation and page aliases and removes outdated WAR if (state === "shadow-document") { await waitFor( async () => output.slice(beforeBuild).includes("[adnbn:missing-isolation-css]") || undefined, - 15000 + 15000, + "CLI watch missing-isolation-css warning" ); } const isolated = ["shadow", "shadow-document", "iframe", "fonts"].includes(state); @@ -99,17 +106,21 @@ test("real CLI watch changes isolation and page aliases and removes outdated WAR expect(renamed.source).toContain("renamed-panel"); const compiled = output; await writeFile(page, pageSource.replace('name: "panel"', 'name: "renamed-panel"')); - await waitFor(async () => { - if (output === compiled) return undefined; - const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); - const pageRules = manifest.web_accessible_resources.filter((rule: {resources: string[]}) => - rule.resources.some(file => file.endsWith(".html")) - ); - return pageRules.length && - pageRules.every((rule: {matches: string[]}) => !rule.matches.includes("http://*/*")) - ? true - : undefined; - }, 15000); + await waitFor( + async () => { + if (output === compiled) return undefined; + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const pageRules = manifest.web_accessible_resources.filter((rule: {resources: string[]}) => + rule.resources.some(file => file.endsWith(".html")) + ); + return pageRules.length && + pageRules.every((rule: {matches: string[]}) => !rule.matches.includes("http://*/*")) + ? true + : undefined; + }, + 15000, + "CLI watch to remove outdated WAR matches" + ); // Validation is late, but an invalid build must not replace the last valid manifest or stop watch. const validManifest = await readFile(path.join(directory, "manifest.json"), "utf8"); @@ -120,7 +131,11 @@ test("real CLI watch changes isolation and page aliases and removes outdated WAR .replace('name: "panel"', 'name: "renamed-panel"') .replace("http://127.0.0.1/*", "https://unrelated.example/*") ); - await waitFor(async () => output.slice(beforeFailure).includes("add matches to the page") || undefined, 15000); + await waitFor( + async () => output.slice(beforeFailure).includes("add matches to the page") || undefined, + 15000, + "CLI watch page access validation error" + ); expect(await readFile(path.join(directory, "manifest.json"), "utf8")).toBe(validManifest); await writeFile( page, @@ -128,15 +143,19 @@ test("real CLI watch changes isolation and page aliases and removes outdated WAR .replace('name: "panel"', 'name: "renamed-panel"') .replace("http://127.0.0.1/*", "*://127.0.0.1/*") ); - await waitFor(async () => { - const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); - return manifest.web_accessible_resources.some( - (rule: {resources: string[]; matches: string[]}) => - rule.resources.some(file => file.endsWith(".html")) && rule.matches.includes("*://127.0.0.1/*") - ) - ? true - : undefined; - }, 15000); + await waitFor( + async () => { + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + return manifest.web_accessible_resources.some( + (rule: {resources: string[]; matches: string[]}) => + rule.resources.some(file => file.endsWith(".html")) && rule.matches.includes("*://127.0.0.1/*") + ) + ? true + : undefined; + }, + 15000, + "CLI watch to restore valid page access" + ); } catch (error) { throw new Error(`${String(error)}\n${output}`, {cause: error}); } finally { diff --git a/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts b/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts index 19815f53..642b17ea 100644 --- a/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts +++ b/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts @@ -1,6 +1,6 @@ /** @jest-environment node */ import {spawn, type ChildProcess} from "child_process"; -import {copyFile, readFile, rm} from "fs/promises"; +import {copyFile, readFile, rm, writeFile} from "fs/promises"; import path from "path"; import vm from "vm"; @@ -42,7 +42,9 @@ test("content plugin delivers Relay options and refreshes names, methods and opt await rm(path.join(fixture.directory, "src/iframe.relay.ts")); await copyFile(path.join(__dirname, "states/background.ts"), path.join(fixture.directory, "src/background.ts")); const entry = path.join(fixture.directory, "src/shadow.relay.ts"); - const setState = (state: string) => copyFile(path.join(__dirname, "states", `${state}.ts`), entry); + // Save like an editor: Windows copyFile preserves the fixture's old mtime, so watch can miss the edit. + const setState = async (state: string) => + writeFile(entry, await readFile(path.join(__dirname, "states", `${state}.ts`))); await setState("messaging"); const directory = await fixture.build({browser: "chrome"}); const inspect = async () => { @@ -63,18 +65,22 @@ test("content plugin delivers Relay options and refreshes names, methods and opt }); watcher.stdout?.on("data", chunk => (output += chunk)); watcher.stderr?.on("data", chunk => (output += chunk)); - await waitFor(inspect); + await waitFor(inspect, 15000, "initial Relay watch build"); for (const [state, expected] of [ ["scripting", expectedScripting], ["messaging", expectedMessaging], ] as const) { await setState(state); - await waitFor(async () => { - const options = await inspect(); - expect(options).toEqual(expected); - return options; - }, 15000); + await waitFor( + async () => { + const options = await inspect(); + expect(options).toEqual(expected); + return options; + }, + 15000, + `CLI watch Relay state "${state}"` + ); const declaration = await readFile(path.join(fixture.directory, ".adnbn/relay.d.ts"), "utf8"); const name = Object.keys(expected)[0]; expect(declaration).toContain(name); From e63c60bf073e4f76363fc499bd4c8e0445285156 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:39:53 +0300 Subject: [PATCH 19/38] test(browser): close speculative connections during teardown --- tests/integration/browser/utils/site.test.ts | 37 ++++++++++++++++++++ tests/integration/browser/utils/site.ts | 7 +++- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/integration/browser/utils/site.test.ts diff --git a/tests/integration/browser/utils/site.test.ts b/tests/integration/browser/utils/site.test.ts new file mode 100644 index 00000000..c3d5797c --- /dev/null +++ b/tests/integration/browser/utils/site.test.ts @@ -0,0 +1,37 @@ +/** @jest-environment node */ +import {once} from "events"; +import {createConnection} from "net"; +import path from "path"; + +import {startIntegrationSite} from "./site"; + +test("closes speculative browser connections that have not sent an HTTP request", async () => { + const site = await startIntegrationSite(path.resolve(__dirname, "../content/isolation-shadow/site")); + const socket = createConnection({host: "127.0.0.1", port: Number(new URL(site.origin).port)}); + let timeout: ReturnType | undefined; + let closing: Promise | undefined; + + try { + await once(socket, "connect"); + // A forced server teardown can surface as FIN or ECONNRESET, depending on the platform. + // Both must end in close; a reset is not a connection-establishment failure here. + const closed = new Promise((resolve, reject) => { + socket.once("close", () => resolve()); + socket.once("error", (error: NodeJS.ErrnoException) => { + if (error.code !== "ECONNRESET") reject(error); + }); + }); + closing = site.close(); + await Promise.race([ + Promise.all([closing, closed]), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("Test server did not close its idle connection")), 2_000); + }), + ]); + expect(socket.destroyed).toBe(true); + } finally { + clearTimeout(timeout); + socket.destroy(); + await (closing ?? site.close()); + } +}); diff --git a/tests/integration/browser/utils/site.ts b/tests/integration/browser/utils/site.ts index 544724e0..a8e15de6 100644 --- a/tests/integration/browser/utils/site.ts +++ b/tests/integration/browser/utils/site.ts @@ -54,6 +54,11 @@ export const startIntegrationSite = async ( return { origin: `http://127.0.0.1:${address.port}`, - close: () => new Promise((resolve, reject) => server.close(error => (error ? reject(error) : resolve()))), + close: () => + new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())); + // Chrome can leave speculative connections with no HTTP request. Drain them on teardown too. + server.closeAllConnections(); + }), }; }; From 68972af26618e94452194322f0a9b5cca4158e56 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:42:59 +0300 Subject: [PATCH 20/38] feat(content): consolidate isolation options and support closed roots Normalize shorthand and object isolation options for content scripts and Relay. Keep Shadow mode, iframe navigation, and dimensions under the isolation contract. Move static value resolution and default-export analysis into file readers. Validate normalized options with explicit Zod schemas while preserving enum injection and runtime boundaries. Update lifecycle, browser, watch, and parser tests alongside isolation documentation and repository conventions. --- AGENTS.md | 6 +- .../entrypoint/file/ExpressionFile.test.ts | 45 +- src/cli/entrypoint/file/ExpressionFile.ts | 14 +- src/cli/entrypoint/file/OptionFile.test.ts | 87 +++ src/cli/entrypoint/file/OptionFile.ts | 147 ++--- src/cli/entrypoint/file/SourceFile.ts | 542 ++++++++++-------- src/cli/entrypoint/file/index.ts | 2 + src/cli/entrypoint/file/injectors/core.ts | 5 + .../entrypoint/file/parsers/ExportParser.ts | 80 +++ src/cli/entrypoint/file/parsers/NodeFinder.ts | 17 + .../entrypoint/file/parsers/ObjectParser.ts | 7 +- src/cli/entrypoint/file/parsers/index.ts | 1 + src/cli/entrypoint/file/parsers/types.ts | 18 + .../file/resolvers/ImportResolver.ts | 4 + src/cli/entrypoint/file/tests/background.ts | 2 - .../expression/default-exports/Handler.ts | 5 + .../default-exports/aliased-function.ts | 1 + .../expression/default-exports/arrow.ts | 1 + .../default-exports/empty-string.ts | 1 + .../expression/default-exports/function.ts | 3 + .../default-exports/imported-function.ts | 2 + .../default-exports/imported-object.ts | 2 + .../expression/default-exports/jsx.tsx | 3 + .../default-exports/local-function.ts | 2 + .../expression/default-exports/named-only.ts | 1 + .../expression/default-exports/number.ts | 1 + .../expression/default-exports/object.ts | 6 + .../default-exports/reexported-function.ts | 1 + .../expression/default-exports/string.ts | 1 + .../expression/default-exports/tsconfig.json | 8 + .../expression/default-exports/type-only.ts | 2 + .../default-exports/unrelated-property.ts | 2 + .../tests/fixtures/static-options/barrel.ts | 1 + .../tests/fixtures/static-options/cycle-a.ts | 2 + .../tests/fixtures/static-options/cycle-b.ts | 2 + .../fixtures/static-options/definition.ts | 12 + .../static-options/dynamic-definition.ts | 2 + .../tests/fixtures/static-options/invalid.ts | 15 + .../fixtures/static-options/overrides.ts | 7 + .../fixtures/static-options/re-export.ts | 3 + .../fixtures/static-options/resource.svg | 1 + .../tests/fixtures/static-options/settings.ts | 10 + .../tests/fixtures/static-options/values.ts | 19 + .../tests/fixtures/static-options/wrapped.ts | 5 + src/cli/entrypoint/file/types.ts | 24 +- src/cli/entrypoint/parser/AbstractParser.ts | 14 +- .../entrypoint/parser/ContentParser.test.ts | 107 +++- src/cli/entrypoint/parser/ContentParser.ts | 181 ++---- src/cli/entrypoint/parser/RelayParser.test.ts | 23 +- .../invalid/default-render/Panel.content.tsx | 9 + .../anonymous-function.content.tsx | 3 +- .../invalid/default-render/arrow.content.tsx | 3 +- .../default-render/element-object.content.ts | 3 + .../imported-component.content.tsx | 4 +- .../invalid/default-render/jsx.content.tsx | 4 +- .../local-component.content.tsx | 3 +- .../default-render/named-function.content.tsx | 3 +- .../reexported-component.content.tsx | 4 +- .../content/invalid/frame-auto.content.ts | 2 +- .../content/invalid/frame-conflict.content.ts | 2 +- .../invalid/frame-dynamic-page.content.ts | 2 +- .../invalid/frame-dynamic-src.content.ts | 2 +- .../invalid/frame-identifier-page.content.ts | 2 +- .../content/invalid/frame-render.content.ts | 3 +- .../content/invalid/frame-spread.content.ts | 2 +- .../frame-without-isolation.content.ts | 2 - .../invalid/isolation-missing-type.content.ts | 2 + .../invalid/isolation-none-options.content.ts | 2 + .../invalid/isolation-type-dynamic.content.ts | 2 + .../isolation-type-identifier.content.ts | 3 + .../content/invalid/shadow/dynamic.content.ts | 2 + .../content/invalid/shadow/frame.content.ts | 2 + .../invalid/shadow/identifier.content.ts | 3 + .../content/invalid/shadow/iframe.content.ts | 2 + .../content/invalid/shadow/mode.content.ts | 2 + .../content/invalid/shadow/unknown.content.ts | 2 + .../content/invalid/shadow/unresolved-mode.ts | 1 + .../invalid/unresolved-isolation-type.ts | 1 + .../default-render/imported-object.content.ts | 4 +- .../default-render/local-object.content.ts | 3 +- .../default-render/named-marker.content.ts | 2 + .../options/default-render/object.content.ts | 2 +- .../isolation/frame-constant.content.ts | 4 +- .../options/isolation/iframe-short.content.ts | 2 + .../options/isolation/iframe.content.ts | 3 +- .../options/isolation/none-object.content.ts | 2 + .../content/options/isolation/page.content.ts | 2 +- .../options/isolation/source.content.ts | 10 + .../options/shadow/constant.content.ts | 3 + .../content/options/shadow/empty.content.ts | 2 + .../content/options/shadow/enum.content.ts | 5 + .../options/shadow/imported.content.ts | 3 + .../fixtures/content/options/shadow/mode.ts | 1 + .../content/options/shadow/open.content.ts | 2 + .../content/options/shadow/spread.content.ts | 3 + .../content/options/shadow/string.content.ts | 1 + .../relay/invalid/dynamic-isolation.relay.ts | 3 + .../relay/invalid/frame-render.relay.ts | 4 +- .../fixtures/relay/invalid/shadow.relay.ts | 2 + .../options/default-init/function.relay.ts | 3 +- .../options/default-init/imported.relay.ts | 4 +- .../default-init/named-render.relay.ts | 3 +- .../relay/options/isolation/imported.relay.ts | 4 + .../fixtures/relay/options/isolation/relay.ts | 3 +- .../relay/options/isolation/settings.ts | 6 + .../relay/options/isolation/shadow.relay.ts | 6 + src/cli/plugins/content/Content.ts | 2 +- .../plugins/content/ContentManager.test.ts | 50 +- src/cli/plugins/content/ContentManager.ts | 8 +- src/cli/plugins/content/ContentName.ts | 2 +- src/cli/plugins/content/Relay.ts | 2 +- src/cli/plugins/content/bundler.test.ts | 34 +- src/cli/plugins/content/bundler.ts | 8 +- src/cli/plugins/content/index.ts | 7 +- src/cli/plugins/content/utils.test.ts | 19 +- src/cli/plugins/content/utils.ts | 4 +- src/cli/virtual/virtual.d.ts | 1 - src/entry/content/README.md | 71 ++- src/entry/content/adapters/react/Node.test.ts | 54 +- .../content/adapters/vanilla/Node.test.ts | 46 +- src/entry/content/core/Builder.ts | 9 +- src/entry/content/core/MountBuilder.ts | 10 +- .../content/core/nodes/FrameNode.test.ts | 6 +- src/entry/content/core/nodes/FrameNode.ts | 6 +- .../content/core/nodes/ShadowNode.test.ts | 142 +++-- src/entry/content/core/nodes/ShadowNode.ts | 25 +- src/entry/relay/Builder.ts | 7 +- src/shared/content/index.ts | 2 +- src/shared/content/isolation.test.ts | 69 ++- src/shared/content/isolation.ts | 90 ++- src/types/content.ts | 74 ++- src/types/relay.ts | 5 +- .../content/frame-pages/src/contract.ts | 65 ++- .../content/frame-pages/src/page.content.ts | 3 +- .../content/frame-pages/src/source.content.ts | 3 +- .../browser/content/isolated-styles-utils.ts | 48 ++ .../content/isolation-shadow/README.md | 7 +- .../src/secondary.content/index.ts | 27 +- .../content/isolation-spike/src/probe.tsx | 6 +- .../content/relay-styles.integration.test.ts | 18 +- .../browser/content/relay-styles/README.md | 4 +- .../content/relay-styles/src/control.relay.ts | 3 +- .../content/relay-styles/src/iframe.relay.ts | 13 +- .../content/relay-styles/src/shadow.relay.ts | 13 +- .../content/relay-styles/src/shared/panel.ts | 2 + .../isolation-watch.integration.test.ts | 5 +- .../build/isolation/states/iframe.ts | 3 +- .../build/isolation/states/page-isolated.ts | 3 +- .../build/isolation/states/page.ts | 3 +- .../build/isolation/states/renamed.ts | 3 +- .../build/isolation/states/shadow-closed.ts | 10 + .../build/isolation/states/source.ts | 3 +- .../relay-runtime.integration.test.ts | 2 + 153 files changed, 1768 insertions(+), 845 deletions(-) create mode 100644 src/cli/entrypoint/file/parsers/ExportParser.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/Handler.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/aliased-function.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/arrow.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/empty-string.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/function.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/imported-function.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/imported-object.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/jsx.tsx create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/local-function.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/named-only.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/number.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/object.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/reexported-function.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/string.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/tsconfig.json create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/type-only.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/default-exports/unrelated-property.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/static-options/barrel.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/static-options/cycle-a.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/static-options/cycle-b.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/static-options/definition.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/static-options/dynamic-definition.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/static-options/invalid.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/static-options/overrides.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/static-options/re-export.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/static-options/resource.svg create mode 100644 src/cli/entrypoint/file/tests/fixtures/static-options/settings.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/static-options/values.ts create mode 100644 src/cli/entrypoint/file/tests/fixtures/static-options/wrapped.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/Panel.content.tsx create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/element-object.content.ts delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-without-isolation.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-missing-type.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-none-options.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-type-dynamic.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-type-identifier.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/dynamic.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/frame.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/identifier.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/iframe.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/mode.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/unknown.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/unresolved-mode.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/invalid/unresolved-isolation-type.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/named-marker.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe-short.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/none-object.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/source.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/constant.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/empty.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/enum.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/imported.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/mode.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/open.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/spread.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/string.content.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/relay/invalid/dynamic-isolation.relay.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/relay/invalid/shadow.relay.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/imported.relay.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/settings.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/shadow.relay.ts create mode 100644 tests/integration/build/isolation/states/shadow-closed.ts diff --git a/AGENTS.md b/AGENTS.md index 349a17d2..61d987cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ - `src/shared` owns pure implementation helpers shared by CLI and extension runtime, grouped by domain (for example, `shared/content`). Such helpers may use domain contracts, but must not import CLI/runtime implementations or depend on DOM state. Keep them separate from public exports and declaration-only contracts. - Generated, augmentable contracts follow the same rule: for example, `PageAliasRegistry`, `PageAlias`, and `PageMap` belong in `types/page.ts` and are re-exported through `main/page.ts`. Preserve public module augmentation and verify both public and internal consumers when moving them. - `src/cli/entrypoint` owns discovery, parsing, naming, and normalized entrypoint descriptions. Reuse its finders instead of reaching into a feature plugin for the same information. +- `src/cli/entrypoint/parser` owns entrypoint schemas, defaults, and interpretation of parsed data. These classes, including `AbstractParser`, configure file readers but must not traverse TypeScript AST or construct `Program`/`TypeChecker` instances. Keep source analysis in `file` and its specialized `file/parsers`; return plain metadata so each entrypoint decides what an export means. - `src/cli/plugins` owns framework feature policy and composition: interpreting entrypoint options, configuring the bundler, supplying normalized data, and declaring manifest requirements. - `src/cli/bundler/plugins` owns Rspack integration: compiler hooks, runtime modules, emitted assets, and generic build validation. A class using Rspack hooks belongs here even when only one framework feature currently uses it. - `src/entry` and the other extension runtime modules own execution, rendering adapters, and lifecycle behavior. Build code must not import runtime implementations to obtain constants, types, or helpers. @@ -20,9 +21,9 @@ ### Dependencies between plugins -- A framework feature plugin must not import or instantiate another feature plugin's manager or implementation. For example, content uses `PageFinder` from `@cli/entrypoint`, not `Page` from `plugins/page`, to resolve `frame.page` aliases. +- A framework feature plugin must not import or instantiate another feature plugin's manager or implementation. For example, content uses `PageFinder` from `@cli/entrypoint`, not `Page` from `plugins/page`, to resolve `isolation.page` aliases. - Framework plugins configure Rspack plugins by passing data, selectors, or callbacks with explicit contracts. Do not pass a manager instance, including a disguised dependency such as `Pick`. -- Give Rspack plugins names and options that describe their build responsibility, not their current caller. They should not know about content options such as `frame.page`, page aliases, or specific managers when they only need filenames and access requirements. +- Give Rspack plugins names and options that describe their build responsibility, not their current caller. They should not know about content options such as `isolation.page`, page aliases, or specific managers when they only need filenames and access requirements. - Caller-specific policy and diagnostic context belong to the framework plugin. Pass a validation callback, issuer, or hint instead of hardcoding the caller's options and error explanation inside a generic Rspack plugin. - Rspack plugins must not import sibling plugin implementations or their private helpers. Extract a genuinely shared contract or algorithm below both consumers; do not hide the dependency behind a barrel re-export. - Use `bundler/plugins/utils` and `bundler/plugins/types.ts` for shared plugin infrastructure such as compilation hooks and option contracts. Use `bundler/utils` for general asset graph, classification, and filename utilities. Keep feature-private helpers inside their owning plugin. @@ -46,6 +47,7 @@ ### Refactoring checks +- For experimental APIs that have not been released and are still being designed in the active branch, replace superseded contracts outright. Do not retain compatibility aliases, migration guards, obsolete-contract tests or migration documentation unless explicitly requested. Keep validation and tests for the currently agreed contract. - Before adding an import or moving a symbol, identify who owns the behavior, who needs the data, and which dependency direction follows from that ownership. A directory move alone does not fix a dependency if the original implementation is still imported through a helper or re-export. - When changing a shared boundary, update declarations, consumers, templates, tests, and documentation together. Treat removal of public metadata as an API change, not a cosmetic cleanup. - Preserve behavior while separating responsibilities: public exports, generated declarations, loading order, lifecycle, user filename templates, manifest/WAR semantics, and watch updates. Verify at the owning layer and use existing integration tests for cross-layer guarantees. diff --git a/src/cli/entrypoint/file/ExpressionFile.test.ts b/src/cli/entrypoint/file/ExpressionFile.test.ts index ad4b0c32..fddafaef 100644 --- a/src/cli/entrypoint/file/ExpressionFile.test.ts +++ b/src/cli/entrypoint/file/ExpressionFile.test.ts @@ -1,10 +1,53 @@ import path from "path"; -jest.mock("./resolvers", () => jest.requireActual("./resolvers/tests/resolvers.mock")); import ExpressionFile from "./ExpressionFile"; +import {ImportResolver, TsResolver} from "./resolvers"; const fixtures = path.resolve(__dirname, "tests", "fixtures", "expression"); describe("ExpressionFile", () => { + describe("Default export descriptions", () => { + const describeExport = (name: string) => + ExpressionFile.make(path.join(fixtures, "default-exports", name)).getDefaultExport(); + + test.each([ + ["function.ts", "function"], + ["arrow.ts", "function"], + ["Handler.ts", "class"], + ["jsx.tsx", "jsx"], + ["local-function.ts", "function"], + ["imported-function.ts", "function"], + ["reexported-function.ts", "function"], + ])("describes %s without entrypoint-specific semantics", (name, kind) => { + expect(describeExport(name)?.kind).toBe(kind); + }); + + test.each(["object.ts", "imported-object.ts"])("describes the properties of %s", name => { + expect(describeExport(name)).toEqual({kind: "object", properties: ["enabled", "handler"]}); + }); + + test("does not include named exports in the default object's properties", () => { + expect(describeExport("unrelated-property.ts")).toEqual({kind: "object", properties: ["enabled"]}); + }); + + test.each([ + ["string.ts", "string", "panel"], + ["empty-string.ts", "string", ""], + ["number.ts", "number", 0], + ])("retains primitive values from %s", (name, kind, value) => { + expect(describeExport(name)).toEqual({kind, properties: [], value}); + }); + + test.each(["named-only.ts", "type-only.ts"])("does not invent a runtime default export in %s", name => { + expect(describeExport(name)).toBeUndefined(); + }); + + test("resolves export aliases using the configured project's TypeScript options", () => { + const root = path.join(fixtures, "default-exports"); + const file = ExpressionFile.make(path.join(root, "aliased-function.ts")); + file.setImportResolver(new ImportResolver(TsResolver.make(path.join(root, "tsconfig.json")))); + expect(file.getDefaultExport()?.kind).toBe("function"); + }); + }); describe("Class Exports", () => { describe("Basic Class Factories", () => { test("class factory function returning class instance", () => { diff --git a/src/cli/entrypoint/file/ExpressionFile.ts b/src/cli/entrypoint/file/ExpressionFile.ts index 6e5c6690..29fd3c03 100644 --- a/src/cli/entrypoint/file/ExpressionFile.ts +++ b/src/cli/entrypoint/file/ExpressionFile.ts @@ -3,6 +3,7 @@ import ts from "typescript"; import SourceFile from "./SourceFile"; import { ClassParser, + ExportParser, FunctionParser, JSDocParser, NodeFinder, @@ -10,7 +11,7 @@ import { SignatureBuilder, TypeResolver, } from "./parsers"; -import {MemberSignature} from "./parsers/types"; +import type {ExportValue, MemberSignature} from "./parsers/types"; import {PackageName} from "@typing/app"; @@ -32,6 +33,7 @@ export default class ExpressionFile extends SourceFile { private readonly objectParser: ObjectParser; private readonly classParser: ClassParser; private readonly jsDocParser: JSDocParser; + private readonly exportParser: ExportParser; constructor(filePath: string) { super(filePath); @@ -43,6 +45,16 @@ export default class ExpressionFile extends SourceFile { this.classParser = new ClassParser(this, this.typeResolver, this.signatureBuilder, this.nodeFinder); this.functionParser = new FunctionParser(this, this.nodeFinder, this.objectParser, this.classParser); this.jsDocParser = new JSDocParser(); + this.exportParser = new ExportParser( + this, + this.nodeFinder, + () => this.getInputResolver().getTs().getConfig().options + ); + } + + /** Returns export metadata; consumers decide what that export means for their entrypoint. */ + public getDefaultExport(): ExportValue | undefined { + return this.exportParser.parseDefault(); } public getType(): string | undefined { diff --git a/src/cli/entrypoint/file/OptionFile.test.ts b/src/cli/entrypoint/file/OptionFile.test.ts index 75e723af..9b85fc9c 100644 --- a/src/cli/entrypoint/file/OptionFile.test.ts +++ b/src/cli/entrypoint/file/OptionFile.test.ts @@ -1 +1,88 @@ import "./tests/background"; + +import path from "path"; +import OptionFile from "./OptionFile"; + +const fixtures = path.resolve(__dirname, "tests/fixtures/static-options"); +const read = (name: string, properties: string[]) => + OptionFile.make(path.join(fixtures, name + ".ts")) + .setDefinition("defineContentScript") + .setProperties(properties); + +describe("OptionFile static values", () => { + test("reads nested objects, enum injectors, imported constants and ordered spreads", () => { + expect(read("definition", ["isolation", "includeBrowser"]).getOptions()).toEqual({ + isolation: {type: "shadow", mode: "closed"}, + includeBrowser: ["firefox", "chrome"], + }); + }); + + test("selects build properties before reading runtime expressions, including spread properties", () => { + const file = read("definition", ["isolation"]); + expect(file.getDeclaredProperties()).toEqual(new Set(["isolation", "includeBrowser", "render", "main"])); + expect(file.getOptions()).toEqual({isolation: {type: "shadow", mode: "closed"}}); + }); + + test("reads imported objects and literal computed keys without losing false, zero or undefined", () => { + expect(read("values", ["settings"]).getOptions()).toEqual({ + settings: {enabled: false, count: 0, label: "", empty: null, missing: undefined, sizes: [-1, 0, 320]}, + }); + }); + + test("resolves local and imported enum members and framework import aliases", () => { + expect(read("values", ["enums"]).getOptions()).toEqual({ + enums: {first: 0, next: 1, named: "closed", world: "ISOLATED", mode: "closed"}, + }); + }); + + test("follows named re-exports without evaluating other exports", () => { + expect(read("re-export", ["mode", "count"]).getOptions()).toEqual({mode: "closed", count: 1}); + }); + + test("does not reinterpret literal strings as local identifiers", () => { + expect(read("values", ["literal"]).getOptions()).toEqual({literal: "shadow"}); + }); + + test("reads a selected object member without evaluating its runtime siblings", () => { + expect(read("values", ["selected"]).getOptions()).toEqual({selected: 320}); + }); + + test("reads a static object passed to the definition helper through TypeScript wrappers", () => { + const file = read("wrapped", ["isolation"]); + expect(file.getOptions()).toEqual({isolation: {type: "shadow", mode: "closed"}}); + expect(file.getDefinition()).toBe("defineContentScript"); + }); + + test("rejects a dynamic definition argument instead of silently ignoring it", () => { + expect(() => read("dynamic-definition", ["isolation"]).getOptions()).toThrow( + /options must be statically known/ + ); + }); + + test("detects cycles across imported files", () => { + expect(() => read("cycle-a", ["options"]).getOptions()).toThrow( + /options must be statically known: circular reference/ + ); + }); + + test("uses the last property declaration without evaluating overwritten expressions", () => { + expect(read("overrides", ["isolation"]).getOptions()).toEqual({isolation: {type: "shadow", mode: "closed"}}); + }); + + test.each([ + ["unresolved", "unresolved.type"], + ["dynamic", "dynamic.page"], + ["spread", "spread"], + ["computed", "computed"], + ["method", "method.value"], + ["cycle", "cycle"], + ["asset", "asset"], + ["unknownEnum", "unknownEnum"], + ["mutable", "mutable"], + ["destructured", "destructured"], + ])("rejects %s instead of producing partial options", (property, field) => { + const file = read("invalid", [property]); + expect(() => file.getOptions()).toThrow(field + " must be statically known"); + expect(() => file.getOptions()).toThrow(path.join(fixtures, "invalid.ts")); + }); +}); diff --git a/src/cli/entrypoint/file/OptionFile.ts b/src/cli/entrypoint/file/OptionFile.ts index 013c6c6d..79cbe290 100644 --- a/src/cli/entrypoint/file/OptionFile.ts +++ b/src/cli/entrypoint/file/OptionFile.ts @@ -2,6 +2,7 @@ import ts from "typescript"; import _ from "lodash"; import SourceFile from "./SourceFile"; +import type {SourceBinding, SourceReadResult} from "./types"; import {PackageName} from "@typing/app"; @@ -29,123 +30,60 @@ export default class> extends SourceFile { } public getOptions(): T { - return {...this.getOptionsFromVariables(), ...this.getOptionsFromDefinition()}; - } - - /** Presence is distinct from a statically evaluated value (for example a render method). */ - public getDeclaredProperties(): Set { - const names = new Set( - Array.from(this.getVariables().values()) - .filter(item => item.exported) - .map(item => item.name) - ); - for (const statement of this.getSourceFile().statements) { - if ( - ts.isFunctionDeclaration(statement) && - statement.name && - statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) && - !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword) - ) { - names.add(statement.name.text); - } - if (!ts.isExportAssignment(statement)) continue; - const expression = this.getExpressionForOptions(statement.expression); - if (!expression || !ts.isObjectLiteralExpression(expression)) continue; - for (const property of expression.properties) { - if (property.name && !ts.isComputedPropertyName(property.name)) - names.add(property.name.getText().replace(/^["']|["']$/g, "")); - } + const options: [string, unknown][] = []; + for (const [name, binding] of this.getOptionBindings()) { + if (!this.properties.has(name)) continue; + const value = this.requireValue(binding.source.parseNode(binding.node, name)); + options.push([name, value]); } - return names; + return Object.fromEntries(options) as T; } - /** Returns the authored expression without confusing unresolved identifiers with string literals. */ - public getPropertyExpression(name: string): ts.Expression | undefined { - let result: ts.Expression | undefined; - for (const statement of this.getSourceFile().statements) { - if ( - ts.isVariableStatement(statement) && - statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) - ) { - const declaration = statement.declarationList.declarations.find(item => item.name.getText() === name); - if (declaration) result = declaration.initializer; - } - if (!ts.isExportAssignment(statement)) continue; - const expression = this.getExpressionForOptions(statement.expression); - if (!expression || !ts.isObjectLiteralExpression(expression)) continue; - const property = expression.properties.find( - item => - item.name && - !ts.isComputedPropertyName(item.name) && - item.name.getText().replace(/^["']|["']$/g, "") === name - ); - if (property && ts.isPropertyAssignment(property)) result = property.initializer; - else if (property && ts.isShorthandPropertyAssignment(property)) result = property.name; - } - const seen = new Set(); - while (result && ts.isIdentifier(result) && !seen.has(result.text)) { - const name = result.text; - seen.add(name); - const declaration = this.getSourceFile() - .statements.filter(ts.isVariableStatement) - .flatMap(statement => Array.from(statement.declarationList.declarations)) - .find(item => item.name.getText() === name); - if (!declaration?.initializer) break; - result = declaration.initializer; - } - return result; + /** Presence does not require evaluating the property's value (for example a render method). */ + public getDeclaredProperties(): Set { + return new Set(this.getOptionBindings().keys()); } public getDefinition(): string | undefined { - if (this.definition === undefined) { - this.getOptionsFromDefinition(); - } - - if (this.definition === null) { - return; - } - - return this.definition; + if (this.definition === undefined) this.getOptionBindings(); + return this.definition ?? undefined; } - protected getOptionsFromVariables(): T { - const options = Array.from(this.getVariables().values()) - .filter(({name, exported}) => exported && this.properties.has(name)) - .reduce((config, {name, value}) => ({...config, [name]: value}), {}); - - return options as T; + private requireValue(result: SourceReadResult): V { + if (result.resolved) return result.value; + const origin = result.file === this.file ? "" : ` (declared in "${result.file}")`; + throw new Error( + `Invalid options in "${this.file}": ${result.path || "options"} must be statically known: ${result.reason}${origin}` + ); } - protected getOptionsFromDefinition(): T { - let options = {} as T; - - const parse = (node: ts.Node) => { - if (ts.isExportAssignment(node)) { - const expr = this.getExpressionForOptions(node.expression); - - if (expr) { - options = this.parseNode(expr); - } - } - - ts.forEachChild(node, parse); - }; - - parse(this.getSourceFile()); - - return _.pickBy(options, (_, key) => this.properties.has(key)) as T; + private getOptionBindings(): Map { + const bindings = this.getBindings(true); + bindings.delete("default"); + for (const statement of this.getSourceFile().statements) { + if (!ts.isExportAssignment(statement)) continue; + const expression = this.getExpressionForOptions(statement.expression); + if (!expression) continue; + const properties = this.requireValue(this.getProperties(expression)); + for (const [name, binding] of properties) bindings.set(name, binding); + } + return bindings; } protected getExpressionForOptions(expr: ts.Expression): ts.Expression | undefined { this.definition = null; + while ( + ts.isAsExpression(expr) || + ts.isSatisfiesExpression(expr) || + ts.isParenthesizedExpression(expr) || + ts.isTypeAssertionExpression(expr) + ) { + expr = expr.expression; + } + if (ts.isObjectLiteralExpression(expr)) { return expr; - } else if ( - (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) && - ts.isObjectLiteralExpression(expr.expression) - ) { - return expr.expression; } else if (ts.isCallExpression(expr) && ts.isIdentifier(expr.expression)) { const functionName = expr.expression.text; @@ -156,13 +94,8 @@ export default class> extends SourceFile { } if (this.definitions.has(functionName) && expr.arguments.length > 0) { - const arg = expr.arguments[0]; - - if (ts.isObjectLiteralExpression(arg)) { - this.definition = functionName; - - return arg; - } + this.definition = functionName; + return expr.arguments[0]; } } } diff --git a/src/cli/entrypoint/file/SourceFile.ts b/src/cli/entrypoint/file/SourceFile.ts index 8103a7b2..0b964099 100644 --- a/src/cli/entrypoint/file/SourceFile.ts +++ b/src/cli/entrypoint/file/SourceFile.ts @@ -3,36 +3,36 @@ import fs from "fs"; import path from "path"; import {ImportResolver} from "./resolvers"; - import injectors from "./injectors"; - -import {EnumMap, ImportMap, Variable, VariableMap} from "./types"; - -export default class EntryFile { +import type { + ImportMap, + VariableMap, + SourceValue, + SourceReadResult, + SourceBinding, + SourceReadContext, + SourceResolvedBinding, +} from "./types"; + +export default class SourceFile { private sourceFile?: ts.SourceFile; - private variables?: VariableMap; - private imports?: ImportMap; - - private enums?: EnumMap; - + private sources = new Map(); protected importResolver?: ImportResolver; - static make(this: new (file: string) => T, file: string): T { + static make(this: new (file: string) => T, file: string): T { return new this(file); } constructor(protected readonly file: string) {} public getSourceFile(): ts.SourceFile { - if (this.sourceFile) { - return this.sourceFile; + if (!this.sourceFile) { + const sourceCode = fs.readFileSync(this.file, "utf8"); + this.sourceFile = ts.createSourceFile(this.file, sourceCode, ts.ScriptTarget.ESNext, true); } - - const sourceCode = fs.readFileSync(this.file, "utf8"); - - return (this.sourceFile = ts.createSourceFile(this.file, sourceCode, ts.ScriptTarget.ESNext, true)); + return this.sourceFile; } public getImports(): ImportMap { @@ -56,6 +56,12 @@ export default class EntryFile { this.imports?.set(el.name.text, this.getInputResolver().get(importPath)); }); } + if (node.importClause.namedBindings && ts.isNamespaceImport(node.importClause.namedBindings)) { + this.imports?.set( + node.importClause.namedBindings.name.text, + this.getInputResolver().get(importPath) + ); + } } } // import equals: import X = Y.Z @@ -81,96 +87,68 @@ export default class EntryFile { return this.imports; } - public getEnums(): EnumMap { - if (this.enums) { - return this.enums; - } - - this.enums = new Map(); - - const parse = (node: ts.Node) => { - if (ts.isEnumDeclaration(node)) { - const enumName = node.name.text; - const enumProperties: Map = new Map(); - - node.members.forEach(member => { - const key = member.name.getText(); - const value = member.initializer ? member.initializer.getText().replace(/['"]/g, "") : key; - - enumProperties.set(key, value); - }); - - this.enums?.set(enumName, enumProperties); - } - - ts.forEachChild(node, parse); - }; - - parse(this.getSourceFile()); - - return this.enums; - } - - public getVariables(): VariableMap { - if (this.variables) { - return this.variables; - } - - this.variables = new Map(); - - const parse = (node: ts.Node) => { - if (ts.isVariableStatement(node)) { - const isExported = node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword); - - node.declarationList.declarations.forEach(declaration => { - if (ts.isIdentifier(declaration.name) && declaration.initializer) { - const name = declaration.name.text; - const value = this.parseNode(declaration.initializer); - - this.variables?.set(name, { - name, - value, - exported: isExported || false, - }); + /** Collect declarations without evaluating runtime functions or unrelated initializers. */ + protected getBindings(exportedOnly = false): Map { + const bindings = new Map(); + const bind = (name: string, node: ts.Node) => bindings.set(name, {source: this, node}); + for (const statement of this.getSourceFile().statements) { + const exported = + ts.canHaveModifiers(statement) && + ts.getModifiers(statement)?.some(item => item.kind === ts.SyntaxKind.ExportKeyword); + if (ts.isVariableStatement(statement) && (!exportedOnly || exported)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) bind(declaration.name.text, declaration); + else { + const collect = (node: ts.Node) => { + if (ts.isBindingElement(node) && ts.isIdentifier(node.name)) bind(node.name.text, node); + else ts.forEachChild(node, collect); + }; + collect(declaration.name); } - }); + } + } else if ( + (ts.isEnumDeclaration(statement) || + ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement)) && + (!exportedOnly || exported) && + statement.name + ) { + const isDefault = ts.getModifiers(statement)?.some(item => item.kind === ts.SyntaxKind.DefaultKeyword); + bind(exportedOnly && isDefault ? "default" : statement.name.text, statement); } - - if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) { - node.exportClause.elements.forEach(el => { - const name = el.name.text; - - if (this.variables?.has(name)) { - const variable = this.variables?.get(name) as Variable; - - this.variables?.set(name, { - name, - value: variable.value, - exported: true, - }); - } - }); + if (exportedOnly && ts.isExportAssignment(statement) && !statement.isExportEquals) { + bind("default", statement.expression); } - - if (ts.isExportAssignment(node)) { - const expr = node.expression; - if (ts.isIdentifier(expr) && this.variables?.has(expr.text)) { - const variable = this.variables?.get(expr.text) as Variable; - const name = "default"; - - this.variables?.set(name, { - name, - value: variable.value, - exported: true, - }); + if ( + exportedOnly && + ts.isExportDeclaration(statement) && + !statement.isTypeOnly && + statement.exportClause && + ts.isNamedExports(statement.exportClause) + ) { + for (const item of statement.exportClause.elements) { + if (!item.isTypeOnly) + bind(item.name.text, statement.moduleSpecifier ? item : (item.propertyName ?? item.name)); } } + } + return bindings; + } - ts.forEachChild(node, parse); - }; - - parse(this.getSourceFile()); - + public getVariables(): VariableMap { + if (this.variables) return this.variables; + const locals = this.getBindings(); + const exported = this.getBindings(true); + this.variables = new Map(); + for (const [name, binding] of new Map([...locals, ...exported])) { + const result = this.parseNode(binding.node, name); + // Signature inference can use known values without requiring executable code to be static. + this.variables.set(name, { + name, + value: result.resolved ? result.value : undefined, + exported: exported.has(name), + }); + } return this.variables; } @@ -178,160 +156,260 @@ export default class EntryFile { return injectors([])(from, target, name); } - protected findPropertyAccessValue(property: ts.Node): any { - if (ts.isPropertyAccessExpression(property)) { - const objectName = property.expression.getText(); - const propertyName = property.name.getText(); - - const varItem = this.getVariables().get(objectName); - - if (varItem) { - return varItem.value; + private getImportBinding(name: string): {from: string; name: string} | undefined { + for (const statement of this.getSourceFile().statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue; + const clause = statement.importClause; + const from = statement.moduleSpecifier.text; + if (clause?.name?.text === name) return {from, name: "default"}; + if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) { + const item = clause.namedBindings.elements.find(item => item.name.text === name); + if (item) return {from, name: (item.propertyName ?? item.name).text}; } + if ( + clause?.namedBindings && + ts.isNamespaceImport(clause.namedBindings) && + clause.namedBindings.name.text === name + ) + return {from, name: "*"}; + } + } + + private getImportedSource(from: string): SourceFile | undefined { + const filename = this.getInputResolver().get(from); + if (!filename || !path.isAbsolute(filename) || !/\.[cm]?[jt]sx?$/.test(filename) || !fs.existsSync(filename)) + return; + let source = this.sources.get(filename); + if (!source) { + source = new SourceFile(filename); + source.setImportResolver(this.getInputResolver()); + source.sources = this.sources; + this.sources.set(filename, source); + } + return source; + } - const enumItem = this.getEnums().get(objectName); + private failure(context: SourceReadContext, reason: string): SourceReadResult { + return {resolved: false, file: this.file, path: context.path, reason}; + } - if (enumItem) { - return enumItem.get(propertyName); + /** Follow static bindings, retaining their source module and detecting reference cycles. */ + private resolveNode( + node: ts.Node | undefined, + context: SourceReadContext + ): SourceReadResult { + if (!node) return this.failure(context, "missing initializer"); + const key = `${this.file}:${node.pos}:${node.end}`; + if (context.seen.has(key)) return this.failure(context, "circular reference"); + context = {...context, seen: new Set([...context.seen, key])}; + if ( + ts.isAsExpression(node) || + ts.isTypeAssertionExpression(node) || + ts.isSatisfiesExpression(node) || + ts.isParenthesizedExpression(node) || + ts.isNonNullExpression(node) + ) { + return this.resolveNode(node.expression, context); + } + if (ts.isVariableDeclaration(node)) { + if (!ts.isVariableDeclarationList(node.parent) || !(node.parent.flags & ts.NodeFlags.Const)) + return this.failure(context, "use a const declaration for build options"); + return this.resolveNode(node.initializer, context); + } + if (ts.isExportSpecifier(node) && ts.isExportDeclaration(node.parent.parent)) { + const from = node.parent.parent.moduleSpecifier; + const name = (node.propertyName ?? node.name).text; + const binding = + from && ts.isStringLiteral(from) + ? this.getImportedSource(from.text)?.getBindings(true).get(name) + : undefined; + if (binding) return binding.source.resolveNode(binding.node, context); + return this.failure(context, `cannot resolve re-exported value ${name}`); + } + if (ts.isIdentifier(node)) { + const local = this.getBindings().get(node.text); + if (local) return local.source.resolveNode(local.node, context); + const imported = this.getImportBinding(node.text); + if (imported) { + const source = this.getImportedSource(imported.from); + const binding = source?.getBindings(true).get(imported.name); + if (binding) return binding.source.resolveNode(binding.node, context); + return this.failure(context, `cannot resolve imported value ${node.text}`); } + if (node.text !== "undefined") return this.failure(context, `unresolved identifier ${node.text}`); + } + return {resolved: true, value: {source: this, node, context}}; + } - const importItem = this.getImports().get(objectName); + public parseNode(node?: ts.Node, property = ""): SourceReadResult { + return this.readValue(node, {path: property, seen: new Set()}); + } - if (importItem) { - const resolverItem = this.resolveValue(importItem, objectName, propertyName); + /** Read an object's shape first so callers can select build fields before evaluating values. */ + protected getProperties(node: ts.Node, property = ""): SourceReadResult> { + return this.readProperties(node, {path: property, seen: new Set()}); + } - if (resolverItem) { - return resolverItem; - } + private readProperties(node: ts.Node, context: SourceReadContext): SourceReadResult> { + const result = this.resolveNode(node, context); + if (!result.resolved) return result; + const binding = result.value; + if (!ts.isObjectLiteralExpression(binding.node)) + return binding.source.failure(binding.context, "expected a static object"); + return binding.source.objectProperties(binding.node, binding.context); + } - return EntryFile.make(importItem).findPropertyAccessValue(property); + private objectProperties( + node: ts.ObjectLiteralExpression, + context: SourceReadContext + ): SourceReadResult> { + const properties = new Map(); + for (const property of node.properties) { + if (ts.isSpreadAssignment(property)) { + const spread = this.readProperties(property.expression, context); + if (!spread.resolved) return spread; + for (const [key, binding] of spread.value) properties.set(key, binding); + continue; } - - return `${objectName}.${propertyName}`; + let key: string; + if (ts.isComputedPropertyName(property.name)) { + const result = this.readValue(property.name.expression, context); + if (!result.resolved) return result; + if (typeof result.value !== "string" && typeof result.value !== "number") + return this.failure(context, "object keys must be strings or numbers"); + key = String(result.value); + } else { + key = property.name.text; + } + properties.set(key, { + source: this, + node: ts.isPropertyAssignment(property) + ? property.initializer + : ts.isShorthandPropertyAssignment(property) + ? property.name + : property, + }); } + return {resolved: true, value: properties}; + } - return undefined; + private readValue(node: ts.Node | undefined, context: SourceReadContext): SourceReadResult { + const resolved = this.resolveNode(node, context); + if (!resolved.resolved) return resolved; + const binding = resolved.value; + return binding.source.readResolvedValue(binding.node, binding.context); } - public parseNode(node?: ts.Node): any { - if (!node) { - return undefined; + private readResolvedValue(node: ts.Node, context: SourceReadContext): SourceReadResult { + const success = (value: SourceValue): SourceReadResult => ({resolved: true, value}); + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return success(node.text); + if (ts.isNumericLiteral(node)) return success(Number(node.text)); + if (node.kind === ts.SyntaxKind.TrueKeyword) return success(true); + if (node.kind === ts.SyntaxKind.FalseKeyword) return success(false); + if (node.kind === ts.SyntaxKind.NullKeyword) return success(null); + if (ts.isIdentifier(node) && node.text === "undefined") return success(undefined); + if (ts.isPrefixUnaryExpression(node)) { + const operand = this.readValue(node.operand, context); + if (!operand.resolved) return operand; + if ( + typeof operand.value === "number" && + (node.operator === ts.SyntaxKind.MinusToken || node.operator === ts.SyntaxKind.PlusToken) + ) + return success(node.operator === ts.SyntaxKind.MinusToken ? -operand.value : operand.value); + return this.failure(context, "unsupported unary expression"); } - - switch (node.kind) { - case ts.SyntaxKind.StringLiteral: - return (node as ts.StringLiteral).text; - case ts.SyntaxKind.TrueKeyword: - return true; - case ts.SyntaxKind.FalseKeyword: - return false; - case ts.SyntaxKind.NullKeyword: - return null; - case ts.SyntaxKind.NumericLiteral: - return parseFloat((node as ts.NumericLiteral).text); - case ts.SyntaxKind.Identifier: { - const id = node as ts.Identifier; - const name = id.text; - // local variable - const local = this.variables?.get(name); - if (local && local.value !== undefined) { - return local.value; - } - // imported constant: try to resolve its value from source file - const importPath = this.getImports().get(name); - if (importPath) { - if (/\.[^.\\/]+$/.test(importPath) && !/\.[cm]?[jt]sx?$/.test(importPath)) { - return name; - } - - // load imported file and check its variables - const parser = EntryFile.make(importPath); - const imported = parser.getVariables().get(name); - if (imported && imported.value !== undefined) { - return imported.value; + if (ts.isArrayLiteralExpression(node)) { + const values: SourceValue[] = []; + for (const [index, element] of node.elements.entries()) { + const result = this.readValue(ts.isSpreadElement(element) ? element.expression : element, { + ...context, + path: `${context.path}[${index}]`, + }); + if (!result.resolved) return result; + if (ts.isSpreadElement(element)) { + if (!Array.isArray(result.value)) + return this.failure(context, "array spread must reference a static array"); + values.push(...result.value); + } else values.push(result.value); + } + return success(values); + } + if (ts.isObjectLiteralExpression(node)) { + const properties = this.objectProperties(node, context); + if (!properties.resolved) return properties; + const values: [string, SourceValue][] = []; + for (const [key, binding] of properties.value) { + const result = binding.source.readValue(binding.node, { + ...context, + path: context.path ? `${context.path}.${key}` : key, + }); + if (!result.resolved) return result; + values.push([key, result.value]); + } + return success(Object.fromEntries(values)); + } + if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { + const property = ts.isPropertyAccessExpression(node) + ? success(node.name.text) + : this.readValue(node.argumentExpression, context); + if (!property.resolved) return property; + if (typeof property.value !== "string" && typeof property.value !== "number") + return this.failure(context, "member name must be a string or number"); + const name = String(property.value); + if (ts.isIdentifier(node.expression) && !this.getBindings().has(node.expression.text)) { + const imported = this.getImportBinding(node.expression.text); + if (imported) { + const value = this.resolveValue(imported.from, imported.name, name); + if (value !== undefined) return success(value); + if (imported.name === "*") { + const binding = this.getImportedSource(imported.from)?.getBindings(true).get(name); + if (binding) return binding.source.readValue(binding.node, context); } } - return name; } - case ts.SyntaxKind.ArrayLiteralExpression: - return (node as ts.ArrayLiteralExpression).elements.map(element => this.parseNode(element)); - case ts.SyntaxKind.ObjectLiteralExpression: - return (node as ts.ObjectLiteralExpression).properties - .filter( - (property): property is ts.PropertyAssignment | ts.ShorthandPropertyAssignment => - ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property) - ) - .reduce( - (acc, property) => { - if (ts.isComputedPropertyName(property.name)) { - return acc; - } - - const key = (property.name as ts.Identifier | ts.StringLiteral).text; - - // Determine the initializer node: for shorthand, use the identifier itself - const initNode = ts.isShorthandPropertyAssignment(property) - ? (property.name as ts.Identifier) - : (property.initializer as ts.Expression | undefined); - - let value = this.parseNode(initNode); - - // If value is still just an identifier name, resolve from collected variables - if (typeof value === "string" && this.variables?.has(value)) { - value = this.variables?.get(value)?.value; - } - - acc[key] = value; - - return acc; - }, - {} as Record - ); - case ts.SyntaxKind.PropertyAccessExpression: { - return this.findPropertyAccessValue(node); + const resolved = this.resolveNode(node.expression, context); + if (!resolved.resolved) return resolved; + const binding = resolved.value; + if (ts.isObjectLiteralExpression(binding.node)) { + const properties = binding.source.objectProperties(binding.node, binding.context); + if (!properties.resolved) return properties; + const member = properties.value.get(name); + if (member) return member.source.readValue(member.node, binding.context); + return this.failure(context, `unknown member ${name}`); } - default: - return undefined; + const object = binding.source.readResolvedValue(binding.node, binding.context); + if (!object.resolved) return object; + if (object.value !== null && typeof object.value === "object" && Object.hasOwn(object.value, name)) + return success((object.value as Record)[name]); + return this.failure(context, `unknown member ${name}`); } - } - - /** Unlike parseNode's identifier fallback, proves a primitive comes from a static declaration. */ - public isStaticValue(node?: ts.Node, seen = new Set()): boolean { - if (!node) return false; - if ( - ts.isStringLiteral(node) || - ts.isNumericLiteral(node) || - node.kind === ts.SyntaxKind.TrueKeyword || - node.kind === ts.SyntaxKind.FalseKeyword - ) - return true; - if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node)) { - return this.isStaticValue(node.expression, seen); - } - if (ts.isPropertyAccessExpression(node)) { - const value = this.parseNode(node); - return (typeof value === "string" || typeof value === "number") && value !== node.getText(); - } - if (!ts.isIdentifier(node)) return false; - const key = `${this.file}:${node.text}`; - if (seen.has(key)) return false; - seen.add(key); - for (const statement of this.getSourceFile().statements) { - if (!ts.isVariableStatement(statement)) continue; - const declaration = statement.declarationList.declarations.find(item => item.name.getText() === node.text); - if (declaration) return this.isStaticValue(declaration.initializer, seen); + if (ts.isEnumDeclaration(node)) { + const values: Record = {}; + let previous: SourceValue = -1; + for (const member of node.members) { + const result = member.initializer + ? this.readValue(member.initializer, context) + : typeof previous === "number" + ? success(previous + 1) + : this.failure(context, "enum member needs a static initializer"); + if (!result.resolved) return result; + if (typeof result.value !== "string" && typeof result.value !== "number") + return this.failure(context, "enum values must be strings or numbers"); + if (ts.isComputedPropertyName(member.name)) + return this.failure(context, "computed enum names are not supported"); + values[member.name.text] = previous = result.value; + } + return success(values); } - const imported = this.getImports().get(node.text); - if (!imported || !/\.[cm]?[jt]sx?$/.test(imported)) return false; - return EntryFile.make(imported).isStaticValue(node, seen); + return this.failure( + context, + `unsupported ${ts.SyntaxKind[node.kind]}; use literals, constants or supported enum members` + ); } protected getInputResolver(): ImportResolver { - if (this.importResolver) { - return this.importResolver; - } - - return this.setImportResolver(new ImportResolver()); + if (!this.importResolver) this.importResolver = new ImportResolver(); + return this.importResolver.setBaseDir(path.dirname(this.file)); } public setImportResolver(resolver: ImportResolver): ImportResolver { diff --git a/src/cli/entrypoint/file/index.ts b/src/cli/entrypoint/file/index.ts index 2080709a..3cc82d5b 100644 --- a/src/cli/entrypoint/file/index.ts +++ b/src/cli/entrypoint/file/index.ts @@ -2,3 +2,5 @@ export {default as OptionFile} from "./OptionFile"; export {default as SourceFile} from "./SourceFile"; export {default as ExpressionFile} from "./ExpressionFile"; export * from "./resolvers"; +export {ExportValueKind} from "./parsers/types"; +export type {ExportValue} from "./parsers/types"; diff --git a/src/cli/entrypoint/file/injectors/core.ts b/src/cli/entrypoint/file/injectors/core.ts index bba2e82a..3dfce35e 100644 --- a/src/cli/entrypoint/file/injectors/core.ts +++ b/src/cli/entrypoint/file/injectors/core.ts @@ -3,6 +3,7 @@ import {Browser} from "@typing/browser"; import {RelayAllFrames, RelayMethod} from "@typing/relay"; import { ContentScriptIsolation, + ContentScriptShadowMode, ContentScriptAppend, ContentScriptDeclarative, ContentScriptMarker, @@ -75,6 +76,10 @@ export default (): Injector[] => { resolvers.push({from: PackageName, target: "ContentScriptIsolation", name: key, value}); }); + Object.entries(ContentScriptShadowMode).forEach(([key, value]) => { + resolvers.push({from: PackageName, target: "ContentScriptShadowMode", name: key, value}); + }); + Object.entries(ContentScriptWorld).forEach(([key, value]) => { resolvers.push({ from: PackageName, diff --git a/src/cli/entrypoint/file/parsers/ExportParser.ts b/src/cli/entrypoint/file/parsers/ExportParser.ts new file mode 100644 index 00000000..d5aee310 --- /dev/null +++ b/src/cli/entrypoint/file/parsers/ExportParser.ts @@ -0,0 +1,80 @@ +import ts from "typescript"; + +import SourceFile from "../SourceFile"; +import NodeFinder from "./NodeFinder"; +import {ExportValueKind, type ExportValue} from "./types"; + +/** Describes default exports without assigning them a renderer, initializer or configuration role. */ +export default class ExportParser { + constructor( + private readonly sourceFile: SourceFile, + private readonly nodeFinder: NodeFinder, + private readonly getCompilerOptions: () => ts.CompilerOptions + ) {} + + public parseDefault(): ExportValue | undefined { + const declaration = this.nodeFinder.findDefaultExport(); + if (!declaration) return undefined; + if (ts.isFunctionDeclaration(declaration)) return {kind: ExportValueKind.Function, properties: []}; + if (ts.isClassDeclaration(declaration)) return {kind: ExportValueKind.Class, properties: []}; + if (ts.isExportAssignment(declaration)) { + const value = this.describeExpression(declaration.expression); + if (value) return value; + } + return this.describeType(); + } + + private describeExpression(expression: ts.Expression): ExportValue | undefined { + while ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) || + ts.isTypeAssertionExpression(expression) || + ts.isNonNullExpression(expression) + ) { + expression = expression.expression; + } + if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) + return {kind: ExportValueKind.Function, properties: []}; + if (ts.isClassExpression(expression)) return {kind: ExportValueKind.Class, properties: []}; + if (ts.isJsxElement(expression) || ts.isJsxSelfClosingElement(expression) || ts.isJsxFragment(expression)) + return {kind: ExportValueKind.Jsx, properties: []}; + if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) + return {kind: ExportValueKind.String, properties: [], value: expression.text}; + if (ts.isNumericLiteral(expression)) + return {kind: ExportValueKind.Number, properties: [], value: Number(expression.text)}; + if (ts.isObjectLiteralExpression(expression)) { + const properties = new Set(); + for (const property of expression.properties) { + // Resolve spread/computed properties through the checker, without evaluating their values. + if (ts.isSpreadAssignment(property) || ts.isComputedPropertyName(property.name)) return undefined; + properties.add(property.name.text); + } + return {kind: ExportValueKind.Object, properties: [...properties]}; + } + return undefined; + } + + private describeType(): ExportValue | undefined { + const filename = this.sourceFile.getSourceFile().fileName; + // Keep the program local to this analysis: a watch rebuild must see changed imported exports. + const program = ts.createProgram([filename], {...this.getCompilerOptions(), noEmit: true}); + const entry = program.getSourceFile(filename); + if (!entry) return undefined; + const checker = program.getTypeChecker(); + const module = checker.getSymbolAtLocation(entry); + const exported = module && checker.getExportsOfModule(module).find(symbol => symbol.name === "default"); + if (!exported) return undefined; + const symbol = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported; + if (!(symbol.flags & ts.SymbolFlags.Value)) return undefined; + const type = checker.getTypeOfSymbolAtLocation(symbol, entry); + const properties = type.getProperties().map(property => property.name); + if (type.getCallSignatures().length > 0) return {kind: ExportValueKind.Function, properties}; + if (type.getConstructSignatures().length > 0) return {kind: ExportValueKind.Class, properties}; + if (type.flags & ts.TypeFlags.StringLike) + return {kind: ExportValueKind.String, properties, ...(type.isStringLiteral() ? {value: type.value} : {})}; + if (type.flags & ts.TypeFlags.NumberLike) + return {kind: ExportValueKind.Number, properties, ...(type.isNumberLiteral() ? {value: type.value} : {})}; + return {kind: type.flags & ts.TypeFlags.Object ? ExportValueKind.Object : ExportValueKind.Other, properties}; + } +} diff --git a/src/cli/entrypoint/file/parsers/NodeFinder.ts b/src/cli/entrypoint/file/parsers/NodeFinder.ts index 84f4bfcf..0e07322b 100644 --- a/src/cli/entrypoint/file/parsers/NodeFinder.ts +++ b/src/cli/entrypoint/file/parsers/NodeFinder.ts @@ -12,6 +12,23 @@ export default class NodeFinder { */ constructor(private sourceFile: SourceFile) {} + /** Finds default-export syntax without traversing nested scopes; skips explicitly type-only re-exports. */ + public findDefaultExport(): ts.Statement | undefined { + return this.sourceFile + .getSourceFile() + .statements.find( + statement => + (ts.isExportAssignment(statement) && !statement.isExportEquals) || + (ts.canHaveModifiers(statement) && + ts.getModifiers(statement)?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) || + (ts.isExportDeclaration(statement) && + !statement.isTypeOnly && + statement.exportClause && + ts.isNamedExports(statement.exportClause) && + statement.exportClause.elements.some(item => !item.isTypeOnly && item.name.text === "default")) + ); + } + /** * Generic method to find a node of a specific type by name in the AST. * diff --git a/src/cli/entrypoint/file/parsers/ObjectParser.ts b/src/cli/entrypoint/file/parsers/ObjectParser.ts index 5a16b6f4..654b3021 100644 --- a/src/cli/entrypoint/file/parsers/ObjectParser.ts +++ b/src/cli/entrypoint/file/parsers/ObjectParser.ts @@ -101,7 +101,12 @@ export default class ObjectParser extends AbstractParser { * @returns The inferred type as a string */ public inferTypeFromExpression(expr: ts.Expression): string { - const val = this.sourceFile.parseNode(expr); + // Shape inference does not require every member to be a statically known value. + if (ts.isObjectLiteralExpression(expr)) return "object"; + if (ts.isArrayLiteralExpression(expr)) return "array"; + const result = this.sourceFile.parseNode(expr); + if (!result.resolved) return "any"; + const val = result.value; switch (typeof val) { case "string": diff --git a/src/cli/entrypoint/file/parsers/index.ts b/src/cli/entrypoint/file/parsers/index.ts index 4bc364f4..d105b851 100644 --- a/src/cli/entrypoint/file/parsers/index.ts +++ b/src/cli/entrypoint/file/parsers/index.ts @@ -5,3 +5,4 @@ export {default as TypeResolver} from "./TypeResolver"; export {default as SignatureBuilder} from "./SignatureBuilder"; export {default as NodeFinder} from "./NodeFinder"; export {default as JSDocParser} from "./JSDocParser"; +export {default as ExportParser} from "./ExportParser"; diff --git a/src/cli/entrypoint/file/parsers/types.ts b/src/cli/entrypoint/file/parsers/types.ts index 90a88498..e4d842f1 100644 --- a/src/cli/entrypoint/file/parsers/types.ts +++ b/src/cli/entrypoint/file/parsers/types.ts @@ -30,3 +30,21 @@ export interface PropertySignature { * Member of an interface: either a method or a property. */ export type MemberSignature = MethodSignature | PropertySignature; + +export enum ExportValueKind { + Function = "function", + Class = "class", + Jsx = "jsx", + Object = "object", + String = "string", + Number = "number", + Other = "other", +} + +/** Source-level facts about an export, without TypeScript nodes or entrypoint-specific meaning. */ +export interface ExportValue { + readonly kind: ExportValueKind; + /** Property names identified statically, without evaluating the exported value. */ + readonly properties: readonly string[]; + readonly value?: string | number; +} diff --git a/src/cli/entrypoint/file/resolvers/ImportResolver.ts b/src/cli/entrypoint/file/resolvers/ImportResolver.ts index 6d33fd2d..3cf4aa99 100644 --- a/src/cli/entrypoint/file/resolvers/ImportResolver.ts +++ b/src/cli/entrypoint/file/resolvers/ImportResolver.ts @@ -20,6 +20,10 @@ export default class { return this; } + public getTs(): TsResolver { + return this.ts; + } + public setBaseDir(baseDir: string): this { this.baseDir = baseDir; diff --git a/src/cli/entrypoint/file/tests/background.ts b/src/cli/entrypoint/file/tests/background.ts index 4474ae6a..bd1b5b4d 100644 --- a/src/cli/entrypoint/file/tests/background.ts +++ b/src/cli/entrypoint/file/tests/background.ts @@ -1,8 +1,6 @@ import path from "path"; import OptionFile from "../OptionFile"; -jest.mock("../resolvers", () => jest.requireActual("../resolvers/tests/resolvers.mock")); - const fixtures = path.resolve(__dirname, "fixtures"); describe("background", () => { diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/Handler.ts b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/Handler.ts new file mode 100644 index 00000000..cb897cd7 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/Handler.ts @@ -0,0 +1,5 @@ +export default class Handler { + constructor() { + throw new Error("Export analysis must not instantiate classes"); + } +} diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/aliased-function.ts b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/aliased-function.ts new file mode 100644 index 00000000..07ef9823 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/aliased-function.ts @@ -0,0 +1 @@ +export {default} from "@export/function"; diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/arrow.ts b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/arrow.ts new file mode 100644 index 00000000..fc150354 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/arrow.ts @@ -0,0 +1 @@ +export default (() => null) satisfies () => unknown; diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/empty-string.ts b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/empty-string.ts new file mode 100644 index 00000000..9cf3c27e --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/empty-string.ts @@ -0,0 +1 @@ +export default ""; diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/function.ts b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/function.ts new file mode 100644 index 00000000..bb7c4213 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/function.ts @@ -0,0 +1,3 @@ +export default function handler() { + throw new Error("Export analysis must not execute this function"); +} diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/imported-function.ts b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/imported-function.ts new file mode 100644 index 00000000..34c6df0a --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/imported-function.ts @@ -0,0 +1,2 @@ +import handler from "./function"; +export default handler; diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/imported-object.ts b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/imported-object.ts new file mode 100644 index 00000000..ffd1ec61 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/imported-object.ts @@ -0,0 +1,2 @@ +import options from "./object"; +export default options; diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/jsx.tsx b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/jsx.tsx new file mode 100644 index 00000000..068ba8da --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/jsx.tsx @@ -0,0 +1,3 @@ +import React from "react"; + +export default
; diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/local-function.ts b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/local-function.ts new file mode 100644 index 00000000..b3d628a4 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/local-function.ts @@ -0,0 +1,2 @@ +const handler = () => null; +export default handler; diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/named-only.ts b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/named-only.ts new file mode 100644 index 00000000..cdcb27ee --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/named-only.ts @@ -0,0 +1 @@ +export const value = "named"; diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/number.ts b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/number.ts new file mode 100644 index 00000000..7f810d3f --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/number.ts @@ -0,0 +1 @@ +export default 0; diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/object.ts b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/object.ts new file mode 100644 index 00000000..a9a5a432 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/object.ts @@ -0,0 +1,6 @@ +export default { + enabled: true, + handler() { + throw new Error("Export analysis must not execute object methods"); + }, +}; diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/reexported-function.ts b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/reexported-function.ts new file mode 100644 index 00000000..52031954 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/reexported-function.ts @@ -0,0 +1 @@ +export {default} from "./function"; diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/string.ts b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/string.ts new file mode 100644 index 00000000..feed265f --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/string.ts @@ -0,0 +1 @@ +export default "panel"; diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/tsconfig.json b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/tsconfig.json new file mode 100644 index 00000000..983ab059 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": {"@export/*": ["./*"]}, + "module": "ESNext", + "moduleResolution": "Bundler" + } +} diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/type-only.ts b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/type-only.ts new file mode 100644 index 00000000..391f6b8c --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/type-only.ts @@ -0,0 +1,2 @@ +type Options = {enabled: boolean}; +export type {Options as default}; diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/unrelated-property.ts b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/unrelated-property.ts new file mode 100644 index 00000000..13723158 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/default-exports/unrelated-property.ts @@ -0,0 +1,2 @@ +export const unrelated = true; +export default {enabled: true}; diff --git a/src/cli/entrypoint/file/tests/fixtures/static-options/barrel.ts b/src/cli/entrypoint/file/tests/fixtures/static-options/barrel.ts new file mode 100644 index 00000000..32d26a9d --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/static-options/barrel.ts @@ -0,0 +1 @@ +export {closed as mode, Count as Numbers, runtime} from "./settings"; diff --git a/src/cli/entrypoint/file/tests/fixtures/static-options/cycle-a.ts b/src/cli/entrypoint/file/tests/fixtures/static-options/cycle-a.ts new file mode 100644 index 00000000..ec10be0f --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/static-options/cycle-a.ts @@ -0,0 +1,2 @@ +import {options as imported} from "./cycle-b"; +export const options = imported; diff --git a/src/cli/entrypoint/file/tests/fixtures/static-options/cycle-b.ts b/src/cli/entrypoint/file/tests/fixtures/static-options/cycle-b.ts new file mode 100644 index 00000000..47dc7672 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/static-options/cycle-b.ts @@ -0,0 +1,2 @@ +import {options as imported} from "./cycle-a"; +export const options = imported; diff --git a/src/cli/entrypoint/file/tests/fixtures/static-options/definition.ts b/src/cli/entrypoint/file/tests/fixtures/static-options/definition.ts new file mode 100644 index 00000000..29730c82 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/static-options/definition.ts @@ -0,0 +1,12 @@ +import {Browser, ContentScriptIsolation, defineContentScript} from "adnbn"; +import {closed, runtime} from "./settings"; + +const defaults = {type: ContentScriptIsolation.Shadow, mode: "open"}; +export default defineContentScript({ + ...runtime, + isolation: {...defaults, mode: closed}, + includeBrowser: [Browser.Firefox, ...[Browser.Chrome]], + main() { + throw new Error("The build must never execute entrypoint code"); + }, +}); diff --git a/src/cli/entrypoint/file/tests/fixtures/static-options/dynamic-definition.ts b/src/cli/entrypoint/file/tests/fixtures/static-options/dynamic-definition.ts new file mode 100644 index 00000000..9b79786e --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/static-options/dynamic-definition.ts @@ -0,0 +1,2 @@ +import {defineContentScript} from "adnbn"; +export default defineContentScript(computeOptions()); diff --git a/src/cli/entrypoint/file/tests/fixtures/static-options/invalid.ts b/src/cli/entrypoint/file/tests/fixtures/static-options/invalid.ts new file mode 100644 index 00000000..77bfc670 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/static-options/invalid.ts @@ -0,0 +1,15 @@ +import {ContentScriptIsolation} from "adnbn"; +import resource from "./resource.svg"; + +const first = second; +const second = first; +export const unresolved = {type: shadow}; +export const dynamic = {page: computePage()}; +export const spread = {type: "iframe", ...unknown}; +export const computed = {[computeKey()]: "iframe"}; +export const method = {value() { return "iframe"; }}; +export const cycle = first; +export const asset = resource; +export const unknownEnum = ContentScriptIsolation.Unknown; +export let mutable = "shadow"; +export const {destructured} = computeOptions(); diff --git a/src/cli/entrypoint/file/tests/fixtures/static-options/overrides.ts b/src/cli/entrypoint/file/tests/fixtures/static-options/overrides.ts new file mode 100644 index 00000000..70ad865a --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/static-options/overrides.ts @@ -0,0 +1,7 @@ +import {defineContentScript} from "adnbn"; +const defaults = {isolation: computeOptions()}; +export const isolation = computeOptions(); +export default defineContentScript({ + ...defaults, + isolation: {type: "shadow", mode: computeMode(), ...{mode: "closed"}}, +}); diff --git a/src/cli/entrypoint/file/tests/fixtures/static-options/re-export.ts b/src/cli/entrypoint/file/tests/fixtures/static-options/re-export.ts new file mode 100644 index 00000000..9feda608 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/static-options/re-export.ts @@ -0,0 +1,3 @@ +import {Numbers} from "./barrel"; +export {mode} from "./barrel"; +export const count = Numbers.Next; diff --git a/src/cli/entrypoint/file/tests/fixtures/static-options/resource.svg b/src/cli/entrypoint/file/tests/fixtures/static-options/resource.svg new file mode 100644 index 00000000..0c7be4e2 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/static-options/resource.svg @@ -0,0 +1 @@ + diff --git a/src/cli/entrypoint/file/tests/fixtures/static-options/settings.ts b/src/cli/entrypoint/file/tests/fixtures/static-options/settings.ts new file mode 100644 index 00000000..6dcdbbf8 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/static-options/settings.ts @@ -0,0 +1,10 @@ +import {ContentScriptShadowMode} from "adnbn"; + +export const closed = ContentScriptShadowMode.Closed; +export const runtime = {render: () => document.createElement("div")}; +export enum Count { + First, + Next, +} +const settings = {enabled: false, count: 0, label: "", empty: null, missing: undefined}; +export default settings; diff --git a/src/cli/entrypoint/file/tests/fixtures/static-options/values.ts b/src/cli/entrypoint/file/tests/fixtures/static-options/values.ts new file mode 100644 index 00000000..ae32659b --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/static-options/values.ts @@ -0,0 +1,19 @@ +import {ContentScriptWorld as World, ContentScriptShadowMode as ShadowMode} from "adnbn"; +import imported, {Count as Numbers} from "./settings"; + +enum Mode { + Closed = "closed", +} +const key = "sizes"; +const shadow = "not the literal"; +export const settings = {...imported, [key]: [-1, Numbers.First, 320]}; +export const enums = { + first: Numbers.First, + next: Numbers.Next, + named: Mode.Closed, + world: World.Isolated, + mode: ShadowMode.Closed, +}; +export const literal = "shadow"; +const mixed = {height: 320, render: () => document.createElement("div")}; +export const selected = mixed.height; diff --git a/src/cli/entrypoint/file/tests/fixtures/static-options/wrapped.ts b/src/cli/entrypoint/file/tests/fixtures/static-options/wrapped.ts new file mode 100644 index 00000000..2a8965de --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/static-options/wrapped.ts @@ -0,0 +1,5 @@ +import {defineContentScript, type ContentScriptDefinition} from "adnbn"; +import {runtime, closed} from "./settings"; + +const definition = {...runtime, isolation: {type: "shadow", mode: closed}} as const; +export default defineContentScript((definition) satisfies ContentScriptDefinition); diff --git a/src/cli/entrypoint/file/types.ts b/src/cli/entrypoint/file/types.ts index 71273a4f..dab33c62 100644 --- a/src/cli/entrypoint/file/types.ts +++ b/src/cli/entrypoint/file/types.ts @@ -1,3 +1,6 @@ +import type ts from "typescript"; +import type SourceFile from "./SourceFile"; + export interface Variable { name: string; value: any; @@ -15,4 +18,23 @@ export type VariableMap = Map; export type ImportMap = Map; -export type EnumMap = Map>; +export type SourceValue = string | number | boolean | null | undefined | SourceValue[] | {[key: string]: SourceValue}; + +export type SourceReadResult = + | {resolved: true; value: T} + | {resolved: false; file: string; path: string; reason: string}; + +/** An unevaluated expression and the module in which its identifiers are defined. */ +export interface SourceBinding { + source: SourceFile; + node: ts.Node; +} + +export interface SourceReadContext { + path: string; + seen: ReadonlySet; +} + +export interface SourceResolvedBinding extends SourceBinding { + context: SourceReadContext; +} diff --git a/src/cli/entrypoint/parser/AbstractParser.ts b/src/cli/entrypoint/parser/AbstractParser.ts index ba5debae..c65dd457 100644 --- a/src/cli/entrypoint/parser/AbstractParser.ts +++ b/src/cli/entrypoint/parser/AbstractParser.ts @@ -43,6 +43,14 @@ export default abstract class AbstractParser implem return instance.getOptions(); } + protected expressionFile(file: EntrypointFile): ExpressionFile { + const instance = ExpressionFile.make(file.file); + + instance.setImportResolver(this.ir); + + return instance.setDefinition(this.definition()); + } + protected agreement(): string | undefined { return undefined; } @@ -70,10 +78,6 @@ export default abstract class AbstractParser implem return; } - const instance = ExpressionFile.make(file.file); - - instance.setImportResolver(this.ir); - - return instance.setDefinition(this.definition()).setProperty(agreement).getType(); + return this.expressionFile(file).setProperty(agreement).getType(); } } diff --git a/src/cli/entrypoint/parser/ContentParser.test.ts b/src/cli/entrypoint/parser/ContentParser.test.ts index 1773e7e5..f279b5ae 100644 --- a/src/cli/entrypoint/parser/ContentParser.test.ts +++ b/src/cli/entrypoint/parser/ContentParser.test.ts @@ -3,7 +3,7 @@ import path from "path"; import ContentParser from "./ContentParser"; import type {ReadonlyConfig} from "@typing/config"; -import {ContentScriptWorld} from "@typing/content"; +import {ContentScriptShadowMode, ContentScriptWorld} from "@typing/content"; const rootDir = path.resolve(__dirname, "../../../.."); const fixtures = path.resolve(__dirname, "tests", "fixtures", "content"); @@ -18,59 +18,109 @@ const file = (...parts: string[]) => { describe("ContentParser", () => { test.each([ - "named-function", - "anonymous-function", - "arrow", - "local-component", - "imported-component", - "reexported-component", - "jsx", + ["enum", {mode: ContentScriptShadowMode.Closed}], + ["open", {mode: ContentScriptShadowMode.Open}], + ["string", {mode: "closed"}], + ["constant", {mode: "closed"}], + ["imported", {mode: "closed"}], + ["empty", {mode: "open"}], + ["spread", {mode: "closed"}], + ])("parses Shadow options from %s", (name, shadow) => { + expect(parser.options(file("options", "shadow", name + ".content.ts"))).toMatchObject({ + isolation: {type: "shadow", ...shadow}, + }); + }); + test.each([ + ["iframe", /isolation.mode is not supported/], + ["frame", /isolation.height is not supported/], + ["mode", /isolation.mode must be "open" or "closed"/], + ["dynamic", /isolation.mode must be statically known/], + ["identifier", /isolation.mode must be statically known/], + ["unknown", /isolation.delegatesFocus is not supported/], + ])("rejects invalid Shadow options from %s", (name, error) => { + const target = file("invalid", "shadow", name + ".content.ts"); + expect(() => parser.options(target)).toThrow(error); + expect(() => parser.options(target)).toThrow(target.file); + }); + test.each([ + "named-function.content.tsx", + "anonymous-function.content.tsx", + "arrow.content.tsx", + "local-component.content.tsx", + "imported-component.content.tsx", + "reexported-component.content.tsx", + "jsx.content.tsx", + "Panel.content.tsx", + "element-object.content.ts", ])("rejects frame navigation with default render: %s", name => { - const target = file("invalid", "default-render", name + ".content.tsx"); - expect(() => parser.options(target)).toThrow(/frame.page\/frame.src cannot be combined with render/); + const target = file("invalid", "default-render", name); + expect(() => parser.options(target)).toThrow(/isolation.page\/isolation.src cannot be combined with render/); expect(() => parser.options(target)).toThrow(target.file); }); - test.each(["function.content.tsx", "object.content.ts", "local-object.content.ts", "imported-object.content.ts"])( - "preserves a valid default export in %s", - name => { - const options = parser.options(file("options", "default-render", name)); - expect(options.isolation).toBe(name === "function.content.tsx" ? "shadow" : "iframe"); - expect(options).not.toHaveProperty("render"); - } - ); + test.each([ + "function.content.tsx", + "object.content.ts", + "local-object.content.ts", + "imported-object.content.ts", + "named-marker.content.ts", + ])("preserves a valid default export in %s", name => { + const options = parser.options(file("options", "default-render", name)); + expect(options.isolation?.type).toBe(name === "function.content.tsx" ? "shadow" : "iframe"); + expect(options).not.toHaveProperty("render"); + }); test("resolves a local frame object without treating identifiers as aliases", () => { expect(parser.options(file("options", "isolation", "frame-constant.content.ts"))).toMatchObject({ - frame: {page: "panel", height: 320}, + isolation: {type: "iframe", page: "panel", height: 320}, }); }); test("parses the isolation enum and frame dimensions", () => { expect(parser.options(file("options", "isolation", "iframe.content.ts"))).toMatchObject({ - isolation: "iframe", - frame: {height: 320}, + isolation: {type: "iframe", height: 320}, }); }); test("projects the Shadow isolation mode without runtime properties", () => { expect(parser.options(file("options", "isolation", "shadow.content.ts"))).toEqual({ matches: ["http://*/*", "https://*/*"], runAt: "document_idle", - isolation: "shadow", + isolation: {type: "shadow", mode: "open"}, }); }); test("resolves a local page alias", () => { expect(parser.options(file("options", "isolation", "page.content.ts"))).toMatchObject({ - frame: {page: "panel", width: "80%"}, + isolation: {type: "iframe", page: "panel", width: "80%"}, }); }); + test("preserves frame source and numeric/CSS dimensions in the normalized options", () => { + expect(parser.options(file("options", "isolation", "source.content.ts"))).toEqual({ + matches: ["http://*/*", "https://*/*"], + runAt: "document_idle", + isolation: { + type: "iframe", + src: "https://example.com/panel", + width: 0, + height: "calc(100vh - 24px)", + }, + }); + }); + test.each([ + ["iframe-short", {type: "iframe", width: "100%", height: 150}], + ["none-object", {type: "none"}], + ])("normalizes shorthand and explicit options from %s", (name, isolation) => { + expect(parser.options(file("options", "isolation", name + ".content.ts"))).toMatchObject({isolation}); + }); test.each([ - ["frame-identifier-page", /statically known string/], - ["frame-without-isolation", /frame requires/], + ["isolation-missing-type", /isolation.type must be/], + ["isolation-type-dynamic", /isolation.type must be statically known/], + ["isolation-type-identifier", /isolation.type must be statically known/], + ["isolation-none-options", /isolation.mode is not supported/], + ["frame-identifier-page", /statically known/], ["frame-conflict", /mutually exclusive/], ["frame-render", /cannot be combined/], ["frame-auto", /not supported yet/], - ["frame-spread", /explicit object/], + ["frame-spread", /statically known/], ["isolation-dynamic", /statically known/], - ["frame-dynamic-page", /statically known string/], - ["frame-dynamic-src", /statically known string/], + ["frame-dynamic-page", /statically known/], + ["frame-dynamic-src", /statically known/], ])("rejects %s with an actionable diagnostic", (name, error) => { expect(() => parser.options(file("invalid", name + ".content.ts"))).toThrow(error); }); @@ -82,6 +132,7 @@ describe("ContentParser", () => { matches: ["http://*/*", "https://*/*"], runAt: "document_idle", world, + isolation: {type: "none"}, }); }); }); diff --git a/src/cli/entrypoint/parser/ContentParser.ts b/src/cli/entrypoint/parser/ContentParser.ts index 3daaf5e2..b5339d5f 100644 --- a/src/cli/entrypoint/parser/ContentParser.ts +++ b/src/cli/entrypoint/parser/ContentParser.ts @@ -1,10 +1,8 @@ -import ts from "typescript"; -import path from "path"; -import {isContentScriptFrameNavigation, validateContentScriptIsolation} from "@shared/content"; +import {isContentScriptFrameNavigation, resolveContentScriptIsolation} from "@shared/content"; import z from "zod"; import AbstractParser from "./AbstractParser"; -import {TsResolver, type OptionFile} from "../file"; +import {ExportValueKind} from "../file"; import { ContentScriptDeclarative, @@ -12,6 +10,9 @@ import { ContentScriptMarker, ContentScriptMatches, ContentScriptWorld, + ContentScriptIsolation, + ContentScriptIsolationOptions, + ContentScriptShadowMode, } from "@typing/content"; import {EntrypointFile, EntrypointOptions} from "@typing/entrypoint"; @@ -21,6 +22,28 @@ export default class; + return this.CommonPropertiesSchema.extend({ matches: z.array(z.string()).optional(), excludeMatches: z.array(z.string()).optional(), @@ -33,69 +56,30 @@ export default class - !ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property) - ) - ) { - throw new Error( - "frame must be an explicit object without spreads or methods so its page/src variant can be determined during the build" - ); - } - for (const property of frame.properties) { - if (!property.name || ts.isComputedPropertyName(property.name)) - throw new Error("frame keys must be statically known"); - const key = property.name.getText().replace(/^["']|["']$/g, ""); - const initializer = ts.isPropertyAssignment(property) ? property.initializer : property.name; - if ( - (key === "page" || key === "src") && - (!optionFile.isStaticValue(initializer) || typeof values.frame?.[key] !== "string") - ) { - throw new Error(`frame.${key} must be a statically known string`); - } - } - } - const hasRender = - declared.has("render") || - (isContentScriptFrameNavigation(values.frame) && this.hasDefaultRender(file, optionFile)); - validateContentScriptIsolation(values.isolation, values.frame, hasRender); - } catch (error) { - throw new Error( - `Invalid content isolation in "${file.file}": ${error instanceof Error ? error.message : String(error)}` - ); - } + protected getOptions(file: EntrypointFile): Record { + const optionFile = this.optionFile(file); + const declared = optionFile.getDeclaredProperties(); + const values = optionFile.getOptions(); + + try { + const hasRender = + declared.has("render") || + (isContentScriptFrameNavigation(values.isolation) && + !optionFile.getDefinition() && + this.hasDefaultRender(file)); + return {...values, isolation: resolveContentScriptIsolation(values.isolation, hasRender)}; + } catch (error) { + throw new Error( + `Invalid content isolation in "${file.file}": ${error instanceof Error ? error.message : String(error)}` + ); } + } + public options(file: EntrypointFile): O { const options = super.options(file); return { @@ -105,65 +89,24 @@ export default class>): boolean { - const source = optionFile.getSourceFile(); - const declaration = source.statements.find( - statement => - (ts.isExportAssignment(statement) && !statement.isExportEquals) || - (ts.canHaveModifiers(statement) && - ts.getModifiers(statement)?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) || - (ts.isExportDeclaration(statement) && - !statement.isTypeOnly && - statement.exportClause && - ts.isNamedExports(statement.exportClause) && - statement.exportClause.elements.some(item => !item.isTypeOnly && item.name.text === "default")) - ); - if (!declaration) return false; - if (ts.isFunctionDeclaration(declaration) || ts.isClassDeclaration(declaration)) return true; + /** Interprets generic export metadata using content render rules; Relay overrides this policy. */ + protected hasDefaultRender(file: EntrypointFile): boolean { + const exported = this.expressionFile(file).getDefaultExport(); - if (ts.isExportAssignment(declaration)) { - let expression = declaration.expression; - while ( - ts.isParenthesizedExpression(expression) || - ts.isAsExpression(expression) || - ts.isSatisfiesExpression(expression) || - ts.isTypeAssertionExpression(expression) || - ts.isNonNullExpression(expression) - ) { - expression = expression.expression; - } - if ( - ts.isArrowFunction(expression) || - ts.isFunctionExpression(expression) || - ts.isClassExpression(expression) || - ts.isJsxElement(expression) || - ts.isJsxSelfClosingElement(expression) || - ts.isJsxFragment(expression) - ) - return true; - if (ts.isObjectLiteralExpression(expression)) return optionFile.getDeclaredProperties().has("$$typeof"); - if (optionFile.getDefinition()) return false; + if (!exported) { + return false; } - // Resolve aliases/imported components without assuming every identifier is a render value. - // This program is local to this check; watch rebuilds never reuse a stale export type. - const options = TsResolver.make(path.resolve(this.config.rootDir, "tsconfig.json")).getConfig().options; - const program = ts.createProgram([file.file], {...options, noEmit: true}); - const entry = program.getSourceFile(file.file); - if (!entry) return false; - const checker = program.getTypeChecker(); - const module = checker.getSymbolAtLocation(entry); - const exported = module && checker.getExportsOfModule(module).find(symbol => symbol.name === "default"); - if (!exported) return false; - const symbol = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported; - const type = checker.getTypeOfSymbolAtLocation(symbol, entry); - return ( - type.getCallSignatures().length > 0 || - type.getConstructSignatures().length > 0 || - type.getProperty("$$typeof") !== undefined || - (type.isStringLiteral() ? type.value.length > 0 : (type.flags & ts.TypeFlags.StringLike) !== 0) || - (type.flags & ts.TypeFlags.NumberLike) !== 0 - ); + switch (exported.kind) { + case ExportValueKind.Function: + case ExportValueKind.Class: + case ExportValueKind.Jsx: + case ExportValueKind.Number: + return true; + case ExportValueKind.String: + return exported.value !== ""; + default: + return exported.properties.includes("$$typeof"); + } } } diff --git a/src/cli/entrypoint/parser/RelayParser.test.ts b/src/cli/entrypoint/parser/RelayParser.test.ts index fc165d87..bc42bb9f 100644 --- a/src/cli/entrypoint/parser/RelayParser.test.ts +++ b/src/cli/entrypoint/parser/RelayParser.test.ts @@ -19,12 +19,28 @@ const file = (...parts: string[]) => { }; describe("RelayParser", () => { + test("reads imported isolation objects and enum injectors through the shared option reader", () => { + expect(parser.options(file("options", "isolation", "imported.relay.ts"))).toMatchObject({ + isolation: {type: "shadow", mode: "closed"}, + method: "messaging", + }); + }); + test("reports the unresolved nested field in Relay options", () => { + const target = file("invalid", "dynamic-isolation.relay.ts"); + expect(() => parser.options(target)).toThrow(/isolation.mode must be statically known/); + expect(() => parser.options(target)).toThrow(target.file); + }); + test("preserves Shadow options and shares content validation", () => { + expect(parser.options(file("options", "isolation", "shadow.relay.ts"))).toMatchObject({ + isolation: {type: "shadow", mode: "closed"}, + }); + expect(() => parser.options(file("invalid", "shadow.relay.ts"))).toThrow(/isolation.mode is not supported/); + }); test.each(["function.relay.ts", "named-render.relay.ts", "imported.relay.ts"])( "treats %s as init rather than a content renderer", name => { expect(parser.options(file("options", "default-init", name))).toMatchObject({ - isolation: "iframe", - frame: {page: "panel"}, + isolation: {type: "iframe", page: "panel"}, }); } ); @@ -35,8 +51,7 @@ describe("RelayParser", () => { }); test("preserves Relay isolation and frame build options", () => { const options = parser.options(file("options", "isolation", "relay.ts")); - expect(options).toHaveProperty("isolation", "iframe"); - expect(options).toHaveProperty("frame", {page: "panel"}); + expect(options.isolation).toEqual({type: "iframe", page: "panel", width: "100%", height: 150}); }); test("parses the all-frame response capability from a real entrypoint file", () => { expect(parser.options(file("options", "all-frames", "relay.ts"))).toEqual( diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/Panel.content.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/Panel.content.tsx new file mode 100644 index 00000000..920dbfee --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/Panel.content.tsx @@ -0,0 +1,9 @@ +import {Component} from "react"; + +export const isolation = {type: "iframe", page: "panel"}; + +export default class Panel extends Component { + render() { + return null; + } +} diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/anonymous-function.content.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/anonymous-function.content.tsx index 36a96ba3..2473ed1e 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/anonymous-function.content.tsx +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/anonymous-function.content.tsx @@ -1,5 +1,4 @@ -export const isolation = "iframe"; -export const frame = {src: "https://example.com/panel"}; +export const isolation = {type: "iframe", src: "https://example.com/panel"}; export default function () { return null; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/arrow.content.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/arrow.content.tsx index 5d46ebef..de782e30 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/arrow.content.tsx +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/arrow.content.tsx @@ -1,4 +1,3 @@ -export const isolation = "iframe"; -export const frame = {src: "https://example.com/panel"}; +export const isolation = {type: "iframe", src: "https://example.com/panel"}; export default () => null; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/element-object.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/element-object.content.ts new file mode 100644 index 00000000..562fce69 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/element-object.content.ts @@ -0,0 +1,3 @@ +export const isolation = {type: "iframe", page: "panel"}; + +export default {$$typeof: Symbol.for("react.element"), type: "div", props: {}, key: null}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/imported-component.content.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/imported-component.content.tsx index e8206004..56814da1 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/imported-component.content.tsx +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/imported-component.content.tsx @@ -1,5 +1,5 @@ import Panel from "./Panel"; -export const isolation = "iframe"; -export const frame = {page: "panel"}; +export const isolation = {type: "iframe", page: "panel"}; + export default Panel; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/jsx.content.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/jsx.content.tsx index df2566b9..28659c9b 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/jsx.content.tsx +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/jsx.content.tsx @@ -1,5 +1,5 @@ import React from "react"; -export const isolation = "iframe"; -export const frame = {page: "panel"}; +export const isolation = {type: "iframe", page: "panel"}; + export default ; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/local-component.content.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/local-component.content.tsx index b4be450b..ca8b65ce 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/local-component.content.tsx +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/local-component.content.tsx @@ -1,5 +1,4 @@ -export const isolation = "iframe"; -export const frame = {page: "panel"}; +export const isolation = {type: "iframe", page: "panel"}; const Panel = () => null; export default Panel satisfies () => null; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/named-function.content.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/named-function.content.tsx index 089fe349..d9a09fb8 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/named-function.content.tsx +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/named-function.content.tsx @@ -1,5 +1,4 @@ -export const isolation = "iframe"; -export const frame = {page: "panel"}; +export const isolation = {type: "iframe", page: "panel"}; export default function Panel() { return null; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/reexported-component.content.tsx b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/reexported-component.content.tsx index c5237e5c..6e97c902 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/reexported-component.content.tsx +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/reexported-component.content.tsx @@ -1,3 +1,3 @@ -export const isolation = "iframe"; -export const frame = {page: "panel"}; +export const isolation = {type: "iframe", page: "panel"}; + export {default} from "./Panel"; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-auto.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-auto.content.ts index 3bdb54c3..22e5b174 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-auto.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-auto.content.ts @@ -1,2 +1,2 @@ import {defineContentScriptAppend} from "adnbn"; -export default defineContentScriptAppend({isolation: "iframe", frame: {height: "auto"}}); +export default defineContentScriptAppend({isolation: {type: "iframe", height: "auto"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-conflict.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-conflict.content.ts index d7fa81f2..26d0ad28 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-conflict.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-conflict.content.ts @@ -1,2 +1,2 @@ import {defineContentScriptAppend} from "adnbn"; -export default defineContentScriptAppend({isolation: "iframe", frame: {page: "panel", src: "https://example.com"}}); +export default defineContentScriptAppend({isolation: {type: "iframe", page: "panel", src: "https://example.com"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-dynamic-page.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-dynamic-page.content.ts index e045ed59..8b04654b 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-dynamic-page.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-dynamic-page.content.ts @@ -1,2 +1,2 @@ import {defineContentScriptAppend} from "adnbn"; -export default defineContentScriptAppend({isolation: "iframe", frame: {page: computeTarget()}}); +export default defineContentScriptAppend({isolation: {type: "iframe", page: computeTarget()}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-dynamic-src.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-dynamic-src.content.ts index 2196908a..2a017456 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-dynamic-src.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-dynamic-src.content.ts @@ -1,2 +1,2 @@ import {defineContentScriptAppend} from "adnbn"; -export default defineContentScriptAppend({isolation: "iframe", frame: {src: computeTarget()}}); +export default defineContentScriptAppend({isolation: {type: "iframe", src: computeTarget()}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-identifier-page.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-identifier-page.content.ts index 26825163..99a0aa9c 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-identifier-page.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-identifier-page.content.ts @@ -1,3 +1,3 @@ import {defineContentScriptAppend} from "adnbn"; // An unresolved identifier must not be confused with an alias that happens to have this name. -export default defineContentScriptAppend({isolation: "iframe", frame: {page: panel}}); +export default defineContentScriptAppend({isolation: {type: "iframe", page: panel}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-render.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-render.content.ts index 066f7143..41f7dc25 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-render.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-render.content.ts @@ -1,7 +1,6 @@ import {defineContentScriptAppend} from "adnbn"; export default defineContentScriptAppend({ - isolation: "iframe", - frame: {page: "panel"}, + isolation: {type: "iframe", page: "panel"}, render() { return null; }, diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-spread.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-spread.content.ts index e3663c4a..9642c41d 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-spread.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-spread.content.ts @@ -1,2 +1,2 @@ import {defineContentScriptAppend} from "adnbn"; -export default defineContentScriptAppend({isolation: "iframe", frame: {...settings}}); +export default defineContentScriptAppend({isolation: {type: "iframe", ...settings}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-without-isolation.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-without-isolation.content.ts deleted file mode 100644 index c33143d2..00000000 --- a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-without-isolation.content.ts +++ /dev/null @@ -1,2 +0,0 @@ -import {defineContentScriptAppend} from "adnbn"; -export default defineContentScriptAppend({frame: {height: 300}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-missing-type.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-missing-type.content.ts new file mode 100644 index 00000000..a4818fcf --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-missing-type.content.ts @@ -0,0 +1,2 @@ +import {defineContentScript} from "adnbn"; +export default defineContentScript({isolation: {mode: "closed"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-none-options.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-none-options.content.ts new file mode 100644 index 00000000..180cad10 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-none-options.content.ts @@ -0,0 +1,2 @@ +import {defineContentScript} from "adnbn"; +export default defineContentScript({isolation: {type: "none", mode: "open"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-type-dynamic.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-type-dynamic.content.ts new file mode 100644 index 00000000..4ffbbb08 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-type-dynamic.content.ts @@ -0,0 +1,2 @@ +import {defineContentScript} from "adnbn"; +export default defineContentScript({isolation: {type: Math.random() > 0.5 ? "shadow" : "iframe"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-type-identifier.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-type-identifier.content.ts new file mode 100644 index 00000000..962b91d5 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/isolation-type-identifier.content.ts @@ -0,0 +1,3 @@ +import {defineContentScript} from "adnbn"; +import {shadow} from "./unresolved-isolation-type"; +export default defineContentScript({isolation: {type: shadow}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/dynamic.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/dynamic.content.ts new file mode 100644 index 00000000..8fc600e1 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/dynamic.content.ts @@ -0,0 +1,2 @@ +import {defineContentScript} from "adnbn"; +export default defineContentScript({isolation: {type: "shadow", mode: Math.random() > 0.5 ? "open" : "closed"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/frame.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/frame.content.ts new file mode 100644 index 00000000..b415342e --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/frame.content.ts @@ -0,0 +1,2 @@ +import {defineContentScript} from "adnbn"; +export default defineContentScript({isolation: {type: "shadow", height: 200, mode: "closed"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/identifier.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/identifier.content.ts new file mode 100644 index 00000000..0b1eafbe --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/identifier.content.ts @@ -0,0 +1,3 @@ +import {defineContentScript} from "adnbn"; +import {closed} from "./unresolved-mode"; +export default defineContentScript({isolation: {type: "shadow", mode: closed}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/iframe.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/iframe.content.ts new file mode 100644 index 00000000..369d85d5 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/iframe.content.ts @@ -0,0 +1,2 @@ +import {defineContentScript} from "adnbn"; +export default defineContentScript({isolation: {type: "iframe", height: 200, mode: "closed"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/mode.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/mode.content.ts new file mode 100644 index 00000000..7f58436b --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/mode.content.ts @@ -0,0 +1,2 @@ +import {defineContentScript} from "adnbn"; +export default defineContentScript({isolation: {type: "shadow", mode: "close"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/unknown.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/unknown.content.ts new file mode 100644 index 00000000..29eb8806 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/unknown.content.ts @@ -0,0 +1,2 @@ +import {defineContentScript} from "adnbn"; +export default defineContentScript({isolation: {type: "shadow", delegatesFocus: true}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/unresolved-mode.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/unresolved-mode.ts new file mode 100644 index 00000000..04e680d8 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/shadow/unresolved-mode.ts @@ -0,0 +1 @@ +export const closed = Math.random() > 0.5 ? "closed" : "open"; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/invalid/unresolved-isolation-type.ts b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/unresolved-isolation-type.ts new file mode 100644 index 00000000..d4a7ddab --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/unresolved-isolation-type.ts @@ -0,0 +1 @@ +export const shadow = Math.random() > 0.5 ? "shadow" : "iframe"; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/imported-object.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/imported-object.content.ts index 33e51c93..22d49ed4 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/imported-object.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/imported-object.content.ts @@ -1,5 +1,5 @@ import options from "./options"; -export const isolation = "iframe"; -export const frame = {page: "panel"}; +export const isolation = {type: "iframe", page: "panel"}; + export default options; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/local-object.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/local-object.content.ts index 63d5ab3f..c062a6c6 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/local-object.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/local-object.content.ts @@ -1,5 +1,4 @@ -export const isolation = "iframe"; -export const frame = {page: "panel"}; +export const isolation = {type: "iframe", page: "panel"}; const options = {watch: true}; export default options; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/named-marker.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/named-marker.content.ts new file mode 100644 index 00000000..b358b8de --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/named-marker.content.ts @@ -0,0 +1,2 @@ +export const $$typeof = Symbol.for("react.element"); +export default {isolation: {type: "iframe", page: "panel"}}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/object.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/object.content.ts index 7c37a9b7..a69e905c 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/object.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/object.content.ts @@ -1 +1 @@ -export default {isolation: "iframe", frame: {page: "panel"}}; +export default {isolation: {type: "iframe", page: "panel"}}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/frame-constant.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/frame-constant.content.ts index 987834a4..d45decc4 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/frame-constant.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/frame-constant.content.ts @@ -1,4 +1,4 @@ import {defineContentScriptAppend} from "adnbn"; const page = "panel"; -const frame = {page, height: 320}; -export default defineContentScriptAppend({isolation: "iframe", frame}); +const isolation = {type: "iframe", page, height: 320} as const; +export default defineContentScriptAppend({isolation}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe-short.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe-short.content.ts new file mode 100644 index 00000000..075390ee --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe-short.content.ts @@ -0,0 +1,2 @@ +import {defineContentScript} from "adnbn"; +export default defineContentScript({isolation: "iframe"}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe.content.ts index 50c47549..bf7572b5 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe.content.ts @@ -1,6 +1,5 @@ import {ContentScriptIsolation, defineContentScriptAppend} from "adnbn"; export default defineContentScriptAppend({ - isolation: ContentScriptIsolation.Iframe, - frame: {height: 320}, + isolation: {type: ContentScriptIsolation.Iframe, height: 320}, }); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/none-object.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/none-object.content.ts new file mode 100644 index 00000000..8317ce8d --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/none-object.content.ts @@ -0,0 +1,2 @@ +import {defineContentScript} from "adnbn"; +export default defineContentScript({isolation: {type: "none"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/page.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/page.content.ts index ee66d26a..80b11a99 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/page.content.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/page.content.ts @@ -1,3 +1,3 @@ import {defineContentScriptAppend} from "adnbn"; const alias = "panel"; -export default defineContentScriptAppend({isolation: "iframe", frame: {page: alias, width: "80%"}}); +export default defineContentScriptAppend({isolation: {type: "iframe", page: alias, width: "80%"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/source.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/source.content.ts new file mode 100644 index 00000000..6742a086 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/source.content.ts @@ -0,0 +1,10 @@ +import {ContentScriptIsolation, defineContentScriptAppend} from "adnbn"; + +export default defineContentScriptAppend({ + isolation: { + type: ContentScriptIsolation.Iframe, + src: "https://example.com/panel", + width: 0, + height: "calc(100vh - 24px)", + }, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/constant.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/constant.content.ts new file mode 100644 index 00000000..4bf04839 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/constant.content.ts @@ -0,0 +1,3 @@ +import {defineContentScript, type ContentScriptIsolationOptions} from "adnbn"; +const isolation = {type: "shadow", mode: "closed"} as const satisfies ContentScriptIsolationOptions; +export default defineContentScript({isolation: isolation satisfies ContentScriptIsolationOptions}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/empty.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/empty.content.ts new file mode 100644 index 00000000..72d958a5 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/empty.content.ts @@ -0,0 +1,2 @@ +import {defineContentScript} from "adnbn"; +export default defineContentScript({isolation: {type: "shadow"}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/enum.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/enum.content.ts new file mode 100644 index 00000000..bd4d98c3 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/enum.content.ts @@ -0,0 +1,5 @@ +import {ContentScriptIsolation, ContentScriptShadowMode, defineContentScriptAppend} from "adnbn"; +export default defineContentScriptAppend({ + isolation: {type: ContentScriptIsolation.Shadow, mode: ContentScriptShadowMode.Closed}, + render: "Panel", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/imported.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/imported.content.ts new file mode 100644 index 00000000..64393000 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/imported.content.ts @@ -0,0 +1,3 @@ +import {defineContentScript} from "adnbn"; +import {mode} from "./mode"; +export default defineContentScript({isolation: {type: "shadow", mode}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/mode.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/mode.ts new file mode 100644 index 00000000..3d75e24b --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/mode.ts @@ -0,0 +1 @@ +export const mode = "closed"; diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/open.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/open.content.ts new file mode 100644 index 00000000..66dba763 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/open.content.ts @@ -0,0 +1,2 @@ +import {ContentScriptShadowMode, defineContentScript} from "adnbn"; +export default defineContentScript({isolation: {type: "shadow", mode: ContentScriptShadowMode.Open}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/spread.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/spread.content.ts new file mode 100644 index 00000000..4645117a --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/spread.content.ts @@ -0,0 +1,3 @@ +import {defineContentScript} from "adnbn"; +const options = {mode: "closed"}; +export default defineContentScript({isolation: {type: "shadow", ...options}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/string.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/string.content.ts new file mode 100644 index 00000000..6c24b465 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/shadow/string.content.ts @@ -0,0 +1 @@ +export const isolation = {type: "shadow", mode: "closed"}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/invalid/dynamic-isolation.relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/invalid/dynamic-isolation.relay.ts new file mode 100644 index 00000000..3aff381f --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/invalid/dynamic-isolation.relay.ts @@ -0,0 +1,3 @@ +import {defineRelay} from "adnbn"; + +export default defineRelay({isolation: {type: "shadow", mode: resolveMode()}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/invalid/frame-render.relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/invalid/frame-render.relay.ts index 7b1f005b..8af64db1 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/relay/invalid/frame-render.relay.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/invalid/frame-render.relay.ts @@ -1,5 +1,5 @@ -export const isolation = "iframe"; -export const frame = {page: "panel"}; +export const isolation = {type: "iframe", page: "panel"}; + export function render() { return "UI"; } diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/invalid/shadow.relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/invalid/shadow.relay.ts new file mode 100644 index 00000000..03367484 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/invalid/shadow.relay.ts @@ -0,0 +1,2 @@ +import {defineRelay} from "adnbn"; +export default defineRelay({isolation: {type: "iframe", mode: "closed"}, init: () => ({})}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/function.relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/function.relay.ts index 2723fff0..920b3167 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/function.relay.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/function.relay.ts @@ -1,5 +1,4 @@ -export const isolation = "iframe"; -export const frame = {page: "panel"}; +export const isolation = {type: "iframe", page: "panel"}; export default function init() { return {ping: () => "pong"}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/imported.relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/imported.relay.ts index f7e9a349..c476bfce 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/imported.relay.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/imported.relay.ts @@ -1,5 +1,5 @@ import init from "./init"; -export const isolation = "iframe"; -export const frame = {page: "panel"}; +export const isolation = {type: "iframe", page: "panel"}; + export default init; diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/named-render.relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/named-render.relay.ts index b1ede486..dec0be9a 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/named-render.relay.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/named-render.relay.ts @@ -1,5 +1,4 @@ -export const isolation = "iframe"; -export const frame = {page: "panel"}; +export const isolation = {type: "iframe", page: "panel"}; export default function render() { return {ping: () => "pong"}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/imported.relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/imported.relay.ts new file mode 100644 index 00000000..fdf4dbd3 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/imported.relay.ts @@ -0,0 +1,4 @@ +import {defineRelay, RelayMethod} from "adnbn"; +import {options} from "./settings"; + +export default defineRelay({...options, method: RelayMethod.Messaging}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/relay.ts index 5c8f5ef0..34c00bef 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/relay.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/relay.ts @@ -1,8 +1,7 @@ import {defineRelay} from "adnbn"; export default defineRelay({ - isolation: "iframe", - frame: {page: "panel"}, + isolation: {type: "iframe", page: "panel"}, init() { return {}; }, diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/settings.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/settings.ts new file mode 100644 index 00000000..f2da9f21 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/settings.ts @@ -0,0 +1,6 @@ +import {ContentScriptIsolation, ContentScriptShadowMode} from "adnbn"; + +export const options = { + isolation: {type: ContentScriptIsolation.Shadow, mode: ContentScriptShadowMode.Closed}, + render: () => document.createElement("div"), +}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/shadow.relay.ts b/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/shadow.relay.ts new file mode 100644 index 00000000..0e219693 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/shadow.relay.ts @@ -0,0 +1,6 @@ +import {ContentScriptIsolation, ContentScriptShadowMode, defineRelay} from "adnbn"; +export default defineRelay({ + isolation: {type: ContentScriptIsolation.Shadow, mode: ContentScriptShadowMode.Closed}, + init: () => ({}), + render: "Panel", +}); diff --git a/src/cli/plugins/content/Content.ts b/src/cli/plugins/content/Content.ts index 8570b434..dda65544 100644 --- a/src/cli/plugins/content/Content.ts +++ b/src/cli/plugins/content/Content.ts @@ -26,7 +26,7 @@ export default class extends ContentFinder implements ContentProvider describe("ContentManager virtual modules", () => { test("requires a prepared group and a matching file before calling a provider", async () => { const file = {file: "panel.content.ts", import: "./panel.content"}; - const options: ContentScriptEntrypointOptions = {isolation: "iframe", frame: {page: "panel"}}; + const options: ContentScriptEntrypointOptions = {isolation: {type: "iframe", page: "panel"}}; const createVirtual = jest.fn((_file, options) => JSON.stringify(options)); const driver = new DriverFixture(new Set(), new Set(), new Map([["panel", {file, options}]])); const manager = new ContentManager({rootDir: process.cwd()} as ReadonlyConfig).provider( @@ -77,18 +77,18 @@ describe("ContentManager virtual modules", () => { const driver = new DriverFixture( new Set(), new Set(), - new Map([["changing", {file, options: {isolation: "iframe", frame: {src: "https://example.com"}}}]]) + new Map([["changing", {file, options: {isolation: {type: "iframe", src: "https://example.com"}}}]]) ); const manager = new ContentManager({rootDir: process.cwd()} as ReadonlyConfig).provider( new ProviderFixture(driver, (_file, options) => JSON.stringify(options)) ); await manager.entries(); - expect(JSON.parse(manager.virtual(file))).toHaveProperty("frame.src", "https://example.com"); + expect(JSON.parse(manager.virtual(file))).toHaveProperty("isolation.src", "https://example.com"); manager.clear(); expect(() => manager.virtual(file)).toThrow(/group is not prepared/); - driver.setItems(new Map([["changing", {file, options: {isolation: "shadow"}}]])); + driver.setItems(new Map([["changing", {file, options: {isolation: {type: "shadow"}}}]])); await manager.entries(); - expect(JSON.parse(manager.virtual(file))).toEqual({isolation: "shadow"}); + expect(JSON.parse(manager.virtual(file))).toEqual({isolation: {type: "shadow"}}); }); }); @@ -123,12 +123,12 @@ describe("ContentManager execution worlds", () => { async frame => { const file = {file: "frame.content.ts", import: "./frame.content"}; const items: ContentItems = new Map([ - ["frame", {file, options: {isolation: "iframe", frame, world: ContentScriptWorld.Main}}], + ["frame", {file, options: {isolation: {type: "iframe", ...frame}, world: ContentScriptWorld.Main}}], ]); const manager = new ContentManager({manifestVersion: 3, rootDir: process.cwd()} as ReadonlyConfig).provider( new ProviderFixture(new DriverFixture(new Set(), new Set(), items)) ); - await expect(manager.entries()).rejects.toThrow(/only external HTTP\(S\) frame.src/); + await expect(manager.entries()).rejects.toThrow(/only external HTTP\(S\) isolation.src/); } ); test("normalizes MV2 worlds before grouping and warns without changing provider options", async () => { @@ -244,7 +244,7 @@ describe("ContentManager execution worlds", () => { const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); const file = {file: "shadow.content.ts", import: "./shadow.content"}; const items: ContentItems = new Map([ - ["shadow", {file, options: {isolation: "shadow", world: ContentScriptWorld.Main}}], + ["shadow", {file, options: {isolation: {type: "shadow"}, world: ContentScriptWorld.Main}}], ]); const manager = new ContentManager({ manifestVersion: 2, @@ -254,7 +254,7 @@ describe("ContentManager execution worlds", () => { try { await expect(manager.entryOptions()).resolves.toEqual( - new Map([["shadow.content", {world: ContentScriptWorld.Isolated, isolation: "shadow"}]]) + new Map([["shadow.content", {world: ContentScriptWorld.Isolated, isolation: {type: "shadow"}}]]) ); } finally { warn.mockRestore(); @@ -264,7 +264,7 @@ describe("ContentManager execution worlds", () => { test("rejects Shadow DOM in the MAIN world for Manifest V3", async () => { const file = {file: "shadow.content.ts", import: "./shadow.content"}; const items: ContentItems = new Map([ - ["shadow", {file, options: {isolation: "shadow", world: ContentScriptWorld.Main}}], + ["shadow", {file, options: {isolation: {type: "shadow"}, world: ContentScriptWorld.Main}}], ]); const manager = new ContentManager({ manifestVersion: 3, @@ -284,7 +284,7 @@ describe("ContentManager Shadow DOM entries", () => { `frame-${index}`, { file: {file: `frame-${index}.content.ts`, import: `./frame-${index}.content`}, - options: {isolation: "iframe", frame}, + options: {isolation: {type: "iframe", ...frame}}, }, ]) ); @@ -295,7 +295,7 @@ describe("ContentManager Shadow DOM entries", () => { } as ReadonlyConfig).provider(new ProviderFixture(new DriverFixture(new Set(), new Set(), items))); expect((await manager.entries()).size).toBe(4); expect(Array.from((await manager.entryOptions()).values())).toEqual( - frames.map(frame => ({isolation: "iframe", frame})) + frames.map(frame => ({isolation: {type: "iframe", ...frame}})) ); }); test("never concatenates shadow entries and keeps ordinary entries eligible for concatenation", async () => { @@ -307,8 +307,8 @@ describe("ContentManager Shadow DOM entries", () => { const items: ContentItems = new Map([ ["normal-a", {file: normalA, options: {matches}}], ["normal-b", {file: normalB, options: {matches}}], - ["shadow-a", {file: shadowA, options: {matches, isolation: "shadow"}}], - ["shadow-b", {file: shadowB, options: {matches, isolation: "shadow"}}], + ["shadow-a", {file: shadowA, options: {matches, isolation: {type: "shadow"}}}], + ["shadow-b", {file: shadowB, options: {matches, isolation: {type: "shadow"}}}], ]); const manager = new ContentManager({ manifestVersion: 3, @@ -326,8 +326,8 @@ describe("ContentManager Shadow DOM entries", () => { await expect(manager.entryOptions()).resolves.toEqual( new Map([ ["normal-a.content", {matches}], - ["shadow-a.content", {matches, isolation: "shadow"}], - ["shadow-b.content", {matches, isolation: "shadow"}], + ["shadow-a.content", {matches, isolation: {type: "shadow"}}], + ["shadow-b.content", {matches, isolation: {type: "shadow"}}], ]) ); }); @@ -337,7 +337,7 @@ describe("ContentManager Shadow DOM entries", () => { const driver = new DriverFixture( new Set(), new Set(), - new Map([["changing", {file, options: {isolation: "shadow"}}]]) + new Map([["changing", {file, options: {isolation: {type: "shadow"}}}]]) ); const manager = new ContentManager({ manifestVersion: 3, @@ -345,7 +345,9 @@ describe("ContentManager Shadow DOM entries", () => { rootDir: process.cwd(), } as ReadonlyConfig).provider(new ProviderFixture(driver)); - await expect(manager.entryOptions()).resolves.toEqual(new Map([["changing.content", {isolation: "shadow"}]])); + await expect(manager.entryOptions()).resolves.toEqual( + new Map([["changing.content", {isolation: {type: "shadow"}}]]) + ); const manifest = await manager.manifest(); expect(manifest).toEqual( new Set([ @@ -359,14 +361,18 @@ describe("ContentManager Shadow DOM entries", () => { ]) ); - driver.setItems(new Map([["changing", {file, options: {isolation: "none"}}]])); + driver.setItems(new Map([["changing", {file, options: {isolation: {type: "none"}}}]])); manager.clear(); - await expect(manager.entryOptions()).resolves.toEqual(new Map([["changing.content", {isolation: "none"}]])); + await expect(manager.entryOptions()).resolves.toEqual( + new Map([["changing.content", {isolation: {type: "none"}}]]) + ); await expect(manager.manifest()).resolves.toEqual(manifest); - driver.setItems(new Map([["changing", {file, options: {isolation: "shadow"}}]])); + driver.setItems(new Map([["changing", {file, options: {isolation: {type: "shadow"}}}]])); manager.clear(); - await expect(manager.entryOptions()).resolves.toEqual(new Map([["changing.content", {isolation: "shadow"}]])); + await expect(manager.entryOptions()).resolves.toEqual( + new Map([["changing.content", {isolation: {type: "shadow"}}]]) + ); await expect(manager.manifest()).resolves.toEqual(manifest); }); }); diff --git a/src/cli/plugins/content/ContentManager.ts b/src/cli/plugins/content/ContentManager.ts index e5be6939..5f064f31 100644 --- a/src/cli/plugins/content/ContentManager.ts +++ b/src/cli/plugins/content/ContentManager.ts @@ -72,12 +72,12 @@ export default class ContentManager { if (this.config.manifestVersion !== 2) { if ( world === ContentScriptWorld.Main && - (options.isolation === ContentScriptIsolation.Shadow || - (options.isolation === ContentScriptIsolation.Iframe && - !(options.frame?.src && /^https?:\/\//.test(options.frame.src)))) + (options.isolation?.type === ContentScriptIsolation.Shadow || + (options.isolation?.type === ContentScriptIsolation.Iframe && + !(options.isolation?.src && /^https?:\/\//.test(options.isolation.src)))) ) { throw new Error( - `Content script "${file.file}" cannot use this isolation in the MAIN execution world; only external HTTP(S) frame.src is supported` + `Content script "${file.file}" cannot use this isolation in the MAIN execution world; only external HTTP(S) isolation.src is supported` ); } diff --git a/src/cli/plugins/content/ContentName.ts b/src/cli/plugins/content/ContentName.ts index 6ebdda3c..4b23b1e3 100644 --- a/src/cli/plugins/content/ContentName.ts +++ b/src/cli/plugins/content/ContentName.ts @@ -19,7 +19,7 @@ export default class ContentName extends NameGenerator implements ContentNameGen public create(name: string, options: ContentScriptEntrypointOptions): string { if ( !this.config.concatContentScripts || - (options.isolation !== undefined && options.isolation !== ContentScriptIsolation.None) + (options.isolation !== undefined && options.isolation.type !== ContentScriptIsolation.None) ) { return this.name(name); } diff --git a/src/cli/plugins/content/Relay.ts b/src/cli/plugins/content/Relay.ts index 672a5f27..b2434b70 100644 --- a/src/cli/plugins/content/Relay.ts +++ b/src/cli/plugins/content/Relay.ts @@ -28,7 +28,7 @@ export default class extends RelayFinder implements ContentProvider> { diff --git a/src/cli/plugins/content/bundler.test.ts b/src/cli/plugins/content/bundler.test.ts index fda669c5..837dbb18 100644 --- a/src/cli/plugins/content/bundler.test.ts +++ b/src/cli/plugins/content/bundler.test.ts @@ -3,7 +3,7 @@ import type {ContentScriptEntrypointOptions} from "@typing/content"; describe("validateContentStyles", () => { test.each(["panel.content", "panel.relay"])("warns for missing isolated CSS in %s", entry => { - for (const isolation of ["shadow", "iframe"] as const) { + for (const isolation of [{type: "shadow"}, {type: "iframe"}] as const) { const warnings = validateContentStyles(entry, {isolation}, {document: ["host.css"], isolated: []}); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain(`[adnbn:missing-isolation-css] Entrypoint "${entry}"`); @@ -14,26 +14,32 @@ describe("validateContentStyles", () => { test.each([ undefined, {}, - {isolation: "none"}, - {isolation: "iframe", frame: {page: "panel"}}, - {isolation: "iframe", frame: {src: "https://example.com/panel"}}, + {isolation: {type: "none"}}, + {isolation: {type: "iframe", page: "panel"}}, + {isolation: {type: "iframe", src: "https://example.com/panel"}}, ])("does not warn for unrelated entries or embedded documents: %j", options => { expect(validateContentStyles("entry", options, {document: ["host.css"], isolated: []})).toEqual([]); }); test("allows entries without CSS and mixed document/isolated styles", () => { for (const document of [[], ["host.css"]]) { - expect(validateContentStyles("panel", {isolation: "shadow"}, {document, isolated: ["ui.css"]})).toEqual([]); + expect( + validateContentStyles("panel", {isolation: {type: "shadow"}}, {document, isolated: ["ui.css"]}) + ).toEqual([]); } - expect(validateContentStyles("panel", {isolation: "shadow"}, {document: [], isolated: []})).toEqual([]); + expect(validateContentStyles("panel", {isolation: {type: "shadow"}}, {document: [], isolated: []})).toEqual([]); }); test.each([{page: "panel"}, {src: "https://example.com/panel"}])( "rejects isolated CSS on navigation: %j", frame => { expect(() => - validateContentStyles("frame", {isolation: "iframe", frame}, {document: [], isolated: ["ui.css"]}) - ).toThrow('Entrypoint "frame" uses ?isolation CSS with frame.page/frame.src'); + validateContentStyles( + "frame", + {isolation: {type: "iframe", ...frame}}, + {document: [], isolated: ["ui.css"]} + ) + ).toThrow('Entrypoint "frame" uses ?isolation CSS with isolation.page/isolation.src'); } ); }); @@ -44,15 +50,15 @@ describe("createPageAccessRequirements", () => { [ "frame.content", { - isolation: "iframe", - frame: {page: "panel"}, + isolation: {type: "iframe", page: "panel"}, + matches: ["https://*.example.com/*"], excludeMatches: ["https://private.example.com/*"], }, ], - ["default.relay", {isolation: "iframe", frame: {page: "panel"}}], - ["source.content", {isolation: "iframe", frame: {src: "https://example.com/panel"}}], - ["shadow.content", {isolation: "shadow"}], + ["default.relay", {isolation: {type: "iframe", page: "panel"}}], + ["source.content", {isolation: {type: "iframe", src: "https://example.com/panel"}}], + ["shadow.content", {isolation: {type: "shadow"}}], ]); const requirements = createPageAccessRequirements(entries, new Map([["panel", "views/custom-panel.html"]])); expect(requirements).toEqual([ @@ -74,7 +80,7 @@ describe("createPageAccessRequirements", () => { test("reports an unknown alias with its entrypoint", () => { expect(() => createPageAccessRequirements( - new Map([["frame.content", {isolation: "iframe", frame: {page: "missing"}}]]), + new Map([["frame.content", {isolation: {type: "iframe", page: "missing"}}]]), new Map() ) ).toThrow('Content entrypoint "frame.content" references unknown page "missing"'); diff --git a/src/cli/plugins/content/bundler.ts b/src/cli/plugins/content/bundler.ts index b7375def..05185dab 100644 --- a/src/cli/plugins/content/bundler.ts +++ b/src/cli/plugins/content/bundler.ts @@ -13,14 +13,14 @@ export const validateContentStyles = ( files: IsolatedStylesPluginFiles ): readonly string[] => { if (!options) return []; - if (files.isolated.length > 0 && isContentScriptFrameNavigation(options.frame)) { + if (files.isolated.length > 0 && isContentScriptFrameNavigation(options.isolation)) { throw new Error( - `Entrypoint "${entry}" uses ?isolation CSS with frame.page/frame.src, but has no local render target. Import these styles in the embedded page instead.` + `Entrypoint "${entry}" uses ?isolation CSS with isolation.page/isolation.src, but has no local render target. Import these styles in the embedded page instead.` ); } if (hasIsolatedTarget(options) && files.document.length > 0 && files.isolated.length === 0) { return [ - `[adnbn:missing-isolation-css] Entrypoint "${entry}" uses isolation: "${options.isolation}", but has no CSS marked with ?isolation. Imported CSS will be delivered to the page document. If these styles belong to the isolated UI, import them with ?isolation. Document-only styles are valid; this warning can be suppressed with bundler ignoreWarnings.`, + `[adnbn:missing-isolation-css] Entrypoint "${entry}" uses isolation: "${options.isolation?.type}", but has no CSS marked with ?isolation. Imported CSS will be delivered to the page document. If these styles belong to the isolated UI, import them with ?isolation. Document-only styles are valid; this warning can be suppressed with bundler ignoreWarnings.`, ]; } return []; @@ -33,7 +33,7 @@ export const createPageAccessRequirements = ( const requirements: ResourceAccessPluginRequirement[] = []; for (const [entry, options] of entries) { - const alias = options.frame?.page; + const alias = options.isolation?.page; if (alias === undefined) continue; const resource = pages.get(alias); diff --git a/src/cli/plugins/content/index.ts b/src/cli/plugins/content/index.ts index 4bf4f4f3..34adfe25 100644 --- a/src/cli/plugins/content/index.ts +++ b/src/cli/plugins/content/index.ts @@ -51,20 +51,25 @@ export default definePlugin(() => { relayDeclaration.dictionary(await relayProvider.dictionary()).build(); let entryOptionsByName = await contentManager.entryOptions(); + const getRelayData = async (): Promise => { // Preserve the previous JSON payload: absent optional values must not become undefined data. return JSON.parse(JSON.stringify(await relayProvider.getOptionsMap())); }; + const relayDataPlugin = new RuntimeDataPlugin({ property: RelayOptionsRuntimeProperty, data: await getRelayData(), }); + const basePlugins: Plugins = [ relayDataPlugin, new ResourceAccessPlugin({ requirements: async () => { if ( - !Array.from(entryOptionsByName.values()).some(options => options.frame?.page !== undefined) + !Array.from(entryOptionsByName.values()).some( + options => options.isolation?.page !== undefined + ) ) { return []; } diff --git a/src/cli/plugins/content/utils.test.ts b/src/cli/plugins/content/utils.test.ts index dd37c387..0c312e28 100644 --- a/src/cli/plugins/content/utils.test.ts +++ b/src/cli/plugins/content/utils.test.ts @@ -87,21 +87,24 @@ describe("content utils - getContentScriptConfigFromOptions", () => { describe("content render targets", () => { test.each([ - {isolation: "shadow"}, - {isolation: "iframe"}, - {isolation: "iframe", frame: {height: 300}}, + {isolation: {type: "shadow"}}, + {isolation: {type: "iframe"}}, + {isolation: {type: "iframe", height: 300}}, ])("provides an isolated local target for %j", options => { expect(hasIsolatedTarget(options)).toBe(true); }); test.each([ - {isolation: "iframe", frame: {page: "panel"}}, - {isolation: "iframe", frame: {src: "https://example.com/panel"}}, + {isolation: {type: "iframe", page: "panel"}}, + {isolation: {type: "iframe", src: "https://example.com/panel"}}, ])("leaves document ownership to the frame for %j", options => { expect(hasIsolatedTarget(options)).toBe(false); }); - test.each([{}, {isolation: "none"}])("has no isolated target for %j", options => { - expect(hasIsolatedTarget(options)).toBe(false); - }); + test.each([{}, {isolation: {type: "none"}}])( + "has no isolated target for %j", + options => { + expect(hasIsolatedTarget(options)).toBe(false); + } + ); }); diff --git a/src/cli/plugins/content/utils.ts b/src/cli/plugins/content/utils.ts index 6409072f..f55675d0 100644 --- a/src/cli/plugins/content/utils.ts +++ b/src/cli/plugins/content/utils.ts @@ -5,8 +5,8 @@ import {ContentScriptConfig, ContentScriptEntrypointOptions, ContentScriptIsolat /** ShadowRoot or a blank iframe provides a local target for isolated styles and UI. */ export const hasIsolatedTarget = (options: ContentScriptEntrypointOptions): boolean => - options.isolation === ContentScriptIsolation.Shadow || - (options.isolation === ContentScriptIsolation.Iframe && !isContentScriptFrameNavigation(options.frame)); + options.isolation?.type === ContentScriptIsolation.Shadow || + (options.isolation?.type === ContentScriptIsolation.Iframe && !isContentScriptFrameNavigation(options.isolation)); export const getContentScriptConfigFromOptions = (options: ContentScriptEntrypointOptions): ContentScriptConfig => { const config = _.pick(options, [ diff --git a/src/cli/virtual/virtual.d.ts b/src/cli/virtual/virtual.d.ts index 838377d0..244f5f7e 100644 --- a/src/cli/virtual/virtual.d.ts +++ b/src/cli/virtual/virtual.d.ts @@ -67,7 +67,6 @@ declare module "virtual:relay-entrypoint" { name, method, isolation, - frame, allFrames, matches, excludeMatches, diff --git a/src/entry/content/README.md b/src/entry/content/README.md index 52a9fbfe..9a058242 100644 --- a/src/entry/content/README.md +++ b/src/entry/content/README.md @@ -10,7 +10,7 @@ and their tests live together: - `core/context`: the node collection, lifecycle operations, and event subscriptions. - `core/resolvers`: normalization of content definitions and option handlers. - `adapters/react` and `adapters/vanilla`: UI rendering implementations. -- `frame`: document navigation for `frame.page` and `frame.src`, without a UI renderer. +- `frame`: document navigation for `isolation.page` and `isolation.src`, without a UI renderer. Pure structural validation and the frame-navigation predicate live in `src/shared/content/isolation.ts`. CLI and runtime import this shared module independently; the parser does not import runtime resolvers. @@ -22,7 +22,8 @@ A blank iframe still uses its React/Vanilla adapter with the shared `FrameNode`. ## Isolation `isolation` accepts `ContentScriptIsolation.None`, `Shadow`, or `Iframe`, or their string values -`"none"`, `"shadow"`, and `"iframe"`. The default is `None`. Isolation changes the render target; +`"none"`, `"shadow"`, and `"iframe"`. For additional options, use an object with a required `type` +containing the same enum or string value. The default is `None`. Isolation changes the render target; existing anchors, containers, mount/append placement, and context methods remain available. ```tsx title="src/panel.content/index.tsx" @@ -33,20 +34,19 @@ import Panel from "./Panel"; export default defineContentScriptAppend({ matches: ["https://example.com/*"], anchor: ".product", - isolation: ContentScriptIsolation.Iframe, - frame: {height: 320}, + isolation: {type: ContentScriptIsolation.Iframe, height: 320}, render: Panel, }); ``` -| Mode | Target | `frame` | -| ----------------------- | ---------------------------------------------------- | -------------------- | -| None | Host container | Forbidden | -| Shadow | Inner element in an open ShadowRoot | Forbidden | -| Iframe without page/src | Inner element in a blank iframe document | Optional dimensions | -| Iframe with page/src | Embedded document owns its UI; `render` is forbidden | Required page or src | +| Type | Target | Additional isolation options | +| ----------------------- | ---------------------------------------------------- | ------------------------------------ | +| None | Host container | None | +| Shadow | Inner element in a ShadowRoot | Optional `mode`, default `open` | +| Iframe without page/src | Inner element in a blank iframe document | Optional `width` and `height` | +| Iframe with page/src | Embedded document owns its UI; `render` is forbidden | `page` or `src`, optional dimensions | -`frame.width` and `frame.height` accept pixels as numbers or CSS strings. Defaults are `100%` and +`isolation.width` and `isolation.height` accept pixels as numbers or CSS strings. Defaults are `100%` and `150px`, with no border and `display: block`. Automatic height is not implemented; `height: "auto"` produces an error. The blank iframe has no `src` attribute. The framework creates the host and target, so a custom `container` factory is optional. @@ -55,6 +55,33 @@ Import UI styles with `?isolation` and declare fonts in CSS as described below. same isolation and frame variants; its RPC transport and all-frame addressing remain independent of UI isolation. +## Shadow options + +Set `mode` inside an isolation object with `type: Shadow`, in content scripts or Relay: + +```tsx title="src/panel.content/index.tsx" +import {ContentScriptIsolation, ContentScriptShadowMode, defineContentScriptAppend} from "adnbn"; +import "./panel.css?isolation"; +import Panel from "./Panel"; + +export default defineContentScriptAppend({ + isolation: {type: ContentScriptIsolation.Shadow, mode: ContentScriptShadowMode.Closed}, + render: Panel, +}); +``` + +`mode` accepts `ContentScriptShadowMode.Open` / `Closed` and the string literals `"open"` / `"closed"`. +The shorthand `isolation: "shadow"` and `{type: "shadow"}` both keep the default `open` behavior. +`mode` is only valid for Shadow; iframe dimensions and page/src are only valid for Iframe. + +In closed mode, `host.shadowRoot` returns `null`. The framework retains its own reference, so +`node.target`, initial and lazy CSS, renderer cleanup, and remount still work. The mode is selected +when creating the root; changing an already mounted root's mode is not supported. + +Closed mode does not hide the UI, change CSS isolation, or provide a security boundary. Composed +events can still reach the page; their external `composedPath()` omits closed-root internals. +Code that needs the rendered element should use the framework's `target`, not query `host.shadowRoot`. + ## Embedding a document ```ts title="src/panel-frame.content.ts" @@ -62,8 +89,7 @@ import {defineContentScriptAppend} from "adnbn"; export default defineContentScriptAppend({ matches: ["https://example.com/*"], - isolation: "iframe", - frame: {page: "panel", height: 320}, + isolation: {type: "iframe", page: "panel", height: 320}, }); ``` @@ -74,23 +100,28 @@ Rules can come from `page.matches` or custom manifest configuration and jointly Insufficient coverage fails the build with a hint to add page matches or narrow content matches; the framework does not broaden access automatically. -Use `frame: {src: "https://example.com/panel"}` for an absolute HTTP(S) URL. Absolute extension URLs +Use `isolation: {type: "iframe", src: "https://example.com/panel"}` for an absolute HTTP(S) URL. Absolute extension URLs are also accepted. Relative paths and other schemes are unsupported. `page` and `src` are mutually exclusive and cannot be combined with `render`. The child page loads its own scripts, styles and fonts. A frame `load` event or the context's `Mount` event does not prove that embedding succeeded: host CSP, destination CSP/frame-ancestors or X-Frame-Options can block it. For content scripts, a default-exported component or render function also conflicts with -`frame.page`/`frame.src` and is checked during the build. A default-exported options object remains +`isolation.page`/`isolation.src` and is checked during the build. A default-exported options object remains configuration. A Relay's default function is `init`, not an implicit renderer. -Isolation values and frame routing must be statically known. Write `frame` as an explicit object -or a local constant holding one, without spreads or methods; page/src can reference statically resolvable string constants. +Isolation options must be statically known. Objects and arrays can reference local or imported +constants and supported enum members. Static object/array spreads and computed string or numeric +keys are supported. Unresolved identifiers, function calls, methods and circular references in build +options produce an error naming the field and source file; the CLI never executes entrypoint code +to compute these values. Only build properties are evaluated, so runtime functions such as `render` +and `main` remain untouched. CLI and runtime both normalize shorthand values to an object +with `type` and the mode/dimension defaults; runtime rendering values are not evaluated by the CLI. ## Execution worlds and lifecycle -Shadow, blank iframe, extension URLs and `frame.page` require effective `ISOLATED`. HTTP(S) -`frame.src` also supports `MAIN`. MV2 normalizes requested `MAIN` to `ISOLATED` with a build warning +Shadow, blank iframe, extension URLs and `isolation.page` require effective `ISOLATED`. HTTP(S) +`isolation.src` also supports `MAIN`. MV2 normalizes requested `MAIN` to `ISOLATED` with a build warning before grouping and bundling. Unsupported MV3 combinations fail the build. In a blank iframe, React/Vanilla JavaScript still executes in the content-script runtime and renders @@ -144,7 +175,7 @@ CSS file in `initial.css` / `async.css`. The isolated styles runtime owns CSS ro With `isolation: None`, marked CSS loads normally into the page. Outside content and Relay, including popup and page entries, `?isolation` has no routing effect and adds no isolated-style runtime. -`frame.page`/`frame.src` entries have no local render target: importing `?isolation` CSS there is a build +`isolation.page`/`isolation.src` entries have no local render target: importing `?isolation` CSS there is a build error. Import the styles in the embedded page instead. That page's own CSS behaves normally. The build reports `[adnbn:missing-isolation-css]` when a Shadow or blank-iframe content/Relay entry diff --git a/src/entry/content/adapters/react/Node.test.ts b/src/entry/content/adapters/react/Node.test.ts index bd80b17e..d2794f2f 100644 --- a/src/entry/content/adapters/react/Node.test.ts +++ b/src/entry/content/adapters/react/Node.test.ts @@ -2,7 +2,7 @@ import {createRoot} from "react-dom/client"; import ReactNode from "./Node"; -import type {ContentScriptNode} from "@typing/content"; +import {ContentScriptShadowMode, type ContentScriptNode} from "@typing/content"; const render = jest.fn(); const unmount = jest.fn(); @@ -17,28 +17,32 @@ describe("ReactNode", () => { jest.mocked(createRoot).mockReturnValue({render, unmount} as never); }); - test("renders into the target while preserving the outer host", () => { - const anchor = document.createElement("div"); - const container = document.createElement("section"); - const target = document.createElement("div"); - const node: ContentScriptNode = { - anchor, - container, - target, - mount: jest.fn(() => true), - unmount: jest.fn(() => true), - }; - const component = "content"; - const reactNode = new ReactNode(node, component); - - expect(reactNode.mount()).toBe(true); - expect(node.mount).toHaveBeenCalledTimes(1); - expect(createRoot).toHaveBeenCalledWith(target); - expect(createRoot).not.toHaveBeenCalledWith(container); - expect(render).toHaveBeenCalledWith(component); - - expect(reactNode.unmount()).toBe(true); - expect(unmount).toHaveBeenCalledTimes(1); - expect(node.unmount).toHaveBeenCalledTimes(1); - }); + test.each([undefined, ContentScriptShadowMode.Open, ContentScriptShadowMode.Closed])( + "renders into the target while preserving the outer host in %s mode", + mode => { + const anchor = document.createElement("div"); + const container = document.createElement("section"); + const target = document.createElement("div"); + if (mode) container.attachShadow({mode}).appendChild(target); + const node: ContentScriptNode = { + anchor, + container, + target, + mount: jest.fn(() => true), + unmount: jest.fn(() => true), + }; + const component = "content"; + const reactNode = new ReactNode(node, component); + + expect(reactNode.mount()).toBe(true); + expect(node.mount).toHaveBeenCalledTimes(1); + expect(createRoot).toHaveBeenCalledWith(target); + expect(createRoot).not.toHaveBeenCalledWith(container); + expect(render).toHaveBeenCalledWith(component); + + expect(reactNode.unmount()).toBe(true); + expect(unmount).toHaveBeenCalledTimes(1); + expect(node.unmount).toHaveBeenCalledTimes(1); + } + ); }); diff --git a/src/entry/content/adapters/vanilla/Node.test.ts b/src/entry/content/adapters/vanilla/Node.test.ts index b31364d2..74eee873 100644 --- a/src/entry/content/adapters/vanilla/Node.test.ts +++ b/src/entry/content/adapters/vanilla/Node.test.ts @@ -1,28 +1,32 @@ import VanillaNode from "./Node"; -import type {ContentScriptNode} from "@typing/content"; +import {ContentScriptShadowMode, type ContentScriptNode} from "@typing/content"; describe("VanillaNode", () => { - test("renders into the target while preserving the outer host", () => { - const anchor = document.createElement("div"); - const container = document.createElement("section"); - const target = document.createElement("div"); - const value = document.createElement("span"); - const node: ContentScriptNode = { - anchor, - container, - target, - mount: jest.fn(() => true), - unmount: jest.fn(() => true), - }; - const vanillaNode = new VanillaNode(node, value); + test.each([undefined, ContentScriptShadowMode.Open, ContentScriptShadowMode.Closed])( + "renders into the target while preserving the outer host in %s mode", + mode => { + const anchor = document.createElement("div"); + const container = document.createElement("section"); + const target = document.createElement("div"); + if (mode) container.attachShadow({mode}).appendChild(target); + const value = document.createElement("span"); + const node: ContentScriptNode = { + anchor, + container, + target, + mount: jest.fn(() => true), + unmount: jest.fn(() => true), + }; + const vanillaNode = new VanillaNode(node, value); - expect(vanillaNode.mount()).toBe(true); - expect(node.mount).toHaveBeenCalledTimes(1); - expect(target.contains(value)).toBe(true); - expect(container.contains(value)).toBe(false); + expect(vanillaNode.mount()).toBe(true); + expect(node.mount).toHaveBeenCalledTimes(1); + expect(target.contains(value)).toBe(true); + expect(container.contains(value)).toBe(false); - expect(vanillaNode.unmount()).toBe(true); - expect(node.unmount).toHaveBeenCalledTimes(1); - }); + expect(vanillaNode.unmount()).toBe(true); + expect(node.unmount).toHaveBeenCalledTimes(1); + } + ); }); diff --git a/src/entry/content/core/Builder.ts b/src/entry/content/core/Builder.ts index 7e858e50..f609f918 100644 --- a/src/entry/content/core/Builder.ts +++ b/src/entry/content/core/Builder.ts @@ -1,4 +1,4 @@ -import {isContentScriptFrameNavigation, validateContentScriptIsolation} from "@shared/content"; +import {isContentScriptFrameNavigation, resolveContentScriptIsolation} from "@shared/content"; import AwaitLock from "await-lock"; import Builder from "@entry/core/Builder"; @@ -37,7 +37,6 @@ import { ContentScriptRenderHandler, ContentScriptRenderValue, ContentScriptResolvedDefinition, - ContentScriptIsolation, ContentScriptWatchStrategy, } from "@typing/content"; @@ -63,7 +62,7 @@ export default abstract class extends Builder implements ContentScriptBuilder { protected constructor(definition: ContentScriptDefinition) { super(); - validateContentScriptIsolation(definition.isolation, definition.frame, "render" in definition); + const isolation = resolveContentScriptIsolation(definition.isolation, "render" in definition); this.definition = { ...definition, @@ -72,7 +71,7 @@ export default abstract class extends Builder implements ContentScriptBuilder { mount: this.resolveMount(definition.mount), container: this.resolveContainer(definition.container), render: this.resolveRender(definition.render), - isolation: definition.isolation ?? ContentScriptIsolation.None, + isolation, watch: this.resolveWatch(definition.watch), }; } @@ -149,7 +148,7 @@ export default abstract class extends Builder implements ContentScriptBuilder { await main?.(this.context, options); - if (render !== undefined || isContentScriptFrameNavigation(this.definition.frame)) { + if (render !== undefined || isContentScriptFrameNavigation(this.definition.isolation)) { await this.processing(); this.unwatch = watch(() => { diff --git a/src/entry/content/core/MountBuilder.ts b/src/entry/content/core/MountBuilder.ts index 8e5387da..41dc56b6 100644 --- a/src/entry/content/core/MountBuilder.ts +++ b/src/entry/content/core/MountBuilder.ts @@ -12,7 +12,7 @@ export default abstract class extends Builder { private values = new Map(); protected getProps(anchor: Element): ContentScriptProps { - const {anchor: _, mount, watch, render, container, main, isolation, frame, ...options} = this.definition; + const {anchor: _, mount, watch, render, container, main, isolation, ...options} = this.definition; return {...options, anchor}; } @@ -43,7 +43,7 @@ export default abstract class extends Builder { const value = await this.getValue(anchor); if ( - isContentScriptFrameNavigation(this.definition.frame) || + isContentScriptFrameNavigation(this.definition.isolation) || (typeof value !== "boolean" && value !== undefined) ) { container = (await this.definition.container(this.getProps(anchor))) as Element | undefined; @@ -51,11 +51,11 @@ export default abstract class extends Builder { const node = new MountNode(new MarkerNode(new Node(anchor, container), this.marker), this.definition.mount); - switch (this.definition.isolation) { + switch (this.definition.isolation.type) { case ContentScriptIsolation.Shadow: - return new ShadowNode(node); + return new ShadowNode(node, this.definition.isolation); case ContentScriptIsolation.Iframe: - return new FrameNode(node, this.definition.frame, () => this.context.mount()); + return new FrameNode(node, this.definition.isolation, () => this.context.mount()); default: return node; } diff --git a/src/entry/content/core/nodes/FrameNode.test.ts b/src/entry/content/core/nodes/FrameNode.test.ts index 332f58c5..cce6cbb6 100644 --- a/src/entry/content/core/nodes/FrameNode.test.ts +++ b/src/entry/content/core/nodes/FrameNode.test.ts @@ -15,7 +15,7 @@ test("FrameNode explains a disconnected host and allows mounting again once the new MountNode(new Node(anchor, container), (anchor, container) => { if (connect) anchor.append(container); }), - {}, + {type: "iframe"}, () => {} ); try { @@ -38,7 +38,7 @@ test("FrameNode does not require a connected host for document navigation", () = const container = document.createElement("div"); const node = new FrameNode( new Node(document.createElement("section"), container), - {src: "https://example.com"}, + {type: "iframe", src: "https://example.com"}, () => {} ); try { @@ -60,7 +60,7 @@ test("FrameNode replaces lost targets, unregisters old styles and releases its l new MountNode(new Node(anchor, document.createElement("div")), (anchor, container) => { anchor.append(container); }), - {height: 320}, + {type: "iframe", height: 320}, recover ); node.mount(); diff --git a/src/entry/content/core/nodes/FrameNode.ts b/src/entry/content/core/nodes/FrameNode.ts index 62ecdb19..7fa35f01 100644 --- a/src/entry/content/core/nodes/FrameNode.ts +++ b/src/entry/content/core/nodes/FrameNode.ts @@ -1,10 +1,11 @@ +import {ContentScriptIsolation} from "@typing/content"; import {getPageUrl} from "@main/page"; import {getContentScriptStylesRuntime} from "./isolated-styles"; import {isContentScriptFrameNavigation} from "@shared/content"; -import type {ContentScriptStylesRuntime, ContentScriptFrame, ContentScriptNode} from "@typing/content"; +import type {ContentScriptStylesRuntime, ContentScriptIsolationFrameOptions, ContentScriptNode} from "@typing/content"; /** Owns the child document, but never moves the content script's JavaScript into it. */ export default class FrameNode implements ContentScriptNode { @@ -17,7 +18,7 @@ export default class FrameNode implements ContentScriptNode { public constructor( private readonly node: ContentScriptNode, - private readonly options: ContentScriptFrame = {}, + private readonly options: ContentScriptIsolationFrameOptions = {type: ContentScriptIsolation.Iframe}, private readonly recover: () => void ) {} @@ -25,7 +26,6 @@ export default class FrameNode implements ContentScriptNode { return this.node.anchor; } - public get container(): Element | undefined { return this.node.container; } diff --git a/src/entry/content/core/nodes/ShadowNode.test.ts b/src/entry/content/core/nodes/ShadowNode.test.ts index eaf04b67..e4fbf01c 100644 --- a/src/entry/content/core/nodes/ShadowNode.test.ts +++ b/src/entry/content/core/nodes/ShadowNode.test.ts @@ -2,13 +2,21 @@ import MountNode from "./MountNode"; import Node from "./Node"; import ShadowNode from "./ShadowNode"; import {getContentScriptStylesRuntime} from "./isolated-styles"; -import type {ContentScriptStylesRuntime} from "@typing/content"; +import { + ContentScriptShadowMode, + type ContentScriptShadowOptions, + type ContentScriptStylesRuntime, +} from "@typing/content"; jest.mock("./isolated-styles", () => ({ getContentScriptStylesRuntime: jest.fn(), })); -const createNode = (runtime: ContentScriptStylesRuntime, host = document.createElement("section")) => { +const createNode = ( + runtime: ContentScriptStylesRuntime, + host = document.createElement("section"), + options?: ContentScriptShadowOptions +) => { const anchor = document.createElement("div"); document.body.appendChild(anchor); jest.mocked(getContentScriptStylesRuntime).mockReturnValue(runtime); @@ -19,7 +27,8 @@ const createNode = (runtime: ContentScriptStylesRuntime, host = document.createE node: new ShadowNode( new MountNode(new Node(anchor, host), (_anchor, container) => { anchor.appendChild(container); - }) + }), + options ), }; }; @@ -30,48 +39,86 @@ describe("ShadowNode", () => { jest.clearAllMocks(); }); - test("creates an open root, registers it with the styles runtime and exposes the render target", () => { - const runtime: ContentScriptStylesRuntime = { - initialize: jest.fn(), - add: jest.fn(), - delete: jest.fn(), - load: jest.fn(async () => undefined), - }; - const {host, node} = createNode(runtime); + test.each([undefined, {}, {mode: ContentScriptShadowMode.Open}, {mode: ContentScriptShadowMode.Closed}])( + "registers the root and exposes the target with options %j", + options => { + const runtime: ContentScriptStylesRuntime = { + initialize: jest.fn(), + add: jest.fn(), + delete: jest.fn(), + load: jest.fn(async () => undefined), + }; + const {host, node} = createNode(runtime, undefined, options); - expect(node.mount()).toBe(true); - expect(host.shadowRoot).not.toBeNull(); - expect(node.target).toBe(host.shadowRoot?.lastElementChild); - expect(node.target).not.toBe(host); - expect(runtime.add).toHaveBeenCalledWith(host.shadowRoot, node.target); - expect(Array.from(host.shadowRoot?.children ?? []).map(element => element.tagName)).toEqual(["DIV"]); - }); + expect(node.mount()).toBe(true); + const root = node.target!.getRootNode() as ShadowRoot; + expect(root.mode).toBe(options?.mode ?? ContentScriptShadowMode.Open); + expect(host.shadowRoot).toBe(options?.mode === ContentScriptShadowMode.Closed ? null : root); + expect(node.target).toBe(root.lastElementChild); + expect(node.target).not.toBe(host); + expect(runtime.add).toHaveBeenCalledWith(root, node.target); + expect(Array.from(root.children).map(element => element.tagName)).toEqual(["DIV"]); - test("unregisters the root before removing the host and creates a new root on remount", () => { - const states: boolean[] = []; - const runtime: ContentScriptStylesRuntime = { - initialize: jest.fn(), - add: jest.fn(), - delete: jest.fn(root => states.push((root as ShadowRoot).host.isConnected)), - load: jest.fn(async () => undefined), - }; - const {anchor, node} = createNode(runtime); + node.mount(); + expect(runtime.add).toHaveBeenCalledTimes(1); + } + ); - node.mount(); - const firstHost = node.container; - const firstRoot = firstHost?.shadowRoot; + test.each([ContentScriptShadowMode.Open, ContentScriptShadowMode.Closed])( + "unregisters %s before removing the host and creates a new root on remount", + mode => { + const states: boolean[] = []; + const runtime: ContentScriptStylesRuntime = { + initialize: jest.fn(), + add: jest.fn(), + delete: jest.fn(root => states.push((root as ShadowRoot).host.isConnected)), + load: jest.fn(async () => undefined), + }; + const {anchor, node} = createNode(runtime, undefined, {mode}); - expect(node.unmount()).toBe(true); - expect(states).toEqual([true]); - expect(firstHost?.isConnected).toBe(false); + node.mount(); + const firstHost = node.container; + const firstRoot = node.target!.getRootNode(); - expect(node.mount()).toBe(true); - expect(node.container).not.toBe(firstHost); - expect(node.container?.shadowRoot).not.toBe(firstRoot); - expect(node.target?.parentNode).toBe(node.container?.shadowRoot); - expect(anchor.contains(node.container ?? null)).toBe(true); - expect(runtime.add).toHaveBeenCalledTimes(2); - }); + expect(node.unmount()).toBe(true); + expect(states).toEqual([true]); + expect(runtime.delete).toHaveBeenCalledWith(firstRoot); + expect(node.target).toBeUndefined(); + expect(firstHost?.isConnected).toBe(false); + + expect(node.mount()).toBe(true); + expect(node.container).not.toBe(firstHost); + const nextRoot = node.target!.getRootNode() as ShadowRoot; + expect(nextRoot).not.toBe(firstRoot); + expect(nextRoot.mode).toBe(mode); + expect(node.target?.parentNode).toBe(nextRoot); + expect(anchor.contains(node.container ?? null)).toBe(true); + expect(runtime.add).toHaveBeenCalledTimes(2); + } + ); + + test.each([ContentScriptShadowMode.Open, ContentScriptShadowMode.Closed])( + "preserves event propagation while encapsulating the %s event path", + mode => { + const runtime: ContentScriptStylesRuntime = { + initialize: jest.fn(), + add: jest.fn(), + delete: jest.fn(), + load: jest.fn(async () => undefined), + }; + const {host, node} = createNode(runtime, undefined, {mode}); + node.mount(); + const button = document.createElement("button"); + node.target!.appendChild(button); + let eventPath: EventTarget[] = []; + host.addEventListener("click", event => { + eventPath = event.composedPath(); + }); + button.click(); + expect(eventPath).toContain(host); + expect(eventPath.includes(button)).toBe(mode === ContentScriptShadowMode.Open); + } + ); test("rejects a container with an existing open ShadowRoot", () => { const runtime: ContentScriptStylesRuntime = { @@ -87,4 +134,19 @@ describe("ShadowNode", () => { expect(() => node.mount()).toThrow("Content script container already has an open ShadowRoot"); expect(runtime.add).not.toHaveBeenCalled(); }); + + test("reports an existing closed root without exposing it or registering styles", () => { + const runtime: ContentScriptStylesRuntime = { + initialize: jest.fn(), + add: jest.fn(), + delete: jest.fn(), + load: jest.fn(async () => undefined), + }; + const host = document.createElement("section"); + host.attachShadow({mode: ContentScriptShadowMode.Closed}); + const {node} = createNode(runtime, host, {mode: ContentScriptShadowMode.Closed}); + expect(() => node.mount()).toThrow(/Cannot attach ShadowRoot/); + expect(runtime.add).not.toHaveBeenCalled(); + expect(node.target).toBeUndefined(); + }); }); diff --git a/src/entry/content/core/nodes/ShadowNode.ts b/src/entry/content/core/nodes/ShadowNode.ts index 0365ce57..20551d95 100644 --- a/src/entry/content/core/nodes/ShadowNode.ts +++ b/src/entry/content/core/nodes/ShadowNode.ts @@ -1,7 +1,10 @@ import {getContentScriptStylesRuntime} from "./isolated-styles"; -import type {ContentScriptStylesRuntime} from "@typing/content"; - -import type {ContentScriptNode} from "@typing/content"; +import { + ContentScriptShadowMode, + type ContentScriptShadowOptions, + type ContentScriptStylesRuntime, + type ContentScriptNode, +} from "@typing/content"; export default class ShadowNode implements ContentScriptNode { private root?: ShadowRoot; @@ -10,7 +13,10 @@ export default class ShadowNode implements ContentScriptNode { private runtime?: ContentScriptStylesRuntime; - public constructor(protected readonly node: ContentScriptNode) {} + public constructor( + protected readonly node: ContentScriptNode, + private readonly options: ContentScriptShadowOptions = {} + ) {} public get anchor(): Element { return this.node.anchor; @@ -39,7 +45,16 @@ export default class ShadowNode implements ContentScriptNode { throw new Error("Content script container already has an open ShadowRoot"); } - const root = this.container.attachShadow({mode: "open"}); + let root: ShadowRoot; + try { + root = this.container.attachShadow({mode: this.options.mode ?? ContentScriptShadowMode.Open}); + } catch (cause) { + // A pre-existing closed root is not observable through container.shadowRoot. + throw new Error( + "Cannot attach ShadowRoot: the content script container may already have a root or not support Shadow DOM", + {cause} + ); + } const target = this.container.ownerDocument.createElement("div"); root.appendChild(target); diff --git a/src/entry/relay/Builder.ts b/src/entry/relay/Builder.ts index 0ab3f8ea..f58a3688 100644 --- a/src/entry/relay/Builder.ts +++ b/src/entry/relay/Builder.ts @@ -1,3 +1,5 @@ +import {resolveContentScriptIsolation} from "@shared/content"; + import TransportBuilder from "./TransportBuilder"; import Builder from "../core/Builder"; @@ -35,7 +37,10 @@ export default class extends Builder { throw new Error("Content script builder not set"); } - await main(this._transport.get(), this._content.getContext(), this.definition); + await main(this._transport.get(), this._content.getContext(), { + ...this.definition, + isolation: resolveContentScriptIsolation(this.definition.isolation, "render" in this.definition), + }); } } diff --git a/src/shared/content/index.ts b/src/shared/content/index.ts index ceee291f..33a2eb61 100644 --- a/src/shared/content/index.ts +++ b/src/shared/content/index.ts @@ -1 +1 @@ -export {isContentScriptFrameNavigation, validateContentScriptIsolation} from "./isolation"; +export {isContentScriptFrameNavigation, resolveContentScriptIsolation} from "./isolation"; diff --git a/src/shared/content/isolation.test.ts b/src/shared/content/isolation.test.ts index 558d4717..a0692cd5 100644 --- a/src/shared/content/isolation.test.ts +++ b/src/shared/content/isolation.test.ts @@ -1,15 +1,64 @@ /** @jest-environment node */ -import {isContentScriptFrameNavigation, validateContentScriptIsolation} from "./isolation"; -import type {ContentScriptFrame} from "@typing/content"; +import {isContentScriptFrameNavigation, resolveContentScriptIsolation} from "./isolation"; +import {ContentScriptIsolation, ContentScriptShadowMode} from "@typing/content"; -test.each<[ContentScriptFrame | undefined, boolean]>([ +test.each([ + [undefined, {type: "none"}], + ["none", {type: "none"}], + [{type: "none"}, {type: "none"}], + [ContentScriptIsolation.Shadow, {type: "shadow", mode: "open"}], + [{type: "shadow"}, {type: "shadow", mode: "open"}], + [ + {type: "shadow", mode: ContentScriptShadowMode.Closed}, + {type: "shadow", mode: "closed"}, + ], + ["iframe", {type: "iframe", width: "100%", height: 150}], + [ + {type: "iframe", height: 320}, + {type: "iframe", width: "100%", height: 320}, + ], + [ + {type: "iframe", page: "panel"}, + {type: "iframe", page: "panel", width: "100%", height: 150}, + ], +])("normalizes isolation %j without changing the supplied object", (input, expected) => { + if (input && typeof input === "object") Object.freeze(input); + expect(resolveContentScriptIsolation(input)).toEqual(expected); +}); + +test.each([ [undefined, false], - [{height: 320}, false], - [{page: "panel"}, true], - [{src: "https://example.com/panel"}, true], -])("recognizes frame navigation without DOM dependencies: %j", (frame, expected) => { - expect(isContentScriptFrameNavigation(frame)).toBe(expected); - expect(() => validateContentScriptIsolation("iframe", frame, false)).not.toThrow(); + ["iframe", false], + [{type: "iframe", height: 320}, false], + [{type: "iframe", page: "panel"}, true], + [{type: "iframe", src: "https://example.com/panel"}, true], + [{type: "shadow", page: "panel"}, false], +])("recognizes navigation independently from shorthand normalization: %j", (isolation, expected) => { + expect(isContentScriptFrameNavigation(isolation)).toBe(expected); if (expected) - expect(() => validateContentScriptIsolation("iframe", frame, true)).toThrow(/cannot be combined with render/); + expect(() => resolveContentScriptIsolation(isolation, true)).toThrow(/cannot be combined with render/); +}); + +test.each([ + [true, /isolation must be/], + [null, /isolation must be/], + [[], /isolation must be/], + [{}, /isolation.type must be/], + ["unknown", /isolation.type must be/], + [{type: "none", mode: "open"}, /isolation.mode is not supported/], + [{type: "iframe", mode: "closed"}, /isolation.mode is not supported/], + [{type: "shadow", height: 100}, /isolation.height is not supported/], + [{type: "shadow", mode: "close"}, /isolation.mode must be "open" or "closed"/], + [{type: "shadow", mode: false}, /isolation.mode must be/], + [{type: "shadow", delegatesFocus: true}, /isolation.delegatesFocus is not supported/], + [{type: "iframe", page: "panel", src: "https://example.com"}, /mutually exclusive/], + [{type: "iframe", page: ""}, /page alias/], + [{type: "iframe", src: "/relative"}, /absolute HTTP/], + [{type: "iframe", src: "javascript:alert(1)"}, /absolute HTTP/], + [{type: "iframe", src: "https://"}, /valid absolute URL/], + [{type: "iframe", width: -1}, /pixels or a CSS size/], + [{type: "iframe", height: Infinity}, /pixels or a CSS size/], + [{type: "iframe", height: " auto "}, /not supported yet/], +])("rejects invalid isolation %j", (isolation, error) => { + expect(() => resolveContentScriptIsolation(isolation)).toThrow(error as RegExp); }); diff --git a/src/shared/content/isolation.ts b/src/shared/content/isolation.ts index 8948d92a..aacd67c0 100644 --- a/src/shared/content/isolation.ts +++ b/src/shared/content/isolation.ts @@ -1,54 +1,92 @@ -import {ContentScriptIsolation, type ContentScriptFrame} from "@typing/content"; +import { + ContentScriptIsolation, + ContentScriptShadowMode, + type ContentScriptIsolationOptions, + type ContentScriptIsolationFrameOptions, + type ContentScriptFramePageOptions, + type ContentScriptFrameSourceOptions, +} from "@typing/content"; -export const isContentScriptFrameNavigation = (frame?: ContentScriptFrame): boolean => - frame?.page !== undefined || frame?.src !== undefined; +export const isContentScriptFrameNavigation = ( + isolation: unknown +): isolation is ContentScriptIsolationFrameOptions & + (ContentScriptFramePageOptions | ContentScriptFrameSourceOptions) => + !!isolation && + typeof isolation === "object" && + "type" in isolation && + isolation.type === ContentScriptIsolation.Iframe && + (("page" in isolation && isolation.page !== undefined) || ("src" in isolation && isolation.src !== undefined)); -/** Pure structural checks shared by CLI and runtime; no DOM or build dependencies. */ -export const validateContentScriptIsolation = (isolation: unknown, frame: unknown, hasRender: boolean): void => { - const mode = isolation ?? ContentScriptIsolation.None; - if (!Object.values(ContentScriptIsolation).includes(mode as ContentScriptIsolation)) { - throw new Error("isolation must be a statically known ContentScriptIsolation value: none, shadow or iframe"); +/** Pure normalization and structural validation, shared by CLI and runtime. */ +export const resolveContentScriptIsolation = (value: unknown, hasRender = false): ContentScriptIsolationOptions => { + const options = + value === undefined ? {type: ContentScriptIsolation.None} : typeof value === "string" ? {type: value} : value; + if (!options || typeof options !== "object" || Array.isArray(options)) { + throw new Error("isolation must be none, shadow, iframe, or an options object with type"); } - if (frame !== undefined && mode !== ContentScriptIsolation.Iframe) { - throw new Error("frame requires isolation: Iframe"); + const {type, mode, page, src, width, height} = options as Record; + if (!Object.values(ContentScriptIsolation).includes(type as ContentScriptIsolation)) { + throw new Error( + "isolation.type must be a statically known ContentScriptIsolation value: none, shadow or iframe" + ); } - if (frame === undefined) return; - if (!frame || typeof frame !== "object" || Array.isArray(frame)) { - throw new Error("frame must be an object with statically known page/src properties"); + const keys = + type === ContentScriptIsolation.Shadow + ? ["type", "mode"] + : type === ContentScriptIsolation.Iframe + ? ["type", "page", "src", "width", "height"] + : ["type"]; + for (const key of Object.keys(options)) { + if (!keys.includes(key)) throw new Error(`isolation.${key} is not supported for isolation type "${type}"`); } - const {page, src, width, height} = frame as Record; - if (page !== undefined && src !== undefined) throw new Error("frame.page and frame.src are mutually exclusive"); + if (type === ContentScriptIsolation.None) return {type}; + if (type === ContentScriptIsolation.Shadow) { + if (mode !== undefined && !Object.values(ContentScriptShadowMode).includes(mode as ContentScriptShadowMode)) { + throw new Error('isolation.mode must be "open" or "closed"'); + } + return {type, mode: (mode as ContentScriptShadowMode | undefined) ?? ContentScriptShadowMode.Open}; + } + if (page !== undefined && src !== undefined) + throw new Error("isolation.page and isolation.src are mutually exclusive"); if (page !== undefined && (typeof page !== "string" || !page.trim())) - throw new Error("frame.page must be a statically known page alias"); + throw new Error("isolation.page must be a statically known page alias"); if (src !== undefined) { if (typeof src !== "string" || !/^(https?|chrome-extension|moz-extension):\/\//.test(src)) { - throw new Error("frame.src must be an absolute HTTP(S) or extension URL"); + throw new Error("isolation.src must be an absolute HTTP(S) or extension URL"); } try { new URL(src); } catch { - throw new Error("frame.src must be a valid absolute URL"); + throw new Error("isolation.src must be a valid absolute URL"); } } if ((page !== undefined || src !== undefined) && hasRender) { throw new Error( - "frame.page/frame.src cannot be combined with render; the embedded page owns its UI and resources" + "isolation.page/isolation.src cannot be combined with render; the embedded page owns its UI and resources" ); } - for (const [name, value] of [ + for (const [name, size] of [ ["width", width], ["height", height], ] as const) { if ( - value !== undefined && + size !== undefined && !( - (typeof value === "number" && Number.isFinite(value) && value >= 0) || - (typeof value === "string" && value.trim()) + (typeof size === "number" && Number.isFinite(size) && size >= 0) || + (typeof size === "string" && size.trim()) ) - ) - throw new Error(`frame.${name} must be pixels or a CSS size`); + ) { + throw new Error(`isolation.${name} must be pixels or a CSS size`); + } } if (typeof height === "string" && height.trim().toLowerCase() === "auto") { - throw new Error('frame.height: "auto" is not supported yet; specify an explicit height'); + throw new Error('isolation.height: "auto" is not supported yet; specify an explicit height'); } + return { + type, + width: width ?? "100%", + height: height ?? 150, + ...(page === undefined ? {} : {page}), + ...(src === undefined ? {} : {src}), + } as ContentScriptIsolationFrameOptions; }; diff --git a/src/types/content.ts b/src/types/content.ts index e90a3259..37e798f6 100644 --- a/src/types/content.ts +++ b/src/types/content.ts @@ -105,7 +105,18 @@ export enum ContentScriptIsolation { Iframe = "iframe", } -export type ContentScriptIsolationValue = ContentScriptIsolation | `${ContentScriptIsolation}`; +export enum ContentScriptShadowMode { + Open = "open", + Closed = "closed", +} + +export interface ContentScriptShadowOptions { + /** + * Controls access through the host's shadowRoot property, not CSS isolation or UI visibility. + * @default ContentScriptShadowMode.Open + */ + mode?: ContentScriptShadowMode | `${ContentScriptShadowMode}`; +} export interface ContentScriptFrameOptions { width?: number | string; @@ -133,10 +144,42 @@ export type ContentScriptFrame = | ContentScriptFramePageOptions | ContentScriptFrameSourceOptions; +export interface ContentScriptIsolationNoneOptions { + type: ContentScriptIsolation.None | "none"; + mode?: never; + page?: never; + src?: never; + width?: never; + height?: never; +} + +export interface ContentScriptIsolationShadowOptions extends ContentScriptShadowOptions { + type: ContentScriptIsolation.Shadow | "shadow"; + page?: never; + src?: never; + width?: never; + height?: never; +} + +export type ContentScriptIsolationFrameOptions = ContentScriptFrame & { + type: ContentScriptIsolation.Iframe | "iframe"; + mode?: never; +}; + +/** Object form of isolation, also used after resolving shorthand values. */ +export type ContentScriptIsolationOptions = + | ContentScriptIsolationNoneOptions + | ContentScriptIsolationShadowOptions + | ContentScriptIsolationFrameOptions; + +export type ContentScriptIsolationValue = + | ContentScriptIsolation + | `${ContentScriptIsolation}` + | ContentScriptIsolationOptions; + /** Statically resolved configuration; runtime render values are never retained here. */ export type ContentScriptEntrypointOptions = Partial & { - isolation?: ContentScriptIsolationValue; - frame?: ContentScriptFrame; + isolation?: ContentScriptIsolationOptions; }; // Append @@ -316,23 +359,17 @@ export interface ContentScriptDefinitionBase extends Partial { - isolation: ContentScriptIsolationValue; - frame?: ContentScriptFrame; + isolation: ContentScriptIsolationOptions; marker: ContentScriptMarkerResolver; anchor: ContentScriptAnchorGetter; mount: ContentScriptMountFunction; diff --git a/src/types/relay.ts b/src/types/relay.ts index 2b887cbe..66dba6d8 100644 --- a/src/types/relay.ts +++ b/src/types/relay.ts @@ -199,8 +199,7 @@ export type RelayOptions = RelayConfig & EntrypointOptions; export type RelayOptionsMap = Map; -export type RelayEntrypointOptions = Partial & - Pick; +export type RelayEntrypointOptions = Partial & Pick; export type RelayMainHandler = ( relay: T, @@ -212,7 +211,7 @@ type RelayContentDefinition = T extends unknown ? O export type RelayDefinition = Omit, "main"> & RelayContentDefinition & - RelayEntrypointOptions & { + Partial & { main?: RelayMainHandler; }; diff --git a/tests/integration/browser/content/frame-pages/src/contract.ts b/tests/integration/browser/content/frame-pages/src/contract.ts index b3f3fa2c..83969dfb 100644 --- a/tests/integration/browser/content/frame-pages/src/contract.ts +++ b/tests/integration/browser/content/frame-pages/src/contract.ts @@ -1,24 +1,57 @@ -import {ContentScriptIsolation, type ContentScriptDefinition, getPageUrl, defineRelay} from "adnbn"; -const page: ContentScriptDefinition = {isolation: "iframe", frame: {page: "panel"}}; -const frame: ContentScriptDefinition = {isolation: ContentScriptIsolation.Iframe, frame: {height: 200}, render: "UI"}; -// @ts-expect-error frame requires iframe isolation -const noIsolation: ContentScriptDefinition = {frame: {page: "panel"}}; +import { + ContentScriptIsolation, + ContentScriptShadowMode, + type ContentScriptIsolationOptions, + type ContentScriptDefinition, + getPageUrl, + defineRelay, + defineContentScriptAppend, +} from "adnbn"; + +const page: ContentScriptDefinition = {isolation: {type: "iframe", page: "panel"}}; +const frame: ContentScriptDefinition = { + isolation: {type: ContentScriptIsolation.Iframe, height: 200}, + render: "UI", +}; +const shadow: ContentScriptIsolationOptions = {type: "shadow", mode: ContentScriptShadowMode.Closed}; +defineContentScriptAppend({isolation: shadow, render: "UI"}); +defineContentScriptAppend({isolation: "shadow", render: "UI"}); +defineContentScriptAppend({isolation: ContentScriptIsolation.Iframe, render: "UI"}); +defineContentScriptAppend({isolation: {type: "shadow", mode: "open"}}); +defineContentScriptAppend({isolation: {type: "shadow"}}); +defineContentScriptAppend({isolation: {type: "none"}, render: "UI"}); +defineContentScriptAppend({isolation: {type: "iframe", src: "https://example.com"}}); + +// @ts-expect-error isolation options need a discriminator +defineContentScriptAppend({isolation: {mode: "closed"}}); // @ts-expect-error embedded pages own their renderer -const render: ContentScriptDefinition = {isolation: "iframe", frame: {page: "panel"}, render: "UI"}; -// @ts-expect-error frame source variants are mutually exclusive -const both: ContentScriptDefinition = {isolation: "iframe", frame: {page: "panel", src: "https://example.com"}}; +const render: ContentScriptDefinition = {isolation: {type: "iframe", page: "panel"}, render: "UI"}; +// @ts-expect-error source variants are mutually exclusive +const both: ContentScriptDefinition = {isolation: {type: "iframe", page: "panel", src: "https://example.com"}}; // @ts-expect-error generated registry rejects an unknown page inside another public type -const unknown: ContentScriptDefinition = {isolation: "iframe", frame: {page: "missing"}}; +const unknown: ContentScriptDefinition = {isolation: {type: "iframe", page: "missing"}}; // @ts-expect-error generated registry also narrows the getter getPageUrl("missing"); -void [page, frame, noIsolation, render, both, unknown]; +// @ts-expect-error browser mode is closed, not close +defineContentScriptAppend({isolation: {type: "shadow", mode: "close"}}); +// @ts-expect-error dimensions belong to iframe +defineContentScriptAppend({isolation: {type: "shadow", height: 200}}); +// @ts-expect-error Shadow mode is not an iframe option +defineContentScriptAppend({isolation: {type: "iframe", mode: "closed"}}); +const misplaced = {type: ContentScriptIsolation.None, page: "panel"} as const; +// @ts-expect-error disallowed options are rejected even through an intermediate variable +defineContentScriptAppend({isolation: misplaced}); +const navigation = {type: ContentScriptIsolation.Iframe, src: "https://example.com"} as const; +// @ts-expect-error inferred object variables do not bypass the render prohibition +defineContentScriptAppend({isolation: navigation, render: "UI"}); +void [page, frame, render, both, unknown]; defineRelay({isolation: "iframe", init: () => ({})}); -defineRelay({isolation: "shadow", render: "UI", init: () => ({})}); -defineRelay({isolation: "iframe", frame: {page: "panel"}, init: () => ({})}); -// @ts-expect-error Relay frame requires iframe isolation -defineRelay({frame: {page: "panel"}, init: () => ({})}); +defineRelay({isolation: shadow, render: "UI", init: () => ({})}); +defineRelay({isolation: {type: "iframe", page: "panel"}, init: () => ({})}); +// @ts-expect-error Relay uses the same discriminator rules +defineRelay({isolation: {type: "iframe", mode: "closed"}, init: () => ({})}); // @ts-expect-error embedded Relay pages own their renderer -defineRelay({isolation: "iframe", frame: {page: "panel"}, render: "UI", init: () => ({})}); +defineRelay({isolation: {type: "iframe", page: "panel"}, render: "UI", init: () => ({})}); // @ts-expect-error Relay page/src variants are exclusive -defineRelay({isolation: "iframe", frame: {page: "panel", src: "https://example.com"}, init: () => ({})}); +defineRelay({isolation: {type: "iframe", page: "panel", src: "https://example.com"}, init: () => ({})}); diff --git a/tests/integration/browser/content/frame-pages/src/page.content.ts b/tests/integration/browser/content/frame-pages/src/page.content.ts index 29773d9e..77c109d4 100644 --- a/tests/integration/browser/content/frame-pages/src/page.content.ts +++ b/tests/integration/browser/content/frame-pages/src/page.content.ts @@ -1,8 +1,7 @@ import {ContentScriptIsolation, defineContentScriptAppend} from "adnbn"; export default defineContentScriptAppend({ matches: ["http://127.0.0.1/top.html"], - isolation: ContentScriptIsolation.Iframe, - frame: {page: "panel", height: 250}, + isolation: {type: ContentScriptIsolation.Iframe, page: "panel", height: 250}, container: {tagName: "section", className: "page-host"}, main() { window.addEventListener("message", event => { diff --git a/tests/integration/browser/content/frame-pages/src/source.content.ts b/tests/integration/browser/content/frame-pages/src/source.content.ts index 8d77bf8d..a4e0fdd5 100644 --- a/tests/integration/browser/content/frame-pages/src/source.content.ts +++ b/tests/integration/browser/content/frame-pages/src/source.content.ts @@ -2,8 +2,7 @@ import {ContentScriptIsolation, ContentScriptWorld, defineContentScriptAppend} f export default defineContentScriptAppend({ matches: ["http://127.0.0.1/top.html"], world: ContentScriptWorld.Main, - isolation: ContentScriptIsolation.Iframe, - frame: {src: "http://127.0.0.1:1/page.html", height: 200}, + isolation: {type: ContentScriptIsolation.Iframe, src: "http://127.0.0.1:1/page.html", height: 200}, container: {tagName: "section", className: "source-host"}, main() { window.addEventListener("message", event => { diff --git a/tests/integration/browser/content/isolated-styles-utils.ts b/tests/integration/browser/content/isolated-styles-utils.ts index 8f74bad2..2690aad9 100644 --- a/tests/integration/browser/content/isolated-styles-utils.ts +++ b/tests/integration/browser/content/isolated-styles-utils.ts @@ -21,6 +21,8 @@ interface ShadowProbe { readonly mounts?: number; readonly instance?: string; readonly kind?: string; + readonly mode?: string; + readonly closed?: boolean; readonly links: string[]; readonly ready?: string; readonly sharedCss?: string; @@ -43,6 +45,13 @@ const DocumentStateExpression = `(document => { const view = document.defaultView; const hosts = Array.from(document.querySelectorAll("[data-shadow-probe]")); const probes = hosts.map(host => { + const reported = host.getAttribute("data-shadow-report"); + if (reported) return { + ...JSON.parse(reported), + kind: host.getAttribute("data-shadow-probe"), + instance: host.getAttribute("data-instance"), + closed: host.shadowRoot === null, + }; const root = host.shadowRoot ?? host.querySelector("iframe")?.contentDocument; const result = root && root.querySelector("[data-shadow-result]"); const style = result && result.ownerDocument.defaultView.getComputedStyle(result); @@ -55,6 +64,7 @@ const DocumentStateExpression = `(document => { style.borderTopWidth === "3px" && (!primary || (range && Math.abs(range.getBoundingClientRect().width - 320) < 0.1)); return { measured: !!measured, + mode: host.shadowRoot?.mode, mounts: Number(host.getAttribute("data-mounts")), ...(result ? Object.fromEntries(Object.entries(result.dataset)) : {ready: "missing"}), instance: host.getAttribute("data-instance") || undefined, @@ -239,6 +249,36 @@ export const runIsolatedStylesIntegration = async ( }, 30_000); expectDocument(top, "top", 2); + if (fixtureName === "isolation-shadow") { + expect(top.probes.filter(probe => probe.kind === "primary").every(probe => probe.mode === "open")).toBe( + true + ); + expect(top.probes.find(probe => probe.kind === "secondary")).toMatchObject({ + mode: "closed", + closed: true, + }); + const instance = Number(top.probes.find(probe => probe.kind === "secondary")!.instance); + await evaluate(`(() => { + document.querySelector('[data-shadow-secondary]')?.remove(); + const anchor = document.createElement("div"); + anchor.setAttribute("data-shadow-secondary", ""); + document.body.appendChild(anchor); + })()`); + const replaced = await waitFor(async () => { + const state = await evaluate(`${DocumentStateExpression}(document)`); + lastState = state; + return isReady(state, 3) && + Number(state.probes.find(probe => probe.kind === "secondary")?.instance) > instance + ? state + : undefined; + }, 30_000); + expectDocument(replaced, "top", 2); + expect(replaced.probes.find(probe => probe.kind === "secondary")).toMatchObject({ + mode: "closed", + closed: true, + }); + } + if (fixtureName === "isolation-iframe") { let mounts = top.probes.map(probe => probe.mounts!); for (const operation of ["append", "insert", "reinsert"]) { @@ -292,6 +332,14 @@ export const runIsolatedStylesIntegration = async ( }, 30_000); expectDocument(frames.top, "top", 1); expectDocument(frames.child, "child", 1); + if (fixtureName === "isolation-shadow") { + for (const document of [frames.top, frames.child]) { + expect(document.probes.find(probe => probe.kind === "secondary")).toMatchObject({ + mode: "closed", + closed: true, + }); + } + } measurements.push({policy: policy === null ? "none" : "strict", top: remounted, frames}); await site.close(); site = undefined; diff --git a/tests/integration/browser/content/isolation-shadow/README.md b/tests/integration/browser/content/isolation-shadow/README.md index dffb213a..904d5805 100644 --- a/tests/integration/browser/content/isolation-shadow/README.md +++ b/tests/integration/browser/content/isolation-shadow/README.md @@ -6,7 +6,7 @@ shared CSS-only chunk, while each shadow entrypoint also loads its own initial a The primary entrypoint mounts two roots, starts one `import()` before the first root exists, watches for anchor replacement, and uses a local WOFF2 declared in document CSS with `@font-face`. The secondary entrypoint -has an independent runtime registry and lazy chunk. The ordinary entrypoint proves that shared CSS +uses `isolation: {type: Shadow, mode: Closed}` and has an independent runtime registry and lazy chunk. The ordinary entrypoint proves that shared CSS continues to load through `content_scripts.css` outside Shadow DOM. ## What the runner verifies @@ -22,6 +22,9 @@ continues to load through `content_scripts.css` outside Shadow DOM. links. - Top documents and same-origin child iframes work with and without strict page CSP. - Two shadow entrypoints sharing CSS keep independent root registries. +- Open and closed roots receive the same CSS delivery and survive anchor replacement. The closed + fixture reports measurements through a test-only host attribute; the runner separately verifies + that `host.shadowRoot` is `null`. It does not expose a root reference to the page. - The local font is really rendered: the synthetic `AAAA` sample is measured at 320px. Merely seeing its `font-family` value is not accepted. @@ -31,7 +34,7 @@ The strict page response uses: default-src 'none'; style-src 'none'; style-src-elem 'none'; font-src 'none'; frame-src 'self'; img-src 'self' ``` -Passing results were reproduced on Chrome 155.0.8043.4 with Manifest V3 and Firefox 155.0.1 with +Passing results were reproduced on Chrome 155.0.8046.0 with Manifest V3 and Firefox 155.0.1 with Manifest V2 and Manifest V3. Reports are written to `.cache/integration/isolation-shadow--mv.json`, outside the extension output. diff --git a/tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts b/tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts index fd0197f9..c4253b04 100644 --- a/tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts +++ b/tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts @@ -1,17 +1,21 @@ -import {ContentScriptIsolation, ContentScriptAppend, defineContentScriptAppend} from "adnbn"; +import {ContentScriptIsolation, ContentScriptShadowMode, ContentScriptAppend, defineContentScriptAppend} from "adnbn"; import sharedStyles from "../shared/styles.module.css?isolation"; import styles from "./styles.module.css?isolation"; +let instance = 0; + export default defineContentScriptAppend({ matches: ["http://127.0.0.1/*"], allFrames: true, anchor: "[data-shadow-secondary]", append: ContentScriptAppend.Last, - isolation: ContentScriptIsolation.Shadow, + isolation: {type: ContentScriptIsolation.Shadow, mode: ContentScriptShadowMode.Closed}, + watch: true, container: () => { const host = document.createElement("section"); host.dataset.shadowProbe = "secondary"; + host.dataset.instance = String(++instance); return host; }, @@ -32,16 +36,35 @@ export default defineContentScriptAppend({ root.dataset.initialCss = "applied"; root.dataset.asyncCss = "applied"; root.dataset.sharedCss = "applied"; + report(root); }) .catch(cause => { root.dataset.error = cause instanceof Error ? cause.message : String(cause); root.dataset.ready = "error"; + report(root); }); return root; }, }); +// The page-side test deliberately cannot traverse a closed root. Publish measured +// results from inside the fixture, without exposing the root or changing production code. +const report = (element: HTMLElement): void => { + const root = element.getRootNode() as ShadowRoot; + if (!root.host) return; + const style = element.ownerDocument.defaultView!.getComputedStyle(element); + (root.host as HTMLElement).dataset.shadowReport = JSON.stringify({ + ...element.dataset, + mode: root.mode, + measured: + style.color === "rgb(119, 51, 34)" && + style.backgroundColor === "rgb(85, 51, 119)" && + style.borderTopWidth === "3px", + links: Array.from(root.querySelectorAll('link[rel="stylesheet"]'), link => link.href), + }); +}; + const waitForStyles = async (element: HTMLElement): Promise => { const started = performance.now(); diff --git a/tests/integration/browser/content/isolation-spike/src/probe.tsx b/tests/integration/browser/content/isolation-spike/src/probe.tsx index a96a148f..15b6670e 100644 --- a/tests/integration/browser/content/isolation-spike/src/probe.tsx +++ b/tests/integration/browser/content/isolation-spike/src/probe.tsx @@ -89,11 +89,7 @@ for (const renderer of ["vanilla", "react"]) { if (renderer === "react") { root = createRoot(target); root.render( - createElement( - "div", - {className: "probe"}, - createElement("span", {className: "font"}, "AAAA") - ) + createElement("div", {className: "probe"}, createElement("span", {className: "font"}, "AAAA")) ); } else { const probe = doc.createElement("div"); diff --git a/tests/integration/browser/content/relay-styles.integration.test.ts b/tests/integration/browser/content/relay-styles.integration.test.ts index 0a6c02d4..91bf2685 100644 --- a/tests/integration/browser/content/relay-styles.integration.test.ts +++ b/tests/integration/browser/content/relay-styles.integration.test.ts @@ -31,8 +31,22 @@ test.each([ ); }); expect(result).toEqual({ - shadow: {color: "rgb(17, 85, 153)", background: "rgb(34, 102, 68)", host: "3px", isolated: true}, - iframe: {color: "rgb(17, 85, 153)", background: "rgb(34, 102, 68)", host: "3px", isolated: true}, + shadow: { + color: "rgb(17, 85, 153)", + background: "rgb(34, 102, 68)", + host: "3px", + isolated: true, + mode: "closed", + closed: true, + isolation: {type: "shadow", mode: "closed"}, + }, + iframe: { + color: "rgb(17, 85, 153)", + background: "rgb(34, 102, 68)", + host: "3px", + isolated: true, + isolation: {type: "iframe", width: "100%", height: 150}, + }, page: "rgb(17, 85, 153)", }); expect(session.errors).toEqual([]); diff --git a/tests/integration/browser/content/relay-styles/README.md b/tests/integration/browser/content/relay-styles/README.md index f48050d2..5a3359f1 100644 --- a/tests/integration/browser/content/relay-styles/README.md +++ b/tests/integration/browser/content/relay-styles/README.md @@ -3,7 +3,9 @@ Two native Relay panels share a component and use Shadow/blank iframe rendering. An extension page imports that same component (where `?isolation` is inert). Background calls both Relays through Messaging RPC. Each call imports lazy document and UI CSS and checks their actual application before returning. -A third Relay embeds this page through `frame.page`, exercising the navigation adapter and WAR check. +The Shadow Relay uses `isolation: {type: Shadow, mode: Closed}`. Its RPC result includes measurements from the closed +root and verifies that `host.shadowRoot` is `null`; rendering and lazy styles still work. +A third Relay embeds this page through `isolation.page`, exercising the navigation adapter and WAR check. The caller is a privileged background context: a web-accessible iframe is not a portable privileged extension context in Firefox. The page reports only its measured style to its parent; all panel commands and results go through the production Relay transport. diff --git a/tests/integration/browser/content/relay-styles/src/control.relay.ts b/tests/integration/browser/content/relay-styles/src/control.relay.ts index 21174e01..0c4688e0 100644 --- a/tests/integration/browser/content/relay-styles/src/control.relay.ts +++ b/tests/integration/browser/content/relay-styles/src/control.relay.ts @@ -2,8 +2,7 @@ import {defineRelay} from "adnbn"; export default defineRelay({ name: "control", matches: ["http://127.0.0.1/*"], - isolation: "iframe", - frame: {page: "control"}, + isolation: {type: "iframe", page: "control"}, container: {tagName: "section", className: "control-host"}, init: () => ({ // An explicit serializable value keeps the readiness probe false until the page reports its CSS. diff --git a/tests/integration/browser/content/relay-styles/src/iframe.relay.ts b/tests/integration/browser/content/relay-styles/src/iframe.relay.ts index 77a92e08..9bda8279 100644 --- a/tests/integration/browser/content/relay-styles/src/iframe.relay.ts +++ b/tests/integration/browser/content/relay-styles/src/iframe.relay.ts @@ -1,12 +1,19 @@ -import {defineRelay} from "adnbn"; +import {defineRelay, type ContentScriptIsolationOptions} from "adnbn"; import {createPanel, createApi} from "./shared/panel"; let panel: HTMLElement | undefined; +let isolation: ContentScriptIsolationOptions | undefined; const api = createApi(() => panel); export default defineRelay({ name: "iframePanel", matches: ["http://127.0.0.1/*"], isolation: "iframe", - frame: {height: 100}, - init: () => ({ready: () => api.ready(), load: () => api.load()}), + + init: () => ({ + ready: () => api.ready() && isolation !== undefined, + load: async () => ({...(await api.load()), isolation}), + }), + main(_relay, _context, options) { + isolation = options.isolation; + }, render: () => (panel = createPanel("iframe")), }); diff --git a/tests/integration/browser/content/relay-styles/src/shadow.relay.ts b/tests/integration/browser/content/relay-styles/src/shadow.relay.ts index 65446a9e..7d3ad2e3 100644 --- a/tests/integration/browser/content/relay-styles/src/shadow.relay.ts +++ b/tests/integration/browser/content/relay-styles/src/shadow.relay.ts @@ -1,11 +1,18 @@ -import {defineRelay} from "adnbn"; +import {ContentScriptShadowMode, defineRelay, type ContentScriptIsolationOptions} from "adnbn"; import {createPanel, createApi} from "./shared/panel"; let panel: HTMLElement | undefined; +let isolation: ContentScriptIsolationOptions | undefined; const api = createApi(() => panel); export default defineRelay({ name: "shadowPanel", matches: ["http://127.0.0.1/*"], - isolation: "shadow", - init: () => ({ready: () => api.ready(), load: () => api.load()}), + isolation: {type: "shadow", mode: ContentScriptShadowMode.Closed}, + init: () => ({ + ready: () => api.ready() && isolation !== undefined, + load: async () => ({...(await api.load()), isolation}), + }), + main(_relay, _context, options) { + isolation = options.isolation; + }, render: () => (panel = createPanel("shadow")), }); diff --git a/tests/integration/browser/content/relay-styles/src/shared/panel.ts b/tests/integration/browser/content/relay-styles/src/shared/panel.ts index caf17a33..a8d908f7 100644 --- a/tests/integration/browser/content/relay-styles/src/shared/panel.ts +++ b/tests/integration/browser/content/relay-styles/src/shared/panel.ts @@ -17,11 +17,13 @@ export const createApi = (getPanel: () => HTMLElement | undefined) => ({ const lazy = await import("./lazy"); lazy.apply(panel); const view = panel.ownerDocument.defaultView!; + const root = panel.getRootNode(); return { color: view.getComputedStyle(panel).color, background: view.getComputedStyle(panel).backgroundColor, host: getComputedStyle(document.querySelector("#host-probe")!).borderTopWidth, isolated: panel.getRootNode() !== document, + ...(root instanceof ShadowRoot ? {mode: root.mode, closed: root.host.shadowRoot === null} : {}), }; }, }); diff --git a/tests/integration/build/isolation/isolation-watch.integration.test.ts b/tests/integration/build/isolation/isolation-watch.integration.test.ts index 3f5a0000..cdb312b4 100644 --- a/tests/integration/build/isolation/isolation-watch.integration.test.ts +++ b/tests/integration/build/isolation/isolation-watch.integration.test.ts @@ -29,7 +29,7 @@ test("real CLI watch changes isolation and page aliases and removes outdated WAR writeFile(entry, await readFile(path.join(__dirname, "states", state + ".ts"))); await setState("page-isolated"); await expect(fixture.build({browser: "chrome"})).rejects.toThrow( - "uses ?isolation CSS with frame.page/frame.src, but has no local render target" + "uses ?isolation CSS with isolation.page/isolation.src, but has no local render target" ); await setState("none"); const directory = await fixture.build({browser: "chrome"}); @@ -64,6 +64,7 @@ test("real CLI watch changes isolation and page aliases and removes outdated WAR for (const state of [ "none", "shadow", + "shadow-closed", "shadow-document", "shadow", "iframe", @@ -83,7 +84,7 @@ test("real CLI watch changes isolation and page aliases and removes outdated WAR "CLI watch missing-isolation-css warning" ); } - const isolated = ["shadow", "shadow-document", "iframe", "fonts"].includes(state); + const isolated = ["shadow", "shadow-closed", "shadow-document", "iframe", "fonts"].includes(state); expect(!!script.css?.length).toBe(!isolated || state === "shadow-document"); expect( /__webpack_require__(?:\.__adnbnIsolatedStyles|\["__adnbnIsolatedStyles"\])\s*=\s*\{/.test(source) diff --git a/tests/integration/build/isolation/states/iframe.ts b/tests/integration/build/isolation/states/iframe.ts index ce5c1d1e..90dfd67c 100644 --- a/tests/integration/build/isolation/states/iframe.ts +++ b/tests/integration/build/isolation/states/iframe.ts @@ -2,8 +2,7 @@ import {defineContentScriptAppend} from "adnbn"; import "./watch.css?isolation"; export default defineContentScriptAppend({ matches: ["http://127.0.0.1/*"], - isolation: "iframe", - frame: {height: 220}, + isolation: {type: "iframe", height: 220}, render: "Watch UI", main() { console.info("isolation-watch-iframe"); diff --git a/tests/integration/build/isolation/states/page-isolated.ts b/tests/integration/build/isolation/states/page-isolated.ts index d28b4893..10cf03bd 100644 --- a/tests/integration/build/isolation/states/page-isolated.ts +++ b/tests/integration/build/isolation/states/page-isolated.ts @@ -3,6 +3,5 @@ import "./watch.css?isolation"; export default defineContentScriptAppend({ matches: ["http://127.0.0.1/*"], - isolation: "iframe", - frame: {page: "panel"}, + isolation: {type: "iframe", page: "panel"}, }); diff --git a/tests/integration/build/isolation/states/page.ts b/tests/integration/build/isolation/states/page.ts index d0a2249a..6b903885 100644 --- a/tests/integration/build/isolation/states/page.ts +++ b/tests/integration/build/isolation/states/page.ts @@ -2,8 +2,7 @@ import {defineContentScriptAppend} from "adnbn"; import "./watch.css"; export default defineContentScriptAppend({ matches: ["http://127.0.0.1/*"], - isolation: "iframe", - frame: {page: "panel"}, + isolation: {type: "iframe", page: "panel"}, main() { console.info("isolation-watch-page"); }, diff --git a/tests/integration/build/isolation/states/renamed.ts b/tests/integration/build/isolation/states/renamed.ts index a0f5ad9e..654a866b 100644 --- a/tests/integration/build/isolation/states/renamed.ts +++ b/tests/integration/build/isolation/states/renamed.ts @@ -2,8 +2,7 @@ import {defineContentScriptAppend} from "adnbn"; import "./watch.css"; export default defineContentScriptAppend({ matches: ["http://127.0.0.1/*"], - isolation: "iframe", - frame: {page: "renamed-panel"}, + isolation: {type: "iframe", page: "renamed-panel"}, main() { console.info("isolation-watch-renamed"); }, diff --git a/tests/integration/build/isolation/states/shadow-closed.ts b/tests/integration/build/isolation/states/shadow-closed.ts new file mode 100644 index 00000000..1fd2407a --- /dev/null +++ b/tests/integration/build/isolation/states/shadow-closed.ts @@ -0,0 +1,10 @@ +import {ContentScriptIsolation, ContentScriptShadowMode, defineContentScriptAppend} from "adnbn"; +import "./watch.css?isolation"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + isolation: {type: ContentScriptIsolation.Shadow, mode: ContentScriptShadowMode.Closed}, + render: "Watch UI", + main() { + console.info("isolation-watch-shadow-closed"); + }, +}); diff --git a/tests/integration/build/isolation/states/source.ts b/tests/integration/build/isolation/states/source.ts index 19163c92..9d8c3b4e 100644 --- a/tests/integration/build/isolation/states/source.ts +++ b/tests/integration/build/isolation/states/source.ts @@ -2,8 +2,7 @@ import {defineContentScriptAppend} from "adnbn"; import "./watch.css"; export default defineContentScriptAppend({ matches: ["http://127.0.0.1/*"], - isolation: "iframe", - frame: {src: "https://example.com/widget"}, + isolation: {type: "iframe", src: "https://example.com/widget"}, main() { console.info("isolation-watch-source"); }, diff --git a/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts b/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts index 642b17ea..ed0f7c8e 100644 --- a/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts +++ b/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts @@ -23,6 +23,7 @@ test("content plugin delivers Relay options and refreshes names, methods and opt name: "observer", matches: ["http://*/*", "https://*/*"], runAt: "document_idle", + isolation: {type: "none"}, method: "messaging", }, }; @@ -31,6 +32,7 @@ test("content plugin delivers Relay options and refreshes names, methods and opt name: "collector", matches: ["https://example.com/*"], runAt: "document_idle", + isolation: {type: "none"}, method: "scripting", declarative: "required", allFrames: false, From 74b5c2645b3de0a9aad7bfd8e96d5890b0881b30 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:27:22 +0300 Subject: [PATCH 21/38] feat(locale): generate a shared virtual catalogue Generate native locale JSON and virtual/locale from cached, validated messages. Add a reusable module generation plugin with watch updates and share the catalogue when used twice or when it reaches 100 KB, while keeping background self-contained. Cover catalogue equivalence, module isolation, watch updates, chunk boundaries and browser delivery with integration fixtures. Known limitation: Chrome deduplicates a shared content script path across MAIN and ISOLATED worlds. The mixed-world browser test still fails; this commit records the agreed first stage without changing script delivery. --- jest.config.ts | 1 + .../GenerateModulePlugin.test.ts | 151 ++++++++++++++++++ .../generate-module/GenerateModulePlugin.ts | 61 +++++++ .../bundler/plugins/generate-module/index.ts | 5 + .../generate-module/tests/fixtures/entry.js | 4 + src/cli/bundler/plugins/index.ts | 6 + src/cli/plugins/locale/Locale.test.ts | 30 ++++ src/cli/plugins/locale/Locale.ts | 37 ++++- src/cli/plugins/locale/index.ts | 75 ++++++--- src/cli/plugins/locale/module.ts | 7 + src/cli/plugins/locale/types.ts | 3 + src/locale/utils.ts | 7 +- src/shared/locale/messages.ts | 4 + .../locale/chunks.firefox.integration.test.ts | 57 +++++++ .../browser/locale/chunks.integration.test.ts | 96 +++++++++++ tests/integration/browser/locale/utils.ts | 24 +++ .../locale/chunks-fixture/adnbn.config.ts | 16 ++ .../build/locale/chunks-fixture/package.json | 9 ++ .../build/locale/chunks-fixture/site/top.html | 8 + .../locale/chunks-fixture/src/background.ts | 5 + .../locale/chunks-fixture/src/catalogue.d.ts | 4 + .../src/isolated-secondary.content.ts | 9 ++ .../chunks-fixture/src/isolated.content.ts | 9 ++ .../locale/chunks-fixture/src/locales/en.json | 3 + .../locale/chunks-fixture/src/locales/fr.json | 3 + .../src/main-secondary.content.ts | 9 ++ .../locale/chunks-fixture/src/main.content.ts | 9 ++ .../locale/chunks-fixture/src/options.ts | 5 + .../build/locale/chunks-fixture/src/panel.ts | 12 ++ .../build/locale/chunks-fixture/src/popup.ts | 5 + .../build/locale/chunks-fixture/tsconfig.json | 1 + .../build/locale/chunks.integration.test.ts | 137 ++++++++++++++++ .../build/locale/fixture/adnbn.config.ts | 7 + .../build/locale/fixture/package.json | 9 ++ .../build/locale/fixture/src/background.ts | 5 + .../build/locale/fixture/src/catalogue.d.ts | 4 + .../build/locale/fixture/src/locales/en.json | 13 ++ .../locale/fixture/src/locales/fr.chrome.json | 5 + .../build/locale/fixture/src/locales/fr.json | 9 ++ .../build/locale/fixture/tsconfig.json | 3 + .../build/locale/locale.integration.test.ts | 94 +++++++++++ tests/integration/build/locale/states/en.json | 11 ++ tests/jest-globals.d.ts | 2 + 43 files changed, 944 insertions(+), 30 deletions(-) create mode 100644 src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts create mode 100644 src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts create mode 100644 src/cli/bundler/plugins/generate-module/index.ts create mode 100644 src/cli/bundler/plugins/generate-module/tests/fixtures/entry.js create mode 100644 src/cli/plugins/locale/module.ts create mode 100644 src/cli/plugins/locale/types.ts create mode 100644 src/shared/locale/messages.ts create mode 100644 tests/integration/browser/locale/chunks.firefox.integration.test.ts create mode 100644 tests/integration/browser/locale/chunks.integration.test.ts create mode 100644 tests/integration/browser/locale/utils.ts create mode 100644 tests/integration/build/locale/chunks-fixture/adnbn.config.ts create mode 100644 tests/integration/build/locale/chunks-fixture/package.json create mode 100644 tests/integration/build/locale/chunks-fixture/site/top.html create mode 100644 tests/integration/build/locale/chunks-fixture/src/background.ts create mode 100644 tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts create mode 100644 tests/integration/build/locale/chunks-fixture/src/isolated-secondary.content.ts create mode 100644 tests/integration/build/locale/chunks-fixture/src/isolated.content.ts create mode 100644 tests/integration/build/locale/chunks-fixture/src/locales/en.json create mode 100644 tests/integration/build/locale/chunks-fixture/src/locales/fr.json create mode 100644 tests/integration/build/locale/chunks-fixture/src/main-secondary.content.ts create mode 100644 tests/integration/build/locale/chunks-fixture/src/main.content.ts create mode 100644 tests/integration/build/locale/chunks-fixture/src/options.ts create mode 100644 tests/integration/build/locale/chunks-fixture/src/panel.ts create mode 100644 tests/integration/build/locale/chunks-fixture/src/popup.ts create mode 100644 tests/integration/build/locale/chunks-fixture/tsconfig.json create mode 100644 tests/integration/build/locale/chunks.integration.test.ts create mode 100644 tests/integration/build/locale/fixture/adnbn.config.ts create mode 100644 tests/integration/build/locale/fixture/package.json create mode 100644 tests/integration/build/locale/fixture/src/background.ts create mode 100644 tests/integration/build/locale/fixture/src/catalogue.d.ts create mode 100644 tests/integration/build/locale/fixture/src/locales/en.json create mode 100644 tests/integration/build/locale/fixture/src/locales/fr.chrome.json create mode 100644 tests/integration/build/locale/fixture/src/locales/fr.json create mode 100644 tests/integration/build/locale/fixture/tsconfig.json create mode 100644 tests/integration/build/locale/locale.integration.test.ts create mode 100644 tests/integration/build/locale/states/en.json create mode 100644 tests/jest-globals.d.ts diff --git a/jest.config.ts b/jest.config.ts index d5fad946..a285024f 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -3,6 +3,7 @@ import type {Config} from "jest"; const config: Config = { verbose: true, testEnvironment: "jsdom", + globals: {ADNBN_TEST_ROOT: import.meta.dirname}, setupFiles: ["/tests/jest.setup.ts"], modulePathIgnorePatterns: ["/.cache/"], resolver: "/tests/raw-module-resolver.cjs", diff --git a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts new file mode 100644 index 00000000..1e547335 --- /dev/null +++ b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts @@ -0,0 +1,151 @@ +/** @jest-environment node */ + +import fs from "fs/promises"; +import os from "os"; +import path from "path"; +import vm from "vm"; +import {rspack, type Compiler, type NormalModule, type Stats} from "@rspack/core"; +import {GenerateModulePlugin} from "./GenerateModulePlugin"; + +const fixtures = path.join(__dirname, "tests/fixtures"); +const modules = { + "virtual/value": "export default 3;", + "virtual/tools": "export function double(value) { return value * 2; }", + "virtual/unused": 'throw new Error("Unused modules must not execute");', +}; + +let directory: string; +const compilers = new Set(); + +beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), "adnbn-generate-module-")); +}); + +const close = async (compiler: Compiler) => { + await new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); + compilers.delete(compiler); +}; + +afterEach(async () => { + for (const compiler of compilers) await close(compiler); + await fs.rm(directory, {recursive: true, force: true}); +}); + +const createCompiler = (plugin: GenerateModulePlugin, name = "bundle") => { + const compiler = rspack({ + mode: "none", + context: fixtures, + target: "node", + cache: false, + entry: "./entry.js", + output: {path: path.join(directory, name), filename: "index.js"}, + plugins: [plugin], + }); + compilers.add(compiler); + return compiler; +}; + +const readModule = (compiler: Compiler, name: string) => compiler.options.resolve.alias![name] as string; + +const run = async (compiler: Compiler): Promise => { + await new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) reject(error); + else if (stats!.hasErrors()) reject(new Error(stats!.toString({all: false, errors: true}))); + else resolve(); + }); + }); + const source = await fs.readFile(path.join(compiler.options.output.path!, "index.js"), "utf8"); + const sandbox = {result: undefined as number | undefined}; + vm.runInNewContext(source, sandbox); + return sandbox.result!; +}; + +test("bundles imports and arbitrary JavaScript exports without executing unused modules", async () => { + const compiler = createCompiler(new GenerateModulePlugin(modules)); + expect(await run(compiler)).toBe(6); + const file = readModule(compiler, "virtual/value"); + await close(compiler); + await expect(fs.access(file)).rejects.toMatchObject({code: "ENOENT"}); +}); + +test("updates before compilation, skips unchanged sources and propagates callback errors", async () => { + let value = 3; + let fail = false; + const compiler = createCompiler( + new GenerateModulePlugin(modules).watch(async () => { + if (fail) throw new Error("Cannot generate module"); + return {"virtual/value": `export default ${value};`, "virtual/tools": modules["virtual/tools"]}; + }) + ); + expect(await run(compiler)).toBe(6); + const file = readModule(compiler, "virtual/value"); + const tools = readModule(compiler, "virtual/tools"); + const timestamp = new Date("2000-01-01T00:00:00Z"); + await fs.utimes(file, timestamp, timestamp); + await fs.utimes(tools, timestamp, timestamp); + await compiler.hooks.watchRun.promise(compiler); + expect((await fs.stat(file)).mtimeMs).toBe(timestamp.getTime()); + + value = 7; + await compiler.hooks.watchRun.promise(compiler); + expect((await fs.stat(tools)).mtimeMs).toBe(timestamp.getTime()); + expect(await run(compiler)).toBe(14); + + fail = true; + await expect(compiler.hooks.watchRun.promise(compiler)).rejects.toThrow("Cannot generate module"); + expect(await fs.readFile(file, "utf8")).toBe("export default 7;"); + fail = false; + value = 9; + await compiler.hooks.watchRun.promise(compiler); + expect(await run(compiler)).toBe(18); +}); + +test("isolates compilers with identical initial sources, updates and cleanup", async () => { + const first = createCompiler( + new GenerateModulePlugin(modules).watch(async () => ({"virtual/value": "export default 5;"})), + "first" + ); + const second = createCompiler(new GenerateModulePlugin(modules), "second"); + expect(readModule(first, "virtual/value")).not.toBe(readModule(second, "virtual/value")); + await first.hooks.watchRun.promise(first); + expect(await run(first)).toBe(10); + expect(await run(second)).toBe(6); + await close(first); + expect(await run(second)).toBe(6); +}); + +test.each([false, true])("shares only generated modules across entrypoint layers when configured: %s", async shared => { + const plugin = new GenerateModulePlugin(modules); + if (shared) plugin.layer("shared-data"); + const compiler = rspack({ + mode: "none", + context: fixtures, + target: "node", + entry: { + view: "./entry.js", + isolated: {import: "./entry.js", layer: "isolated"}, + main: {import: "./entry.js", layer: "main"}, + }, + output: {path: directory, filename: "[name].js"}, + plugins: [plugin], + optimization: {splitChunks: false}, + }); + compilers.add(compiler); + const stats = await new Promise((resolve, reject) => { + compiler.run((error, result) => (error ? reject(error) : resolve(result!))); + }); + expect(stats.hasErrors()).toBe(false); + const built = [...stats.compilation.modules] as NormalModule[]; + for (const request of ["virtual/value", "virtual/tools"]) { + const instances = built.filter(module => module.rawRequest === request); + expect(instances).toHaveLength(shared ? 1 : 3); + if (shared) expect(instances[0].layer).toBe("shared-data"); + } + expect(built.filter(module => module.rawRequest === "./entry.js")).toHaveLength(3); + for (const entry of ["view", "isolated", "main"]) { + const sandbox = {result: undefined as number | undefined}; + vm.runInNewContext(await fs.readFile(path.join(directory, `${entry}.js`), "utf8"), sandbox); + expect(sandbox.result).toBe(6); + } +}); diff --git a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts new file mode 100644 index 00000000..727d7d7a --- /dev/null +++ b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts @@ -0,0 +1,61 @@ +import {randomUUID} from "crypto"; +import path from "path"; +import type {Compiler} from "@rspack/core"; +import {RspackVirtualModulePlugin} from "rspack-plugin-virtual-module"; + +/** JavaScript source keyed by the module's import specifier. */ +export type GenerateModulePluginModules = Record; + +/** Returns source updates for the registered modules. */ +export type GenerateModulePluginUpdate = () => Promise; + +export class GenerateModulePlugin { + private readonly pluginName = "GenerateModulePlugin"; + private update?: GenerateModulePluginUpdate; + private files: readonly string[] = []; + private moduleLayer?: string; + + constructor(private readonly modules: GenerateModulePluginModules) {} + + /** Use one module identity regardless of the importing entrypoint's layer. */ + public layer(name: string): this { + this.moduleLayer = name; + return this; + } + + public watch(update: GenerateModulePluginUpdate, files: Iterable = []): this { + this.update = update; + this.files = [...files]; + return this; + } + + public apply(compiler: Compiler): void { + const modules = {...this.modules}; + + // The underlying plugin removes its entire directory on shutdown. + const plugin = new RspackVirtualModulePlugin(modules, `generate-module-${randomUUID()}`); + plugin.apply(compiler); + + if (this.moduleLayer !== undefined) { + const resources = Object.keys(modules).map(name => compiler.options.resolve.alias![name] as string); + compiler.options.module.rules.push({include: resources, layer: this.moduleLayer}); + } + + compiler.hooks.compilation.tap(this.pluginName, compilation => { + for (const file of this.files) { + compilation.fileDependencies.add(path.resolve(compiler.context, file)); + } + }); + + compiler.hooks.watchRun.tapPromise(this.pluginName, async () => { + if (!this.update) return; + + for (const [name, source] of Object.entries(await this.update())) { + if (modules[name] !== source) { + plugin.writeModule(name, source); + modules[name] = source; + } + } + }); + } +} diff --git a/src/cli/bundler/plugins/generate-module/index.ts b/src/cli/bundler/plugins/generate-module/index.ts new file mode 100644 index 00000000..8cf1f8ed --- /dev/null +++ b/src/cli/bundler/plugins/generate-module/index.ts @@ -0,0 +1,5 @@ +export { + GenerateModulePlugin, + type GenerateModulePluginModules, + type GenerateModulePluginUpdate, +} from "./GenerateModulePlugin"; diff --git a/src/cli/bundler/plugins/generate-module/tests/fixtures/entry.js b/src/cli/bundler/plugins/generate-module/tests/fixtures/entry.js new file mode 100644 index 00000000..5ed345fb --- /dev/null +++ b/src/cli/bundler/plugins/generate-module/tests/fixtures/entry.js @@ -0,0 +1,4 @@ +import {double} from "virtual/tools"; +import value from "virtual/value"; + +globalThis.result = double(value); diff --git a/src/cli/bundler/plugins/index.ts b/src/cli/bundler/plugins/index.ts index 0610d4c9..0fe4664e 100644 --- a/src/cli/bundler/plugins/index.ts +++ b/src/cli/bundler/plugins/index.ts @@ -25,6 +25,12 @@ export { type GenerateJsonPluginUpdate, } from "./generate-json"; +export { + GenerateModulePlugin, + type GenerateModulePluginModules, + type GenerateModulePluginUpdate, +} from "./generate-module"; + export {default as ManifestPlugin} from "./manifest"; export { diff --git a/src/cli/plugins/locale/Locale.test.ts b/src/cli/plugins/locale/Locale.test.ts index a2405add..ffb4d611 100644 --- a/src/cli/plugins/locale/Locale.test.ts +++ b/src/cli/plugins/locale/Locale.test.ts @@ -16,6 +16,7 @@ import {Browser} from "@typing/browser"; import {ReadonlyConfig} from "@typing/config"; import {Language, LocaleMessages} from "@typing/locale"; import {GenerateJsonPluginData} from "@cli/bundler"; +import {flattenLocaleMessages} from "@shared/locale/messages"; const fixtures = path.resolve(__dirname, "tests/fixtures/completion"); @@ -188,6 +189,7 @@ describe("locale JSON completion", () => { const locale = makeLocale("no-locales"); expect(await locale.json()).toEqual({}); + expect(await locale.catalogue()).toEqual({}); expect(await locale.languages()).toEqual(new Set()); }); @@ -216,11 +218,39 @@ describe("locale JSON completion", () => { 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")); + // The second representation must use the same prepared messages until explicitly cleared. + expect((await locale.catalogue()).fr!.app_greeting).toBe("Hello {{ name }}"); locale.clear(); + expect((await locale.catalogue()).fr!.app_greeting).toBe("Welcome {{ name }}"); expect(messages(await locale.json(), Language.French).app_greeting.message).toBe("Welcome {{ name }}"); }); + test.each([Browser.Chrome, Browser.Firefox])( + "catalogue matches every completed JSON message for %s", + async browser => { + const locale = makeLayeredLocale({browser}); + const [json, catalogue] = await Promise.all([locale.json(), locale.catalogue()]); + expect(Object.keys(catalogue)).toEqual([...(await locale.languages())]); + for (const lang of await locale.languages()) { + expect(catalogue[lang]).toEqual(flattenLocaleMessages(messages(json, lang))); + expect(catalogue[lang]!.locale).toBe(lang); + } + expect(catalogue.fr).toMatchObject({empty: "", pluginItems: "élément plugin|éléments plugin"}); + } + ); + + test("validates the catalogue directly and recovers after clearing invalid sources", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-locale-catalogue-")); + temporaryDirectories.push(root); + fs.cpSync(path.join(fixtures, "missing-plural"), root, {recursive: true}); + const locale = makeLocale("missing-plural", {rootDir: root}); + await expect(locale.catalogue()).rejects.toThrow('missing plural key "cart.items"'); + fs.cpSync(path.join(fixtures, "single"), root, {recursive: true}); + locale.clear(); + expect((await locale.catalogue()).fr!.cart_items).toBe("{{count}} article|{{count}} articles"); + }); + 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); diff --git a/src/cli/plugins/locale/Locale.ts b/src/cli/plugins/locale/Locale.ts index ad1b67e8..b872f6a0 100644 --- a/src/cli/plugins/locale/Locale.ts +++ b/src/cli/plugins/locale/Locale.ts @@ -3,11 +3,35 @@ import {getLocaleFilename} from "@locale/utils"; import {LocaleFinder} from "@cli/entrypoint"; import {GenerateJsonPluginData} from "@cli/bundler"; -export default class extends LocaleFinder { +import {flattenLocaleMessages} from "@shared/locale/messages"; + +import type {Language, LocaleMessages} from "@typing/locale"; + +import type {LocaleCatalogue} from "./types"; + +export default class Locale extends LocaleFinder { + private _messages?: Promise>; + public async json(): Promise { + return Object.fromEntries( + [...(await this.messages())].map(([lang, messages]) => [getLocaleFilename(lang), messages]) + ); + } + + public async catalogue(): Promise { + return Object.fromEntries( + [...(await this.messages())].map(([lang, messages]) => [lang, flattenLocaleMessages(messages)]) + ); + } + + private messages(): Promise> { + return (this._messages ??= this.createMessages()); + } + + private async createMessages(): Promise> { await this.validate(); - const data: GenerateJsonPluginData = {}; + const data = new Map(); const builders = await this.builders(); const defaultBuilder = this.getValidator().getDefaultBuilder(builders); @@ -17,9 +41,16 @@ export default class extends LocaleFinder { // 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}; + + data.set(builder.lang(), {...defaultMessages, ...messages}); } return data; } + + public clear(): this { + this._messages = undefined; + + return super.clear(); + } } diff --git a/src/cli/plugins/locale/index.ts b/src/cli/plugins/locale/index.ts index 39603a7e..1914b771 100644 --- a/src/cli/plugins/locale/index.ts +++ b/src/cli/plugins/locale/index.ts @@ -1,13 +1,13 @@ import _ from "lodash"; -import {Configuration as RspackConfig, DefinePlugin} from "@rspack/core"; +import {DefinePlugin, type Configuration as RspackConfig} from "@rspack/core"; import {definePlugin} from "@main/plugin"; -import {GenerateJsonPlugin} from "@cli/bundler"; +import {GenerateJsonPlugin, GenerateModulePlugin} from "@cli/bundler"; import {extractLocaleKey, modifyLocaleMessageKey} from "@locale/utils"; import Locale from "./Locale"; - import {LocaleDeclaration} from "./declaration"; +import {createLocaleModule, LocaleModuleName} from "./module"; import {Command} from "@typing/app"; import {Browser} from "@typing/browser"; @@ -24,32 +24,63 @@ export default definePlugin(() => { }, locale: () => locale.files(), bundler: async ({config}) => { - await locale.validate(); + const prepareLocale = async () => { + await locale.validate(); + declaration.structure(await locale.structure()).build(); + }; + + const getModules = async () => ({ + [LocaleModuleName]: createLocaleModule(await locale.catalogue()), + }); - declaration.structure(await locale.structure()).build(); + await prepareLocale(); - const plugin = new GenerateJsonPlugin(await locale.json()); + const jsonPlugin = new GenerateJsonPlugin(await locale.json()); + const modulePlugin = new GenerateModulePlugin(await getModules()).layer("adnbn:locale"); + const constantsPlugin = new DefinePlugin({ + __ADNBN_LOCALE_KEYS__: JSON.stringify([...(await locale.keys())]), + __ADNBN_DEFINED_LOCALES__: JSON.stringify([...(await locale.languages())]), + }); if (config.command === Command.Watch) { - plugin.watch(async () => { + jsonPlugin.watch(async () => { locale.clear(); + await prepareLocale(); - await locale.validate(); - - declaration.structure(await locale.structure()).build(); - - return await locale.json(); + return locale.json(); }); + + const watchFiles = [...(await locale.plugin().files())].map(({file}) => file); + modulePlugin.watch(getModules, watchFiles); } return { - plugins: [ - plugin, - new DefinePlugin({ - __ADNBN_LOCALE_KEYS__: JSON.stringify([...(await locale.keys())]), - __ADNBN_DEFINED_LOCALES__: JSON.stringify([...(await locale.languages())]), - }), - ], + // JSON refresh clears and prepares the shared cache before the module reads it. + plugins: [jsonPlugin, modulePlugin, constantsPlugin], + optimization: config.commonChunks + ? { + splitChunks: { + cacheGroups: { + adnbnLocaleShared: { + layer: "adnbn:locale", + name: "locale", + minChunks: 2, + minSize: 0, + enforce: true, + priority: 60, + }, + adnbnLocaleLarge: { + layer: "adnbn:locale", + name: "locale", + minChunks: 1, + minSize: 100_000, + enforce: true, + priority: 70, + }, + }, + }, + } + : undefined, } satisfies RspackConfig; }, manifest: async ({config, manifest}) => { @@ -94,13 +125,13 @@ export default definePlugin(() => { /** Opera/Edge do not support localization in manifest's short_name field */ if (shortNameKey && (browser === Browser.Opera || browser === Browser.Edge)) { - const instance = builders.get(language); + const defaultBuilder = builders.get(language); - if (!instance) { + if (!defaultBuilder) { throw new Error(`Locale not found for "${language}"`); } - manifestShortName = instance.get().get(shortNameKey) ?? manifestShortName; + manifestShortName = defaultBuilder.get().get(shortNameKey) ?? manifestShortName; } manifest.setShortName(manifestShortName); diff --git a/src/cli/plugins/locale/module.ts b/src/cli/plugins/locale/module.ts new file mode 100644 index 00000000..a7a30ac9 --- /dev/null +++ b/src/cli/plugins/locale/module.ts @@ -0,0 +1,7 @@ +import type {LocaleCatalogue} from "./types"; + +export const LocaleModuleName = "virtual/locale"; + +export const createLocaleModule = (catalogue: LocaleCatalogue): string => + // Parse serialized JSON to preserve own keys such as __proto__ in the exported object. + `export default JSON.parse(${JSON.stringify(JSON.stringify(catalogue))});\n`; diff --git a/src/cli/plugins/locale/types.ts b/src/cli/plugins/locale/types.ts new file mode 100644 index 00000000..5b26f6d6 --- /dev/null +++ b/src/cli/plugins/locale/types.ts @@ -0,0 +1,3 @@ +import type {Language} from "@typing/locale"; + +export type LocaleCatalogue = Partial>>; diff --git a/src/locale/utils.ts b/src/locale/utils.ts index dce5ac07..148fe465 100644 --- a/src/locale/utils.ts +++ b/src/locale/utils.ts @@ -4,11 +4,12 @@ import { LocaleDir, LocaleKeyMarker, LocaleKeysSeparator, - LocaleMessages, LocaleNestedKeysSeparator, RtlLanguages, } from "@typing/locale"; +export {flattenLocaleMessages} from "@shared/locale/messages"; + export const getLocaleFilename = (lang: Language): string => { return `_locales/${lang}/messages.json`; }; @@ -53,10 +54,6 @@ export const getLocaleDir = (lang: Language): LocaleDir => { return isLocaleRtl(lang) ? LocaleDir.RightToLeft : LocaleDir.LeftToRight; }; -export const flattenLocaleMessages = (messages: LocaleMessages): Record => { - return Object.fromEntries(Object.entries(messages).map(([key, value]) => [key, value.message])); -}; - export const resolveLanguage = (language?: string): Language | undefined => { if (!language) { return undefined; diff --git a/src/shared/locale/messages.ts b/src/shared/locale/messages.ts new file mode 100644 index 00000000..132d1a23 --- /dev/null +++ b/src/shared/locale/messages.ts @@ -0,0 +1,4 @@ +import type {LocaleMessages} from "@typing/locale"; + +export const flattenLocaleMessages = (messages: LocaleMessages): Record => + Object.fromEntries(Object.entries(messages).map(([key, value]) => [key, value.message])); diff --git a/tests/integration/browser/locale/chunks.firefox.integration.test.ts b/tests/integration/browser/locale/chunks.firefox.integration.test.ts new file mode 100644 index 00000000..6a364961 --- /dev/null +++ b/tests/integration/browser/locale/chunks.firefox.integration.test.ts @@ -0,0 +1,57 @@ +/** @jest-environment node */ + +import {spawn, type ChildProcess} from "child_process"; +import {mkdtemp, rm} from "fs/promises"; +import os from "os"; +import path from "path"; + +import {createIntegrationFixture} from "../../utils/fixture"; +import BidiClient from "../utils/BidiClient"; +import {findFirefoxBinary} from "../utils/firefox"; +import {getFreePort, stop, waitFor} from "../utils/browser"; +import {startIntegrationSite, type IntegrationSite} from "../utils/site"; +import {ChangeLanguage, expectPanels, LocaleFixtureDirectory, ReadPanels} from "./utils"; + +jest.setTimeout(90_000); + +test("Firefox MV3 loads the same locale chunk in ISOLATED and MAIN", async () => { + const binary = findFirefoxBinary(); + if (!binary) throw new Error("Install Firefox or set ADNBN_FIREFOX_BIN"); + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, LocaleFixtureDirectory); + const profile = await mkdtemp(path.join(os.tmpdir(), "adnbn-locale-firefox-")); + let process: ChildProcess | undefined; + let client: BidiClient | undefined; + let site: IntegrationSite | undefined; + + try { + const directory = await fixture.build({browser: "firefox"}); + const port = await getFreePort(); + process = spawn( + binary, + ["--headless", "--no-remote", "--profile", profile, "--remote-debugging-port", String(port), "about:blank"], + {stdio: "ignore"} + ); + client = await waitFor(() => BidiClient.connect(`ws://127.0.0.1:${port}/session`)); + await client.send("session.new", {capabilities: {alwaysMatch: {}}}); + await client.send("session.subscribe", {events: ["log.entryAdded"]}); + await client.send("webExtension.install", {extensionData: {path: directory, type: "path"}}); + site = await startIntegrationSite(path.join(fixture.directory, "site")); + const {context} = await client.send("browsingContext.create", {type: "tab"}); + await client.send("browsingContext.navigate", {context, url: site.origin, wait: "complete"}); + const contexts = ["isolated", "isolated-secondary", "main", "main-secondary"]; + await waitFor(async () => { + expectPanels(await client!.evaluate(context, ReadPanels), contexts); + return true; + }); + expect(await client.evaluate(context, "typeof globalThis.browser?.i18n")).toBe("undefined"); + await client.evaluate(context, ChangeLanguage); + expectPanels(await client.evaluate(context, ReadPanels), contexts, "fr"); + expect(client.runtimeErrors).toEqual([]); + } finally { + await client?.close(); + if (process) await stop(process); + await site?.close(); + await fixture.dispose(); + await rm(profile, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } +}); diff --git a/tests/integration/browser/locale/chunks.integration.test.ts b/tests/integration/browser/locale/chunks.integration.test.ts new file mode 100644 index 00000000..16a0b2a3 --- /dev/null +++ b/tests/integration/browser/locale/chunks.integration.test.ts @@ -0,0 +1,96 @@ +/** @jest-environment node */ + +import {spawn, type ChildProcess} from "child_process"; +import {mkdtemp, readFile, rm} from "fs/promises"; +import os from "os"; +import path from "path"; + +import {createIntegrationFixture} from "../../utils/fixture"; +import {browserVersion, CdpClient, findChromeBinary} from "../utils/chrome"; +import {getFreePort, stop, waitFor} from "../utils/browser"; +import {startIntegrationSite, type IntegrationSite} from "../utils/site"; +import {ChangeLanguage, expectPanels, LocaleFixtureDirectory, ReadPanels} from "./utils"; + +jest.setTimeout(90_000); + +test("Chrome loads one locale chunk in views, ISOLATED and MAIN while background stays standalone", async () => { + const binary = findChromeBinary(ADNBN_TEST_ROOT); + if (!binary) throw new Error("Install Chrome for Testing or set ADNBN_CHROME_BIN"); + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, LocaleFixtureDirectory); + const profile = await mkdtemp(path.join(os.tmpdir(), "adnbn-locale-chrome-")); + let process: ChildProcess | undefined; + let client: CdpClient | undefined; + let site: IntegrationSite | undefined; + + try { + const directory = await fixture.build(); + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const port = await getFreePort(); + process = spawn( + binary, + [ + "--headless=new", + "--no-sandbox", + "--no-first-run", + "--no-default-browser-check", + `--user-data-dir=${profile}`, + `--remote-debugging-port=${port}`, + "about:blank", + ], + {stdio: "ignore"} + ); + const {webSocketDebuggerUrl} = await waitFor(() => browserVersion(port)); + client = await CdpClient.connect(webSocketDebuggerUrl); + const extension = await client.send("Extensions.loadUnpacked", {path: directory}); + const origin = `chrome-extension://${extension.id}`; + + const attach = async (targetId: string): Promise => { + const {sessionId} = await client!.send("Target.attachToTarget", {targetId, flatten: true}); + await client!.send("Runtime.enable", {}, sessionId); + return sessionId; + }; + const evaluate = async (session: string, expression: string): Promise => { + const result = await client!.send("Runtime.evaluate", {expression, returnByValue: true}, session); + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + const worker = await waitFor(async () => { + const {targetInfos} = await client!.send("Target.getTargets"); + return targetInfos.find( + (target: {url: string}) => target.url === `${origin}/${manifest.background.service_worker}` + ); + }); + const background = await attach(worker.targetId); + expect(await evaluate(background, "globalThis.catalogue.en.greeting")).toBe( + "Hello from the locale chunk fixture" + ); + + site = await startIntegrationSite(path.join(fixture.directory, "site")); + const pages = [ + {url: site.origin, contexts: ["isolated", "isolated-secondary", "main", "main-secondary"]}, + {url: `${origin}/${manifest.action.default_popup}`, contexts: ["popup"]}, + {url: `${origin}/${manifest.options_ui.page}`, contexts: ["options"]}, + ]; + for (const page of pages) { + const {targetId} = await client.send("Target.createTarget", {url: "about:blank"}); + const session = await attach(targetId); + await client.send("Page.navigate", {url: page.url}, session); + await waitFor(async () => { + expectPanels(await evaluate(session, ReadPanels), page.contexts); + return true; + }); + if (page.url === site.origin) { + expect(await evaluate(session, "typeof globalThis.chrome?.i18n")).toBe("undefined"); + } + await evaluate(session, ChangeLanguage); + expectPanels(await evaluate(session, ReadPanels), page.contexts, "fr"); + } + expect(client.runtimeErrors).toEqual([]); + } finally { + await client?.close(); + if (process) await stop(process); + await site?.close(); + await fixture.dispose(); + await rm(profile, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } +}); diff --git a/tests/integration/browser/locale/utils.ts b/tests/integration/browser/locale/utils.ts new file mode 100644 index 00000000..f244b37c --- /dev/null +++ b/tests/integration/browser/locale/utils.ts @@ -0,0 +1,24 @@ +import path from "path"; + +export const LocaleFixtureDirectory = path.join(ADNBN_TEST_ROOT, "tests/integration/build/locale/chunks-fixture"); + +export const ReadPanels = `Array.from(document.querySelectorAll('[data-locale-context]'), panel => ({ + context: panel.dataset.localeContext, + message: panel.querySelector('p').textContent, +})).sort((a, b) => a.context.localeCompare(b.context))`; + +export const ChangeLanguage = `(() => { + for (const select of document.querySelectorAll('[data-locale-context] select')) { + select.value = 'fr'; + select.dispatchEvent(new Event('change', {bubbles: true})); + } +})()`; + +export const expectPanels = (panels: unknown, contexts: string[], language = "en"): void => { + expect(panels).toEqual( + [...contexts].sort().map(context => ({ + context, + message: language === "en" ? "Hello from the locale chunk fixture" : "Bonjour depuis le catalogue partagé", + })) + ); +}; diff --git a/tests/integration/build/locale/chunks-fixture/adnbn.config.ts b/tests/integration/build/locale/chunks-fixture/adnbn.config.ts new file mode 100644 index 00000000..a0fe5276 --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/adnbn.config.ts @@ -0,0 +1,16 @@ +import {defineConfig} from "adnbn"; +import {writeFileSync} from "node:fs"; + +export default defineConfig({ + name: "Locale chunks integration", + version: "1.0.0", + commonChunks: true, + concatContentScripts: false, + jsFilename: "[name].[chunkhash:8].js", + specific: {gecko: {id: "locale-chunks@adnbn.test"}}, + bundler: config => { + const splitChunks = config.optimization?.splitChunks; + writeFileSync("cache-groups.json", JSON.stringify(Object.keys((splitChunks && splitChunks.cacheGroups) || {}))); + return {}; + }, +}); diff --git a/tests/integration/build/locale/chunks-fixture/package.json b/tests/integration/build/locale/chunks-fixture/package.json new file mode 100644 index 00000000..7d731f2d --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-locale-chunks", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "*" + } +} diff --git a/tests/integration/build/locale/chunks-fixture/site/top.html b/tests/integration/build/locale/chunks-fixture/site/top.html new file mode 100644 index 00000000..318e69a2 --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/site/top.html @@ -0,0 +1,8 @@ + + + + + Locale chunks + + + diff --git a/tests/integration/build/locale/chunks-fixture/src/background.ts b/tests/integration/build/locale/chunks-fixture/src/background.ts new file mode 100644 index 00000000..38e0761d --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/background.ts @@ -0,0 +1,5 @@ +import catalogue from "virtual/locale"; + +(globalThis as typeof globalThis & {catalogue: typeof catalogue}).catalogue = catalogue; + +export default () => {}; diff --git a/tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts b/tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts new file mode 100644 index 00000000..85ef57c9 --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts @@ -0,0 +1,4 @@ +declare module "virtual/locale" { + const catalogue: Record>; + export default catalogue; +} diff --git a/tests/integration/build/locale/chunks-fixture/src/isolated-secondary.content.ts b/tests/integration/build/locale/chunks-fixture/src/isolated-secondary.content.ts new file mode 100644 index 00000000..1ac29256 --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/isolated-secondary.content.ts @@ -0,0 +1,9 @@ +import {defineContentScript} from "adnbn"; +import catalogue from "virtual/locale"; +import {createPanel} from "./panel"; + +export default defineContentScript({ + matches: ["http://127.0.0.1/*", "https://example.com/*"], + world: "ISOLATED", + render: () => createPanel(catalogue, "isolated-secondary"), +}); diff --git a/tests/integration/build/locale/chunks-fixture/src/isolated.content.ts b/tests/integration/build/locale/chunks-fixture/src/isolated.content.ts new file mode 100644 index 00000000..92f47417 --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/isolated.content.ts @@ -0,0 +1,9 @@ +import {defineContentScript} from "adnbn"; +import catalogue from "virtual/locale"; +import {createPanel} from "./panel"; + +export default defineContentScript({ + matches: ["http://127.0.0.1/*", "https://example.com/*"], + world: "ISOLATED", + render: () => createPanel(catalogue, "isolated"), +}); diff --git a/tests/integration/build/locale/chunks-fixture/src/locales/en.json b/tests/integration/build/locale/chunks-fixture/src/locales/en.json new file mode 100644 index 00000000..a4a5fce0 --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/locales/en.json @@ -0,0 +1,3 @@ +{ + "greeting": "Hello from the locale chunk fixture" +} diff --git a/tests/integration/build/locale/chunks-fixture/src/locales/fr.json b/tests/integration/build/locale/chunks-fixture/src/locales/fr.json new file mode 100644 index 00000000..9630ff6b --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/locales/fr.json @@ -0,0 +1,3 @@ +{ + "greeting": "Bonjour depuis le catalogue partagé" +} diff --git a/tests/integration/build/locale/chunks-fixture/src/main-secondary.content.ts b/tests/integration/build/locale/chunks-fixture/src/main-secondary.content.ts new file mode 100644 index 00000000..5a4099ee --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/main-secondary.content.ts @@ -0,0 +1,9 @@ +import {defineContentScript} from "adnbn"; +import catalogue from "virtual/locale"; +import {createPanel} from "./panel"; + +export default defineContentScript({ + matches: ["http://127.0.0.1/*", "https://example.com/*"], + world: "MAIN", + render: () => createPanel(catalogue, "main-secondary"), +}); diff --git a/tests/integration/build/locale/chunks-fixture/src/main.content.ts b/tests/integration/build/locale/chunks-fixture/src/main.content.ts new file mode 100644 index 00000000..168c29b9 --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/main.content.ts @@ -0,0 +1,9 @@ +import {defineContentScript} from "adnbn"; +import catalogue from "virtual/locale"; +import {createPanel} from "./panel"; + +export default defineContentScript({ + matches: ["http://127.0.0.1/*", "https://example.com/*"], + world: "MAIN", + render: () => createPanel(catalogue, "main"), +}); diff --git a/tests/integration/build/locale/chunks-fixture/src/options.ts b/tests/integration/build/locale/chunks-fixture/src/options.ts new file mode 100644 index 00000000..3d6663b4 --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/options.ts @@ -0,0 +1,5 @@ +import {defineOptions} from "adnbn"; +import catalogue from "virtual/locale"; +import {createPanel} from "./panel"; + +export default defineOptions({render: () => createPanel(catalogue, "options")}); diff --git a/tests/integration/build/locale/chunks-fixture/src/panel.ts b/tests/integration/build/locale/chunks-fixture/src/panel.ts new file mode 100644 index 00000000..ae79b537 --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/panel.ts @@ -0,0 +1,12 @@ +export const createPanel = (catalogue: Record>, context: string): HTMLElement => { + const panel = document.createElement("div"); + panel.dataset.localeContext = context; + const select = document.createElement("select"); + for (const language of Object.keys(catalogue)) select.add(new Option(language, language)); + const message = document.createElement("p"); + const update = () => (message.textContent = catalogue[select.value].greeting); + select.addEventListener("change", update); + update(); + panel.append(select, message); + return panel; +}; diff --git a/tests/integration/build/locale/chunks-fixture/src/popup.ts b/tests/integration/build/locale/chunks-fixture/src/popup.ts new file mode 100644 index 00000000..c1aa9b81 --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/popup.ts @@ -0,0 +1,5 @@ +import {definePopup} from "adnbn"; +import catalogue from "virtual/locale"; +import {createPanel} from "./panel"; + +export default definePopup({render: () => createPanel(catalogue, "popup")}); diff --git a/tests/integration/build/locale/chunks-fixture/tsconfig.json b/tests/integration/build/locale/chunks-fixture/tsconfig.json new file mode 100644 index 00000000..d1b4ddb1 --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "./.adnbn/tsconfig.json" } diff --git a/tests/integration/build/locale/chunks.integration.test.ts b/tests/integration/build/locale/chunks.integration.test.ts new file mode 100644 index 00000000..43064098 --- /dev/null +++ b/tests/integration/build/locale/chunks.integration.test.ts @@ -0,0 +1,137 @@ +/** @jest-environment node */ + +import {readFile, readdir, rm, writeFile} from "fs/promises"; +import path from "path"; +import vm from "vm"; + +import {createLocaleModule} from "@cli/plugins/locale/module"; +import {createIntegrationFixture} from "../../utils/fixture"; + +jest.setTimeout(90_000); + +const Greeting = "Hello from the locale chunk fixture"; +const Entries = [ + "popup", + "options", + "isolated.content", + "isolated-secondary.content", + "main.content", + "main-secondary.content", +]; + +interface Scenario { + browser: "chrome" | "firefox"; + entries: string[]; + bytes?: number; + enabled?: boolean; +} + +test.each([ + {browser: "chrome", entries: ["popup"]}, + {browser: "chrome", entries: ["isolated.content"]}, + {browser: "chrome", entries: ["main.content"]}, + {browser: "chrome", entries: ["popup", "isolated.content"]}, + {browser: "chrome", entries: ["isolated.content", "main.content"]}, + {browser: "chrome", entries: Entries}, + {browser: "chrome", entries: ["popup"], bytes: 99_999}, + {browser: "chrome", entries: ["popup"], bytes: 100_000}, + {browser: "chrome", entries: ["isolated.content"], bytes: 100_000}, + {browser: "chrome", entries: ["main.content"], bytes: 100_000}, + {browser: "chrome", entries: [], bytes: 100_000}, + {browser: "chrome", entries: Entries, enabled: false}, + {browser: "firefox", entries: Entries}, +])("$browser: $entries, bytes=$bytes, commonChunks=$enabled", async ({browser, entries, bytes, enabled = true}) => { + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, path.join(__dirname, "chunks-fixture")); + try { + for (const entry of Entries) { + if (!entries.includes(entry)) await rm(path.join(fixture.directory, "src", `${entry}.ts`)); + } + if (bytes !== undefined) { + const empty = createLocaleModule({en: {greeting: Greeting, padding: "", locale: "en"}}); + const padding = "x".repeat(bytes - Buffer.byteLength(empty)); + const messages = {greeting: Greeting, padding}; + expect(Buffer.byteLength(createLocaleModule({en: {...messages, locale: "en"}}))).toBe(bytes); + await writeFile(path.join(fixture.directory, "src/locales/en.json"), JSON.stringify(messages)); + await rm(path.join(fixture.directory, "src/locales/fr.json")); + } + if (!enabled) { + const config = path.join(fixture.directory, "adnbn.config.ts"); + await writeFile( + config, + (await readFile(config, "utf8")).replace("commonChunks: true", "commonChunks: false") + ); + } + + const directory = await fixture.build({browser}); + const groups: string[] = JSON.parse(await readFile(path.join(fixture.directory, "cache-groups.json"), "utf8")); + expect(groups.filter(name => name.startsWith("adnbnLocale")).sort()).toEqual( + enabled ? ["adnbnLocaleLarge", "adnbnLocaleShared"] : [] + ); + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const backgroundFiles: string[] = manifest.background.service_worker + ? [manifest.background.service_worker] + : manifest.background.scripts; + expect(backgroundFiles).toHaveLength(1); + + const sources = new Map(); + for (const filename of await readdir(directory, {recursive: true})) { + if (filename.endsWith(".js")) { + sources.set(filename.split(path.sep).join("/"), await readFile(path.join(directory, filename), "utf8")); + } + } + const catalogueFiles = [...sources.keys()].filter(file => sources.get(file)!.includes(Greeting)); + expect(catalogueFiles).toContain(backgroundFiles[0]); + + const sandbox = {catalogue: undefined as Record> | undefined}; + vm.runInNewContext(sources.get(backgroundFiles[0])!, sandbox); + expect(sandbox.catalogue!.en.greeting).toBe(Greeting); + if (bytes === undefined) expect(sandbox.catalogue!.fr.greeting).toBe("Bonjour depuis le catalogue partagé"); + for (const [language, messages] of Object.entries(sandbox.catalogue!)) { + const json = JSON.parse( + await readFile(path.join(directory, "_locales", language, "messages.json"), "utf8") + ); + expect(messages).toEqual( + Object.fromEntries( + Object.entries(json).map(([key, value]) => [key, (value as {message: string}).message]) + ) + ); + } + + const consumers: string[][] = []; + for (const filename of [manifest.action?.default_popup, manifest.options_ui?.page]) { + if (!filename) continue; + const html = await readFile(path.join(directory, filename), "utf8"); + consumers.push( + [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map(([, file]) => + path.posix.normalize(path.posix.join(path.posix.dirname(filename), file)) + ) + ); + } + consumers.push(...(manifest.content_scripts ?? []).map((script: {js: string[]}) => script.js)); + expect(consumers).toHaveLength(entries.length); + + const split = enabled && entries.length > 0 && (entries.length >= 2 || (bytes ?? 0) >= 100_000); + const sharedFiles = new Set(); + for (const files of consumers) { + expect(files).not.toContain(backgroundFiles[0]); + for (const file of files) expect(sources.has(file)).toBe(true); + const found = files.filter(file => catalogueFiles.includes(file)); + expect(found).toHaveLength(1); + sharedFiles.add(found[0]); + if (enabled) { + expect(sources.get(found[0])!.includes("localeContext")).toBe(!split); + if (split) expect(found[0]).toMatch(/^js\/locale\.[a-f0-9]{8}\.js$/); + } + } + if (enabled) { + expect(catalogueFiles).toHaveLength(1 + (split ? 1 : entries.length)); + if (split) { + expect(sharedFiles.size).toBe(1); + const source = sources.get([...sharedFiles][0])!; + expect(source.split(Greeting)).toHaveLength(2); + } + } + } finally { + await fixture.dispose(); + } +}); diff --git a/tests/integration/build/locale/fixture/adnbn.config.ts b/tests/integration/build/locale/fixture/adnbn.config.ts new file mode 100644 index 00000000..a0651a1f --- /dev/null +++ b/tests/integration/build/locale/fixture/adnbn.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Locale Catalogue Integration", + description: "Build fixture for generated locale modules.", + version: "1.0.0", +}); diff --git a/tests/integration/build/locale/fixture/package.json b/tests/integration/build/locale/fixture/package.json new file mode 100644 index 00000000..1e3a5b21 --- /dev/null +++ b/tests/integration/build/locale/fixture/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-locale-catalogue", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "*" + } +} diff --git a/tests/integration/build/locale/fixture/src/background.ts b/tests/integration/build/locale/fixture/src/background.ts new file mode 100644 index 00000000..2a11f696 --- /dev/null +++ b/tests/integration/build/locale/fixture/src/background.ts @@ -0,0 +1,5 @@ +import catalogue from "virtual/locale"; + +(globalThis as typeof globalThis & {readLocaleCatalogue: () => typeof catalogue}).readLocaleCatalogue = () => catalogue; + +export default () => {}; diff --git a/tests/integration/build/locale/fixture/src/catalogue.d.ts b/tests/integration/build/locale/fixture/src/catalogue.d.ts new file mode 100644 index 00000000..85ef57c9 --- /dev/null +++ b/tests/integration/build/locale/fixture/src/catalogue.d.ts @@ -0,0 +1,4 @@ +declare module "virtual/locale" { + const catalogue: Record>; + export default catalogue; +} diff --git a/tests/integration/build/locale/fixture/src/locales/en.json b/tests/integration/build/locale/fixture/src/locales/en.json new file mode 100644 index 00000000..42a8ed28 --- /dev/null +++ b/tests/integration/build/locale/fixture/src/locales/en.json @@ -0,0 +1,13 @@ +{ + "app": { + "title": "Locale catalogue", + "greeting": "Hello {{name}}" + }, + "items": [ + "{{count}} item", + "{{count}} items" + ], + "empty": "", + "literal": "Quotes: \"hello\"; slash: \\; line:\nnext; dollar: $&", + "__proto__": "An ordinary translation key" +} diff --git a/tests/integration/build/locale/fixture/src/locales/fr.chrome.json b/tests/integration/build/locale/fixture/src/locales/fr.chrome.json new file mode 100644 index 00000000..31acf99a --- /dev/null +++ b/tests/integration/build/locale/fixture/src/locales/fr.chrome.json @@ -0,0 +1,5 @@ +{ + "app": { + "title": "Catalogue pour Chrome" + } +} diff --git a/tests/integration/build/locale/fixture/src/locales/fr.json b/tests/integration/build/locale/fixture/src/locales/fr.json new file mode 100644 index 00000000..94997767 --- /dev/null +++ b/tests/integration/build/locale/fixture/src/locales/fr.json @@ -0,0 +1,9 @@ +{ + "app": { + "title": "Catalogue de traductions" + }, + "items": [ + "{{count}} article", + "{{count}} articles" + ] +} diff --git a/tests/integration/build/locale/fixture/tsconfig.json b/tests/integration/build/locale/fixture/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/build/locale/fixture/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/build/locale/locale.integration.test.ts b/tests/integration/build/locale/locale.integration.test.ts new file mode 100644 index 00000000..a2c8859a --- /dev/null +++ b/tests/integration/build/locale/locale.integration.test.ts @@ -0,0 +1,94 @@ +/** @jest-environment node */ + +import {spawn, type ChildProcess} from "child_process"; +import {readFile, readdir, rm, writeFile} from "fs/promises"; +import path from "path"; +import vm from "vm"; +import {createIntegrationFixture} from "../../utils/fixture"; +import {stop, waitFor} from "../../browser/utils/browser"; + +jest.setTimeout(90_000); + +type Catalogue = Record>; + +const inspect = async (directory: string) => { + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const files: string[] = manifest.background.service_worker + ? [manifest.background.service_worker] + : manifest.background.scripts; + const sandbox = {readLocaleCatalogue: undefined as (() => Catalogue) | undefined}; + const context = vm.createContext(sandbox); + for (const file of files) { + vm.runInContext(await readFile(path.join(directory, file), "utf8"), context); + } + const catalogue = sandbox.readLocaleCatalogue!(); + const languages = await readdir(path.join(directory, "_locales")); + expect(Object.keys(catalogue).sort()).toEqual(languages.sort()); + for (const lang of languages) { + const messages = JSON.parse(await readFile(path.join(directory, "_locales", lang, "messages.json"), "utf8")); + const flattened = Object.fromEntries( + Object.entries(messages).map(([key, value]) => [key, (value as {message: string}).message]) + ); + expect(catalogue[lang]).toEqual(flattened); + expect(catalogue[lang].locale).toBe(lang); + } + return catalogue; +}; + +test.each(["chrome", "firefox"])("%s build imports the catalogue and preserves the native JSON data", async browser => { + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, path.join(__dirname, "fixture")); + try { + const catalogue = await inspect(await fixture.build({browser})); + expect(catalogue.fr).toMatchObject({ + app_title: browser === "chrome" ? "Catalogue pour Chrome" : "Catalogue de traductions", + app_greeting: "Hello {{name}}", + items: "{{count}} article|{{count}} articles", + empty: "", + literal: 'Quotes: "hello"; slash: \\; line:\nnext; dollar: $&', + }); + expect(Object.hasOwn(catalogue.fr, "__proto__")).toBe(true); + expect(catalogue.fr.__proto__).toBe("An ordinary translation key"); + } finally { + await fixture.dispose(); + } +}); + +test("CLI watch refreshes the catalogue, JSON and declarations from the same locale edit", async () => { + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, path.join(__dirname, "fixture")); + let watcher: ChildProcess | undefined; + let output = ""; + try { + const directory = await fixture.build(); + await rm(path.join(directory, "manifest.json")); + watcher = spawn(process.execPath, [path.join(ADNBN_TEST_ROOT, "bin/adnbn.js"), "watch", ".", "-b", "chrome"], { + cwd: fixture.directory, + stdio: ["ignore", "pipe", "pipe"], + }); + watcher.stdout?.on("data", chunk => (output += chunk)); + watcher.stderr?.on("data", chunk => (output += chunk)); + await waitFor(() => inspect(directory), 15000, "initial locale watch build"); + await writeFile( + path.join(fixture.directory, "src/locales/en.json"), + await readFile(path.join(__dirname, "states/en.json")) + ); + await waitFor( + async () => { + const catalogue = await inspect(directory); + expect(catalogue.fr.app_greeting).toBe("Welcome {{name}}"); + expect(catalogue.fr.newKey).toBe("Added during watch"); + expect(catalogue.fr).not.toHaveProperty("empty"); + const declarations = await readFile(path.join(fixture.directory, ".adnbn/locale.d.ts"), "utf8"); + expect(declarations).toContain('"newKey"'); + expect(declarations).not.toContain('"empty"'); + return true; + }, + 15000, + "updated locale catalogue and JSON" + ); + } catch (error) { + throw new Error(`${String(error)}\n${output}`, {cause: error}); + } finally { + if (watcher) await stop(watcher); + await fixture.dispose(); + } +}); diff --git a/tests/integration/build/locale/states/en.json b/tests/integration/build/locale/states/en.json new file mode 100644 index 00000000..4b95c239 --- /dev/null +++ b/tests/integration/build/locale/states/en.json @@ -0,0 +1,11 @@ +{ + "app": { + "title": "Updated catalogue", + "greeting": "Welcome {{name}}" + }, + "items": [ + "{{count}} item", + "{{count}} items" + ], + "newKey": "Added during watch" +} diff --git a/tests/jest-globals.d.ts b/tests/jest-globals.d.ts new file mode 100644 index 00000000..ce25f8c8 --- /dev/null +++ b/tests/jest-globals.d.ts @@ -0,0 +1,2 @@ +/** Repository root supplied by the Jest configuration. */ +declare const ADNBN_TEST_ROOT: string; From 17e2ef47c5344883ed594d912a7ee52b97d54a35 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:45:14 +0300 Subject: [PATCH 22/38] feat(locale): use bundled translations in DynamicLocale --- jest.config.ts | 1 + package.json | 3 + .../GenerateModulePlugin.test.ts | 13 +- .../generate-module/GenerateModulePlugin.ts | 14 +- src/cli/plugins/locale/Locale.test.ts | 13 +- src/cli/plugins/locale/Locale.ts | 4 +- src/cli/plugins/locale/index.ts | 26 +- src/cli/plugins/locale/module.ts | 12 +- src/cli/plugins/locale/types.ts | 3 - src/locale/README.md | 41 +++ .../adapters/react/localeProvider.test.ts | 43 +-- .../react/tests/fixtures/catalogue.json | 14 + src/locale/catalogue/catalogue.ts | 9 + src/locale/catalogue/index.ts | 2 + src/locale/catalogue/module.d.ts | 6 + src/locale/helpers.test.ts | 13 +- src/locale/providers/DynamicLocale.test.ts | 306 ++++++++++++------ src/locale/providers/DynamicLocale.ts | 82 ++--- src/locale/providers/NativeLocale.test.ts | 37 ++- src/locale/providers/NativeLocale.ts | 19 +- .../tests/fixtures/dynamic/catalogue.json | 29 ++ src/types/locale.ts | 7 + .../locale/chunks.firefox.integration.test.ts | 2 +- .../browser/locale/chunks.integration.test.ts | 2 +- .../dynamic.firefox.integration.test.ts | 83 +++++ .../locale/dynamic.integration.test.ts | 153 +++++++++ tests/integration/browser/utils/BidiClient.ts | 7 +- tests/integration/browser/utils/chrome.ts | 6 + .../locale/chunks-fixture/src/catalogue.d.ts | 2 + .../build/locale/chunks.integration.test.ts | 61 +++- .../locale/dynamic-fixture/adnbn.config.ts | 11 + .../build/locale/dynamic-fixture/package.json | 9 + .../locale/dynamic-fixture/site/top.html | 8 + .../locale/dynamic-fixture/src/background.ts | 7 + .../locale/dynamic-fixture/src/contract.ts | 22 ++ .../dynamic-fixture/src/locale.content.ts | 8 + .../dynamic-fixture/src/locales/en.json | 10 + .../dynamic-fixture/src/locales/fr.json | 9 + .../locale/dynamic-fixture/src/options.ts | 4 + .../build/locale/dynamic-fixture/src/panel.ts | 26 ++ .../build/locale/dynamic-fixture/src/popup.ts | 4 + .../locale/dynamic-fixture/tsconfig.json | 3 + .../dynamic-fixture/tsconfig.locale.json | 15 + .../build/locale/dynamic.integration.test.ts | 103 ++++++ .../build/locale/fixture/src/background.ts | 4 +- .../build/locale/fixture/src/catalogue.d.ts | 2 + .../build/locale/fixture/src/locales/fr.json | 1 + .../build/locale/locale.integration.test.ts | 33 +- tests/integration/build/locale/states/de.json | 9 + .../build/locale/states/native-background.ts | 4 + .../build/locale/states/package-import.mjs | 6 + 51 files changed, 1057 insertions(+), 254 deletions(-) delete mode 100644 src/cli/plugins/locale/types.ts create mode 100644 src/locale/README.md create mode 100644 src/locale/adapters/react/tests/fixtures/catalogue.json create mode 100644 src/locale/catalogue/catalogue.ts create mode 100644 src/locale/catalogue/index.ts create mode 100644 src/locale/catalogue/module.d.ts create mode 100644 src/locale/providers/tests/fixtures/dynamic/catalogue.json create mode 100644 tests/integration/browser/locale/dynamic.firefox.integration.test.ts create mode 100644 tests/integration/browser/locale/dynamic.integration.test.ts create mode 100644 tests/integration/build/locale/dynamic-fixture/adnbn.config.ts create mode 100644 tests/integration/build/locale/dynamic-fixture/package.json create mode 100644 tests/integration/build/locale/dynamic-fixture/site/top.html create mode 100644 tests/integration/build/locale/dynamic-fixture/src/background.ts create mode 100644 tests/integration/build/locale/dynamic-fixture/src/contract.ts create mode 100644 tests/integration/build/locale/dynamic-fixture/src/locale.content.ts create mode 100644 tests/integration/build/locale/dynamic-fixture/src/locales/en.json create mode 100644 tests/integration/build/locale/dynamic-fixture/src/locales/fr.json create mode 100644 tests/integration/build/locale/dynamic-fixture/src/options.ts create mode 100644 tests/integration/build/locale/dynamic-fixture/src/panel.ts create mode 100644 tests/integration/build/locale/dynamic-fixture/src/popup.ts create mode 100644 tests/integration/build/locale/dynamic-fixture/tsconfig.json create mode 100644 tests/integration/build/locale/dynamic-fixture/tsconfig.locale.json create mode 100644 tests/integration/build/locale/dynamic.integration.test.ts create mode 100644 tests/integration/build/locale/states/de.json create mode 100644 tests/integration/build/locale/states/native-background.ts create mode 100644 tests/integration/build/locale/states/package-import.mjs diff --git a/jest.config.ts b/jest.config.ts index a285024f..9e19c53b 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -8,6 +8,7 @@ const config: Config = { modulePathIgnorePatterns: ["/.cache/"], resolver: "/tests/raw-module-resolver.cjs", moduleNameMapper: { + "^#adnbn/locale$": "/src/locale/catalogue/index.ts", "^@cli/(.*)$": "/src/cli/$1", "^@entry/(.*)$": "/src/entry/$1", "^@frame/(.*)$": "/src/frame/$1", diff --git a/package.json b/package.json index df1a5507..c1ad26ac 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,9 @@ "types" ], "sideEffects": false, + "imports": { + "#adnbn/locale": "./dist/locale/catalogue/index.js" + }, "engines": { "node": ">=22.0.0" }, diff --git a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts index 1e547335..0035058f 100644 --- a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts +++ b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts @@ -115,9 +115,14 @@ test("isolates compilers with identical initial sources, updates and cleanup", a expect(await run(second)).toBe(6); }); -test.each([false, true])("shares only generated modules across entrypoint layers when configured: %s", async shared => { +test.each([ + {sharing: "none", layers: [null, "isolated", "main"]}, + {sharing: "all", layers: ["shared-data"]}, + {sharing: "filtered", layers: ["shared-data", "main"]}, +])("shares generated modules across the selected issuer layers: $sharing", async ({sharing, layers}) => { const plugin = new GenerateModulePlugin(modules); - if (shared) plugin.layer("shared-data"); + if (sharing === "all") plugin.layer("shared-data"); + if (sharing === "filtered") plugin.layer("shared-data", {not: ["main"]}); const compiler = rspack({ mode: "none", context: fixtures, @@ -139,8 +144,8 @@ test.each([false, true])("shares only generated modules across entrypoint layers const built = [...stats.compilation.modules] as NormalModule[]; for (const request of ["virtual/value", "virtual/tools"]) { const instances = built.filter(module => module.rawRequest === request); - expect(instances).toHaveLength(shared ? 1 : 3); - if (shared) expect(instances[0].layer).toBe("shared-data"); + expect(instances).toHaveLength(layers.length); + expect(new Set(instances.map(module => module.layer ?? null))).toEqual(new Set(layers)); } expect(built.filter(module => module.rawRequest === "./entry.js")).toHaveLength(3); for (const entry of ["view", "isolated", "main"]) { diff --git a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts index 727d7d7a..da4d364d 100644 --- a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts +++ b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts @@ -1,6 +1,6 @@ import {randomUUID} from "crypto"; import path from "path"; -import type {Compiler} from "@rspack/core"; +import type {Compiler, RuleSetCondition} from "@rspack/core"; import {RspackVirtualModulePlugin} from "rspack-plugin-virtual-module"; /** JavaScript source keyed by the module's import specifier. */ @@ -14,12 +14,14 @@ export class GenerateModulePlugin { private update?: GenerateModulePluginUpdate; private files: readonly string[] = []; private moduleLayer?: string; + private issuerLayer?: RuleSetCondition; constructor(private readonly modules: GenerateModulePluginModules) {} - /** Use one module identity regardless of the importing entrypoint's layer. */ - public layer(name: string): this { + /** Share a module layer for matching issuers; other issuers keep their inherited layer. */ + public layer(name: string, issuerLayer?: RuleSetCondition): this { this.moduleLayer = name; + this.issuerLayer = issuerLayer; return this; } @@ -38,7 +40,11 @@ export class GenerateModulePlugin { if (this.moduleLayer !== undefined) { const resources = Object.keys(modules).map(name => compiler.options.resolve.alias![name] as string); - compiler.options.module.rules.push({include: resources, layer: this.moduleLayer}); + compiler.options.module.rules.push({ + include: resources, + layer: this.moduleLayer, + issuerLayer: this.issuerLayer, + }); } compiler.hooks.compilation.tap(this.pluginName, compilation => { diff --git a/src/cli/plugins/locale/Locale.test.ts b/src/cli/plugins/locale/Locale.test.ts index ffb4d611..c12d99f6 100644 --- a/src/cli/plugins/locale/Locale.test.ts +++ b/src/cli/plugins/locale/Locale.test.ts @@ -1,12 +1,14 @@ jest.mock("@addon-core/browser", () => ({ getI18nMessage: jest.fn(() => "de"), })); +jest.mock("#adnbn/locale", () => ({__esModule: true, default: {}, keys: [], languages: []})); import fs from "fs"; import os from "os"; import path from "path"; import {getI18nMessage} from "@addon-core/browser"; +import catalogue from "#adnbn/locale"; import Locale from "./Locale"; import DynamicLocale from "@locale/providers/DynamicLocale"; import NativeLocale from "@locale/providers/NativeLocale"; @@ -70,6 +72,7 @@ describe("locale JSON completion", () => { afterEach(() => { consoleWarnSpy.mockRestore(); + for (const lang of Object.keys(catalogue)) delete catalogue[lang as Language]; if (fetchDescriptor) { Object.defineProperty(globalThis, "fetch", fetchDescriptor); @@ -252,12 +255,16 @@ describe("locale JSON completion", () => { }); test("dynamic selection uses the generated default rather than a third native language", async () => { - const json = await makeLocale("single").json(); + const locale = makeLocale("single"); + const json = await locale.json(); + Object.assign(catalogue, await locale.catalogue()); 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)})), + value: jest.fn(() => { + throw new Error("Translations must come from the catalogue"); + }), }); const dynamic = new DynamicLocale<{ "app.greeting": {plural: false; substitutions: ["name"]}; @@ -273,6 +280,6 @@ describe("locale JSON completion", () => { 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)); + expect(globalThis.fetch).not.toHaveBeenCalled(); }); }); diff --git a/src/cli/plugins/locale/Locale.ts b/src/cli/plugins/locale/Locale.ts index b872f6a0..f52f1646 100644 --- a/src/cli/plugins/locale/Locale.ts +++ b/src/cli/plugins/locale/Locale.ts @@ -5,9 +5,7 @@ import {GenerateJsonPluginData} from "@cli/bundler"; import {flattenLocaleMessages} from "@shared/locale/messages"; -import type {Language, LocaleMessages} from "@typing/locale"; - -import type {LocaleCatalogue} from "./types"; +import type {Language, LocaleCatalogue, LocaleMessages} from "@typing/locale"; export default class Locale extends LocaleFinder { private _messages?: Promise>; diff --git a/src/cli/plugins/locale/index.ts b/src/cli/plugins/locale/index.ts index 1914b771..08604569 100644 --- a/src/cli/plugins/locale/index.ts +++ b/src/cli/plugins/locale/index.ts @@ -1,16 +1,19 @@ import _ from "lodash"; -import {DefinePlugin, type Configuration as RspackConfig} from "@rspack/core"; +import type {Configuration as RspackConfig} from "@rspack/core"; import {definePlugin} from "@main/plugin"; import {GenerateJsonPlugin, GenerateModulePlugin} from "@cli/bundler"; +import {getContentLayer} from "@cli/bundler/utils/layers"; import {extractLocaleKey, modifyLocaleMessageKey} from "@locale/utils"; import Locale from "./Locale"; import {LocaleDeclaration} from "./declaration"; -import {createLocaleModule, LocaleModuleName} from "./module"; +import {createLocaleModule} from "./module"; import {Command} from "@typing/app"; import {Browser} from "@typing/browser"; +import {ContentScriptWorld} from "@typing/content"; +import {LocaleModuleLayer, LocaleModuleName} from "@typing/locale"; export default definePlugin(() => { let locale: Locale; @@ -30,16 +33,15 @@ export default definePlugin(() => { }; const getModules = async () => ({ - [LocaleModuleName]: createLocaleModule(await locale.catalogue()), + [LocaleModuleName]: createLocaleModule(await locale.catalogue(), await locale.keys()), }); await prepareLocale(); const jsonPlugin = new GenerateJsonPlugin(await locale.json()); - const modulePlugin = new GenerateModulePlugin(await getModules()).layer("adnbn:locale"); - const constantsPlugin = new DefinePlugin({ - __ADNBN_LOCALE_KEYS__: JSON.stringify([...(await locale.keys())]), - __ADNBN_DEFINED_LOCALES__: JSON.stringify([...(await locale.languages())]), + // MAIN keeps its content layer and uses the regular common-main.content group. + const modulePlugin = new GenerateModulePlugin(await getModules()).layer(LocaleModuleLayer, { + not: [getContentLayer(ContentScriptWorld.Main)], }); if (config.command === Command.Watch) { @@ -56,22 +58,26 @@ export default definePlugin(() => { return { // JSON refresh clears and prepares the shared cache before the module reads it. - plugins: [jsonPlugin, modulePlugin, constantsPlugin], + plugins: [jsonPlugin, modulePlugin], + resolve: {alias: {"#adnbn/locale$": LocaleModuleName}}, optimization: config.commonChunks ? { splitChunks: { cacheGroups: { adnbnLocaleShared: { - layer: "adnbn:locale", + layer: LocaleModuleLayer, name: "locale", + // Share the module across consumers of different exports. + usedExports: false, minChunks: 2, minSize: 0, enforce: true, priority: 60, }, adnbnLocaleLarge: { - layer: "adnbn:locale", + layer: LocaleModuleLayer, name: "locale", + usedExports: false, minChunks: 1, minSize: 100_000, enforce: true, diff --git a/src/cli/plugins/locale/module.ts b/src/cli/plugins/locale/module.ts index a7a30ac9..d1e94118 100644 --- a/src/cli/plugins/locale/module.ts +++ b/src/cli/plugins/locale/module.ts @@ -1,7 +1,7 @@ -import type {LocaleCatalogue} from "./types"; +import type {LocaleCatalogue} from "@typing/locale"; -export const LocaleModuleName = "virtual/locale"; - -export const createLocaleModule = (catalogue: LocaleCatalogue): string => - // Parse serialized JSON to preserve own keys such as __proto__ in the exported object. - `export default JSON.parse(${JSON.stringify(JSON.stringify(catalogue))});\n`; +export const createLocaleModule = (catalogue: LocaleCatalogue, keys: ReadonlySet): string => + // JSON.parse preserves own __proto__ keys; the pure annotation lets native consumers omit the catalogue. + `export const keys = ${JSON.stringify([...keys])};\n` + + `export const languages = ${JSON.stringify(Object.keys(catalogue))};\n` + + `export default /*#__PURE__*/ JSON.parse(${JSON.stringify(JSON.stringify(catalogue))});\n`; diff --git a/src/cli/plugins/locale/types.ts b/src/cli/plugins/locale/types.ts deleted file mode 100644 index 5b26f6d6..00000000 --- a/src/cli/plugins/locale/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type {Language} from "@typing/locale"; - -export type LocaleCatalogue = Partial>>; diff --git a/src/locale/README.md b/src/locale/README.md new file mode 100644 index 00000000..9ef30871 --- /dev/null +++ b/src/locale/README.md @@ -0,0 +1,41 @@ +--- +description: Runtime translation providers, shared formatting, catalogue access, and React integration. +--- + +# Locale + +`src/locale` implements translation at runtime: message lookup, substitutions, plural forms, language selection, and UI integration. Its public entrypoints are `adnbn/locale` and `adnbn/locale/react`. + +## Directory responsibilities + +| Area | Responsibility | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `providers/AbstractLocale.ts` | Common string rendering, plural rules, missing-message diagnostics, and language names. | +| `providers/NativeLocale.ts` | Browser i18n message lookup and a singleton used by the public helpers. | +| `providers/DynamicLocale.ts` | Compiled translations, language state per instance, and optional storage synchronization. | +| `providers/CustomLocale.ts` | Internal provider for an explicitly supplied language and flat message dictionary; not re-exported by the public entrypoint. | +| `helpers.ts` | Native shortcuts: `t`, `choice`, `key` for browser message references, and `resolve` for strings prefixed with `@`. | +| `utils.ts` | Key conversion, locale markers, language resolution, and text direction. | +| `catalogue/` | Empty package fallback and typing for the generated data module. | +| `adapters/react/` | `LocaleProvider`, its context, and `useLocale`; connects dynamic localization to React state and DOM `lang`/`dir` attributes. | +| `index.ts` | Public providers, helpers, and selected shared contracts. | + +## Ownership and data flow + +Shared languages, contracts, and module identifiers belong to `src/types/locale.ts`. Generated `.adnbn/locale.d.ts` augments `LocaleNativeStructure` through `adnbn/locale`, defining the application's keys and required substitutions from its default language. + +Locale discovery, merging, validation, fallback preparation, native `_locales/*/messages.json`, and declaration generation belong to the CLI locale pipeline. The locale feature plugin supplies the generated data; Rspack integration and chunk delivery belong to the bundler. Shared message-flattening logic lives in `src/shared/locale`. + +During extension builds, the private `#adnbn/locale` import resolves to `virtual/locale`: a default catalogue export plus named `keys` and `languages`. `DynamicLocale` reads the catalogue; `NativeLocale` imports only the named lists, allowing unused translations to be removed from optimized bundles. Outside these builds, `catalogue/` provides a resolvable empty module. + +Views and ISOLATED content scripts can share `locale.js`. MAIN retains its own content layer: its catalogue stays in the entrypoint or joins `common-main.content.js` under the regular content chunk rules. Background keeps translations in its single bundle. This separates file delivery across content execution worlds; the same translations may occur in their respective bundles. + +Core providers have no React dependency. Browser i18n access goes through `@addon-core/browser`; persistence goes through `@addon-core/storage`. Both native and dynamic providers determine their initial language from the browser's `locale` message. The empty package fallback does not make them operational without the required browser API and generated locale data. + +## Runtime behavior + +Translations are synchronous strings. Providers share substitution and plural processing through `AbstractLocale`; `trans` handles non-plural keys and `choice` selects a form by count. Substitutions accept strings and numbers. Generated public keys use dot notation, while message lookup converts them to underscore keys. Missing messages warn and return the key; empty translations remain empty strings. + +`DynamicLocale` reads bundled messages without fetching JSON or dynamically importing languages. `change()` updates its state immediately and returns a promise for saving the language code. Storage defaults to local storage with the key `"lang"`; a string changes the key, and `false` keeps state only in the instance. + +Storage initialization is explicit: `sync()` reads the saved language, `watch()` observes subsequent storage events, and `unwatch()` disconnects. The constructor does neither. `watch()` is not a general subscription to in-memory state; `sync()` and `watch()` require enabled storage. The current React provider connects these methods on mount and exposes their results through context. diff --git a/src/locale/adapters/react/localeProvider.test.ts b/src/locale/adapters/react/localeProvider.test.ts index 796546d7..f317eb82 100644 --- a/src/locale/adapters/react/localeProvider.test.ts +++ b/src/locale/adapters/react/localeProvider.test.ts @@ -1,6 +1,12 @@ jest.mock("@addon-core/browser", () => ({ getI18nMessage: jest.fn(() => "en"), })); +jest.mock("#adnbn/locale", () => ({ + __esModule: true, + default: require("./tests/fixtures/catalogue.json"), + keys: ["demo.empty"], + languages: ["en", "fr", "ru"], +})); import {createElement, PropsWithChildren} from "react"; import {act, cleanup, renderHook, waitFor} from "@testing-library/react"; @@ -10,23 +16,15 @@ 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"}})})), + value: jest.fn(() => { + throw new Error("Translations must come from the catalogue"); + }), }); }); @@ -34,16 +32,10 @@ describe("React locale provider", () => { 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); - } + if (fetchDescriptor) { + Object.defineProperty(globalThis, "fetch", fetchDescriptor); + } else { + Reflect.deleteProperty(globalThis, "fetch"); } }); @@ -67,11 +59,8 @@ describe("React locale provider", () => { 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 () => { + test("preserves an empty catalogue 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); @@ -91,6 +80,7 @@ describe("React locale provider", () => { expect(result.current).toEqual({lang: Language.Russian, message: ""}); expect(warn).not.toHaveBeenCalled(); expect(fetch).not.toHaveBeenCalled(); + expect(getI18nMessage).toHaveBeenCalledTimes(1); }); test("keeps the available language map stable while changing the selected language", async () => { @@ -103,6 +93,7 @@ describe("React locale provider", () => { expect([...langs]).toEqual([ [Language.English, "English"], [Language.French, "Français"], + [Language.Russian, "Русский"], ]); act(() => result.current.change(Language.French)); @@ -110,6 +101,6 @@ describe("React locale provider", () => { rerender(); expect(result.current.langs).toBe(langs); - expect([...result.current.langs.keys()]).toEqual([Language.English, Language.French]); + expect([...result.current.langs.keys()]).toEqual([Language.English, Language.French, Language.Russian]); }); }); diff --git a/src/locale/adapters/react/tests/fixtures/catalogue.json b/src/locale/adapters/react/tests/fixtures/catalogue.json new file mode 100644 index 00000000..059e298f --- /dev/null +++ b/src/locale/adapters/react/tests/fixtures/catalogue.json @@ -0,0 +1,14 @@ +{ + "en": { + "locale": "en", + "demo_empty": "" + }, + "fr": { + "locale": "fr", + "demo_empty": "" + }, + "ru": { + "locale": "ru", + "demo_empty": "" + } +} diff --git a/src/locale/catalogue/catalogue.ts b/src/locale/catalogue/catalogue.ts new file mode 100644 index 00000000..f1b756a8 --- /dev/null +++ b/src/locale/catalogue/catalogue.ts @@ -0,0 +1,9 @@ +import type {Language, LocaleCatalogue} from "@typing/locale"; + +/** Package fallback; extension builds replace #adnbn/locale with virtual/locale. */ +const catalogue: LocaleCatalogue = {}; + +export const keys: readonly string[] = []; +export const languages: readonly Language[] = []; + +export default catalogue; diff --git a/src/locale/catalogue/index.ts b/src/locale/catalogue/index.ts new file mode 100644 index 00000000..f6e827b2 --- /dev/null +++ b/src/locale/catalogue/index.ts @@ -0,0 +1,2 @@ +export {default} from "./catalogue"; +export * from "./catalogue"; diff --git a/src/locale/catalogue/module.d.ts b/src/locale/catalogue/module.d.ts new file mode 100644 index 00000000..c267d398 --- /dev/null +++ b/src/locale/catalogue/module.d.ts @@ -0,0 +1,6 @@ +declare module "#adnbn/locale" { + const catalogue: import("@typing/locale").LocaleCatalogue; + export const keys: readonly string[]; + export const languages: readonly import("@typing/locale").Language[]; + export default catalogue; +} diff --git a/src/locale/helpers.test.ts b/src/locale/helpers.test.ts index fd301c09..8b1ab0df 100644 --- a/src/locale/helpers.test.ts +++ b/src/locale/helpers.test.ts @@ -13,23 +13,14 @@ jest.mock("@addon-core/browser", () => ({ import {getI18nMessage} from "@addon-core/browser"; import {resolve} from "./helpers"; -describe("locale resolve", () => { - const keysDescriptor = Object.getOwnPropertyDescriptor(globalThis, "__ADNBN_LOCALE_KEYS__"); +jest.mock("#adnbn/locale", () => ({keys: ["locale", "app.title", "app.greeting"], languages: ["en"]})); +describe("locale resolve", () => { 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(); }); diff --git a/src/locale/providers/DynamicLocale.test.ts b/src/locale/providers/DynamicLocale.test.ts index 32d5494d..3f34b35f 100644 --- a/src/locale/providers/DynamicLocale.test.ts +++ b/src/locale/providers/DynamicLocale.test.ts @@ -1,111 +1,227 @@ -jest.mock("@addon-core/browser", () => ({ - getI18nMessage: jest.fn(() => "en"), +jest.mock("@addon-core/browser", () => ({getI18nMessage: jest.fn(() => "en")})); +jest.mock("@addon-core/storage", () => ({Storage: {Local: jest.fn()}})); +jest.mock("#adnbn/locale", () => ({ + __esModule: true, + default: require("./tests/fixtures/dynamic/catalogue.json"), + keys: ["greeting", "items", "app.title", "fallback", "empty", "__proto__"], + languages: ["en", "fr", "en_GB"], })); -import DynamicLocale from "./DynamicLocale"; -import CustomLocale, {CustomLocaleData} from "./CustomLocale"; - import {getI18nMessage} from "@addon-core/browser"; +import {Storage, type StorageProvider} from "@addon-core/storage"; +import DynamicLocale from "./DynamicLocale"; import {Language} from "@typing/locale"; interface Structure { + greeting: {plural: false; substitutions: ["name"]}; + items: {plural: true; substitutions: ["count"]}; + "app.title": {plural: false; substitutions: []}; + fallback: {plural: false; substitutions: []}; empty: {plural: false; substitutions: []}; + __proto__: {plural: false; substitutions: []}; } -class TestDynamicLocale extends DynamicLocale { - public setLocale(lang: Language, data: CustomLocaleData): this { - this.locale = new CustomLocale(lang, data); +type Watcher = Record void>; +const keys = ["greeting", "items", "app.title", "fallback", "empty", "__proto__"]; +let stored: string | undefined; +const listeners = new Set(); +const notify = (key: string, value: string | undefined) => { + for (const watcher of listeners) watcher[key]?.(value); +}; +const driver = { + get: jest.fn(async (_key: string) => stored), + set: jest.fn(async (key: string, value: string) => { + stored = value; + notify(key, value); + }), + watch: jest.fn((watcher: Watcher) => { + listeners.add(watcher); + return () => listeners.delete(watcher); + }), +}; + +beforeEach(() => { + stored = undefined; + listeners.clear(); + jest.clearAllMocks(); + jest.mocked(getI18nMessage).mockReturnValue("en"); + jest.mocked(Storage.Local).mockReturnValue(driver as unknown as StorageProvider>); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); - return this; - } -} +test("reads the initial language from browser i18n and translates synchronously from the catalogue", () => { + const locale = new DynamicLocale(); + expect(locale.lang()).toBe(Language.English); + expect(locale.trans("greeting", {name: "Ada"})).toBe("Hello Ada"); + expect(locale.trans("app.title")).toBe("Catalogue"); + expect(locale.trans("empty")).toBe(""); + expect(locale.trans("__proto__")).toBe("Ordinary message"); + expect(locale.choice("items", 2, {count: 2})).toBe("2 items"); + expect(getI18nMessage).toHaveBeenCalledTimes(1); + expect(getI18nMessage).toHaveBeenCalledWith("locale"); + expect(driver.get).not.toHaveBeenCalled(); + expect(driver.watch).not.toHaveBeenCalled(); +}); + +test("normalizes the native marker before selecting catalogue data", () => { + jest.mocked(getI18nMessage).mockReturnValue("en-GB"); + const locale = new DynamicLocale(false); + expect(locale.lang()).toBe(Language.English); + expect(locale.trans("app.title")).toBe("Catalogue"); +}); + +test.each(["", "unknown", "de"])("rejects an unavailable native language: %s", marker => { + jest.mocked(getI18nMessage).mockReturnValue(marker); + expect(() => new DynamicLocale(false)).toThrow(/Language/); +}); + +test("keeps public dot keys separate from catalogue keys and returns independent language collections", () => { + const locale = new DynamicLocale(false); + expect([...locale.keys()]).toEqual(keys); + expect([...locale.languageNames()]).toEqual([ + [Language.English, "English"], + [Language.French, "Français"], + [Language.EnglishGreatBritain, "English (United Kingdom)"], + ]); + locale.languages().clear(); + locale.languageNames().clear(); + locale.keys().clear(); + expect(locale.languages().size).toBe(3); + expect(locale.languageNames().size).toBe(3); + expect(locale.keys().size).toBe(keys.length); +}); -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" : "")); +test("switches messages immediately while waiting for storage persistence", async () => { + const write = Promise.withResolvers(); + driver.set.mockImplementationOnce(() => write.promise); + const locale = new DynamicLocale(); + const saved = locale.change(Language.French); + expect(locale.lang()).toBe(Language.French); + expect(locale.trans("greeting", {name: "Ada"})).toBe("Bonjour Ada"); + expect(locale.choice("items", 0, {count: 0})).toBe("0 article"); + expect(locale.trans("fallback")).toBe("Shared default message"); + expect(locale.trans("empty")).toBe(""); + expect(driver.set).toHaveBeenCalledWith("lang", Language.French); + write.resolve(); + await expect(saved).resolves.toBe(Language.French); +}); + +test("persists a repeated selection of the current language under the configured key", async () => { + const locale = new DynamicLocale("chosen-language"); + await locale.change(Language.English); + expect(driver.set).toHaveBeenCalledWith("chosen-language", Language.English); + expect(stored).toBe(Language.English); +}); + +test("rejects write errors without rolling back a newer selection", async () => { + const write = Promise.withResolvers(); + driver.set.mockImplementationOnce(() => write.promise); + const locale = new DynamicLocale(); + const first = locale.change(Language.French); + await locale.change(Language.EnglishGreatBritain); + const error = new Error("Storage write failed"); + write.reject(error); + await expect(first).rejects.toBe(error); + expect(locale.lang()).toBe(Language.EnglishGreatBritain); +}); + +test("ignores prototype properties and rejects languages absent from the application catalogue", async () => { + const locale = new DynamicLocale>(false); + const warn = jest.spyOn(console, "warn").mockImplementation(); + expect(locale.trans("toString")).toBe("toString"); + expect(warn).toHaveBeenCalledWith('Locale key "toString" not found in "en" language.'); + await expect(locale.change(Language.German)).rejects.toThrow('Language "de" is not available'); + await expect(locale.change("__proto__" as Language)).rejects.toThrow("is not available"); + expect(locale.lang()).toBe(Language.English); + expect(driver.set).not.toHaveBeenCalled(); +}); + +test("restores the saved language only through explicit sync, without writing it back", async () => { + stored = Language.French; + const locale = new DynamicLocale("chosen-language"); + expect(locale.lang()).toBe(Language.English); + expect(driver.get).not.toHaveBeenCalled(); + await expect(locale.sync()).resolves.toBe(Language.French); + expect(driver.get).toHaveBeenCalledWith("chosen-language"); + expect(driver.set).not.toHaveBeenCalled(); +}); + +test.each([undefined, "de", "unknown", "__proto__"])( + "keeps the current language for invalid or absent storage: %s", + async value => { + stored = value; const warn = jest.spyOn(console, "warn").mockImplementation(); - const locale = new DynamicLocale(false); + const locale = new DynamicLocale(); + await expect(locale.sync()).resolves.toBe(Language.English); + expect(driver.set).not.toHaveBeenCalled(); + if (value) expect(warn).toHaveBeenCalledWith(`Incorrect language code in storage - "${value}"`); + else expect(warn).not.toHaveBeenCalled(); + } +); + +test("propagates storage read errors without changing the language", async () => { + const error = new Error("Storage read failed"); + driver.get.mockRejectedValueOnce(error); + const locale = new DynamicLocale(); + await expect(locale.sync()).rejects.toBe(error); + expect(locale.lang()).toBe(Language.English); +}); - expect(locale.lang()).toBe(Language.Russian); - expect(locale.trans("empty")).toBe(""); - expect(warn).not.toHaveBeenCalled(); - }); +test("applies external changes and own storage events without another write", async () => { + const locale = new DynamicLocale("chosen-language"); + const handler = jest.fn(); + const stop = locale.watch(handler); + notify("chosen-language", Language.French); + expect(locale.lang()).toBe(Language.French); + expect(handler).toHaveBeenLastCalledWith(Language.French); + expect(driver.set).not.toHaveBeenCalled(); + await locale.change(Language.English); + expect(handler).toHaveBeenLastCalledWith(Language.English); + expect(driver.set).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledTimes(2); + stop(); + stop(); + notify("chosen-language", Language.French); + expect(locale.lang()).toBe(Language.English); + expect(listeners.size).toBe(0); +}); - test("does not fall back to native locale when dynamic value is empty", () => { - const locale = new TestDynamicLocale(false).setLocale(Language.French, { - empty: "", - }); +test("retains the single-listener contract and permits subscribing again after unwatch", () => { + const locale = new DynamicLocale(); + locale.watch(); + expect(() => locale.watch()).toThrow("Already subscribed"); + locale.unwatch(); + locale.watch(); + expect(listeners.size).toBe(1); + locale.unwatch(); + expect(listeners.size).toBe(0); +}); + +test("reports invalid external changes without changing state or notifying the handler", () => { + const locale = new DynamicLocale(); + const error = jest.spyOn(console, "error").mockImplementation(); + const handler = jest.fn(); + const stop = locale.watch(handler); + notify("lang", Language.German); + notify("lang", undefined); + expect(error).toHaveBeenCalledWith("Error while changing language:", expect.any(Error)); + expect(handler).not.toHaveBeenCalled(); + expect(locale.lang()).toBe(Language.English); + expect(driver.set).not.toHaveBeenCalled(); + stop(); +}); - expect(locale.trans("empty")).toBe(""); - }); +test("supports memory-only changes without creating storage", async () => { + const locale = new DynamicLocale(false); + const changed = locale.change(Language.French); + expect(locale.lang()).toBe(Language.French); + await expect(changed).resolves.toBe(Language.French); + expect(Storage.Local).not.toHaveBeenCalled(); + expect(driver.set).not.toHaveBeenCalled(); + await expect(locale.sync()).rejects.toThrow("Language is not saving in storage"); + expect(() => locale.watch()).toThrow("Language is not saved in storage"); + expect(() => locale.unwatch()).not.toThrow(); }); diff --git a/src/locale/providers/DynamicLocale.ts b/src/locale/providers/DynamicLocale.ts index ee2b3092..1308fe91 100644 --- a/src/locale/providers/DynamicLocale.ts +++ b/src/locale/providers/DynamicLocale.ts @@ -1,28 +1,36 @@ -import {flattenLocaleMessages, getLocaleFilename} from "../utils"; - +import {getI18nMessage} from "@addon-core/browser"; import {Storage, type StorageProvider} from "@addon-core/storage"; -import NativeLocale, {LocaleNativeStructure} from "./NativeLocale"; -import CustomLocale, {CustomLocaleData} from "./CustomLocale"; +import catalogue, {keys, languages} from "#adnbn/locale"; + +import AbstractLocale from "./AbstractLocale"; +import type {LocaleNativeStructure} from "./NativeLocale"; -import {Language, LanguageCodes, LocaleDynamicProvider, LocaleMessages} from "@typing/locale"; +import {convertLocaleKey, resolveLanguage} from "../utils"; +import {Language, LocaleCustomKeyForLanguage, type LocaleDynamicProvider} from "@typing/locale"; -export default class - extends NativeLocale +export default class DynamicLocale + extends AbstractLocale implements LocaleDynamicProvider { - protected cache = new Map(); - - protected locale?: CustomLocale; + private language!: Language; + private messages!: Record; protected storage?: StorageProvider>; protected storageKey?: string; - protected unsubscribe?: () => void; constructor(storage: string | false = "lang") { super(); + const language = resolveLanguage(getI18nMessage(LocaleCustomKeyForLanguage)); + + if (!language) { + throw new Error("[DynamicLocale] Language is not defined. Failed to determine a supported locale."); + } + + this.select(language); + if (storage) { this.storageKey = storage; this.storage = Storage.Local(); @@ -30,13 +38,7 @@ export default class } public async change(lang: Language): Promise { - if (lang === this.lang()) { - return lang; - } - - const messages = await this.fetch(lang); - - (this.locale ??= new CustomLocale()).setLang(lang).setData(messages); + this.select(lang); if (this.storage && this.storageKey) { await this.storage.set(this.storageKey, lang); @@ -56,13 +58,13 @@ export default class return this.lang(); } - if (!LanguageCodes.has(lang)) { + if (!Object.hasOwn(catalogue, lang)) { console.warn(`Incorrect language code in storage - "${lang}"`); return this.lang(); } - return this.change(lang); + return this.select(lang); } public watch(handler?: (lang: Language) => void): () => void { @@ -77,9 +79,12 @@ export default class this.unsubscribe = this.storage.watch({ [this.storageKey]: newValue => { if (newValue) { - this.change(newValue) - .then(() => handler && handler(newValue)) - .catch(err => console.error("Error while changing language:", err)); + try { + this.select(newValue); + handler?.(newValue); + } catch (error) { + console.error("Error while changing language:", error); + } } }, }); @@ -93,28 +98,31 @@ export default class } public lang(): Language { - return this.locale?.lang() || super.lang(); + return this.language; } - protected value(key: Extract): string | undefined { - return this.locale?.get(key) ?? super.value(key); + public keys(): Set { + return new Set(keys) as Set; } - protected async fetch(lang: Language): Promise { - let messages = this.cache.get(lang); - - if (!messages) { - const response: LocaleMessages = await (await fetch(getLocaleFilename(lang))).json(); + public languages(): Set { + return new Set(languages); + } - if (!response || typeof response !== "object") { - throw new Error(`Invalid or empty locale data for "${lang}"`); - } + protected value(key: Extract): string | undefined { + const name = convertLocaleKey(key); - messages = flattenLocaleMessages(response); + return Object.hasOwn(this.messages, name) ? this.messages[name] : undefined; + } - this.cache.set(lang, messages); + private select(lang: Language): Language { + if (!Object.hasOwn(catalogue, lang)) { + throw new Error(`[DynamicLocale] Language "${lang}" is not available in the catalogue.`); } - return messages; + this.language = lang; + this.messages = catalogue[lang]!; + + return lang; } } diff --git a/src/locale/providers/NativeLocale.test.ts b/src/locale/providers/NativeLocale.test.ts index b3dd6dea..64ee6851 100644 --- a/src/locale/providers/NativeLocale.test.ts +++ b/src/locale/providers/NativeLocale.test.ts @@ -1,6 +1,7 @@ jest.mock("@addon-core/browser", () => ({ getI18nMessage: jest.fn(), })); +jest.mock("#adnbn/locale", () => ({keys: ["demo.empty", "demo.title"], languages: ["en", "fr", "ru"]})); import {getI18nMessage} from "@addon-core/browser"; import NativeLocale from "./NativeLocale"; @@ -11,16 +12,11 @@ interface Structure { } 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" : "")); @@ -30,11 +26,6 @@ describe("NativeLocale", () => { }); afterEach(() => { - if (keysDescriptor) { - Object.defineProperty(globalThis, "__ADNBN_LOCALE_KEYS__", keysDescriptor); - } else { - Reflect.deleteProperty(globalThis, "__ADNBN_LOCALE_KEYS__"); - } jest.restoreAllMocks(); }); @@ -53,11 +44,12 @@ describe("NativeLocale", () => { 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__"); + const keys = jest.spyOn(locale, "keys"); expect(locale.trans("demo.title")).toBe(value); expect(warn).not.toHaveBeenCalled(); expect(error).not.toHaveBeenCalled(); + expect(keys).not.toHaveBeenCalled(); }); test("preserves nonempty messages outside the default build contract", () => { @@ -74,10 +66,29 @@ describe("NativeLocale", () => { 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__"); + test("keeps missing-key diagnostics when build keys are empty", () => { + jest.spyOn(locale, "keys").mockReturnValue(new Set()); expect(locale.trans("demo.empty")).toBe("demo.empty"); expect(warn).toHaveBeenCalledWith('Locale key "demo.empty" not found in "ru" language.'); }); + + test("returns independent sets of public dot keys from the locale module", () => { + expect([...locale.keys()]).toEqual(["demo.empty", "demo.title"]); + locale.keys().clear(); + expect([...locale.keys()]).toEqual(["demo.empty", "demo.title"]); + }); + + test("reads available languages from the module independently of the selected native language", () => { + expect(locale.lang()).toBe("ru"); + expect([...locale.languages()]).toEqual(["en", "fr", "ru"]); + expect([...locale.languageNames()]).toEqual([ + ["en", "English"], + ["fr", "Français"], + ["ru", "Русский"], + ]); + locale.languages().clear(); + expect([...locale.languages()]).toEqual(["en", "fr", "ru"]); + expect(error).not.toHaveBeenCalled(); + }); }); diff --git a/src/locale/providers/NativeLocale.ts b/src/locale/providers/NativeLocale.ts index 67be2b5d..b960ee76 100644 --- a/src/locale/providers/NativeLocale.ts +++ b/src/locale/providers/NativeLocale.ts @@ -1,4 +1,5 @@ import {getI18nMessage} from "@addon-core/browser"; +import {keys, languages} from "#adnbn/locale"; import AbstractLocale from "./AbstractLocale"; @@ -52,25 +53,11 @@ export default class NativeLocale exte } public keys(): Set { - try { - // @ts-expect-error: __ADNBN_LOCALE_KEYS__ is a virtual variable generated by the bundler `src/cli/plugins/locale/index.ts` - return new Set(__ADNBN_LOCALE_KEYS__) as Set; - } catch (e) { - console.error("[NativeLocale] Failed to access locale keys. Ensure the bundler plugin is active.", e); - - return new Set(); - } + return new Set(keys) as Set; } public languages(): Set { - try { - // @ts-expect-error: __ADNBN_DEFINED_LOCALES__ is a virtual variable generated by the bundler `src/cli/plugins/locale/index.ts` - return new Set(__ADNBN_DEFINED_LOCALES__); - } catch (e) { - console.error("[NativeLocale] Failed to access defined locales. Check virtual variables configuration.", e); - - return new Set(); - } + return new Set(languages); } protected value(key: Extract): string | undefined { diff --git a/src/locale/providers/tests/fixtures/dynamic/catalogue.json b/src/locale/providers/tests/fixtures/dynamic/catalogue.json new file mode 100644 index 00000000..17733dfa --- /dev/null +++ b/src/locale/providers/tests/fixtures/dynamic/catalogue.json @@ -0,0 +1,29 @@ +{ + "en": { + "locale": "en", + "greeting": "Hello {{ name }}", + "items": "{{count}} item|{{count}} items", + "app_title": "Catalogue", + "fallback": "Shared default message", + "empty": "", + "__proto__": "Ordinary message" + }, + "fr": { + "locale": "fr", + "greeting": "Bonjour {{ name }}", + "items": "{{count}} article|{{count}} articles", + "app_title": "Catalogue français", + "fallback": "Shared default message", + "empty": "", + "__proto__": "Message ordinaire" + }, + "en_GB": { + "locale": "en_GB", + "greeting": "Hello {{ name }}", + "items": "{{count}} item|{{count}} items", + "app_title": "British catalogue", + "fallback": "Shared default message", + "empty": "", + "__proto__": "Ordinary message" + } +} diff --git a/src/types/locale.ts b/src/types/locale.ts index e703058a..1a9c888c 100644 --- a/src/types/locale.ts +++ b/src/types/locale.ts @@ -63,6 +63,10 @@ export enum LocaleDir { export const RtlLanguages: ReadonlySet = new Set([Language.Arabic, Language.Persian, Language.Hebrew]); +export const LocaleModuleName = "virtual/locale"; + +export const LocaleModuleLayer = "adnbn:locale"; + export const LocaleNestedKeysSeparator = "."; export const LocaleKeysSeparator = "_"; @@ -172,6 +176,9 @@ export interface LocaleBuilder { export type LocaleBuilders = Map; +/** Completed messages keyed by language and browser-compatible message key. */ +export type LocaleCatalogue = Partial>>; + export interface LocaleValidator { isValid(locale: LocaleBuilder): boolean; diff --git a/tests/integration/browser/locale/chunks.firefox.integration.test.ts b/tests/integration/browser/locale/chunks.firefox.integration.test.ts index 6a364961..9b255b85 100644 --- a/tests/integration/browser/locale/chunks.firefox.integration.test.ts +++ b/tests/integration/browser/locale/chunks.firefox.integration.test.ts @@ -14,7 +14,7 @@ import {ChangeLanguage, expectPanels, LocaleFixtureDirectory, ReadPanels} from " jest.setTimeout(90_000); -test("Firefox MV3 loads the same locale chunk in ISOLATED and MAIN", async () => { +test("Firefox MV3 loads locale in ISOLATED and shares the MAIN catalogue through common content", async () => { const binary = findFirefoxBinary(); if (!binary) throw new Error("Install Firefox or set ADNBN_FIREFOX_BIN"); const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, LocaleFixtureDirectory); diff --git a/tests/integration/browser/locale/chunks.integration.test.ts b/tests/integration/browser/locale/chunks.integration.test.ts index 16a0b2a3..ed336074 100644 --- a/tests/integration/browser/locale/chunks.integration.test.ts +++ b/tests/integration/browser/locale/chunks.integration.test.ts @@ -13,7 +13,7 @@ import {ChangeLanguage, expectPanels, LocaleFixtureDirectory, ReadPanels} from " jest.setTimeout(90_000); -test("Chrome loads one locale chunk in views, ISOLATED and MAIN while background stays standalone", async () => { +test("Chrome shares locale with views and ISOLATED, and loads the MAIN catalogue through common content", async () => { const binary = findChromeBinary(ADNBN_TEST_ROOT); if (!binary) throw new Error("Install Chrome for Testing or set ADNBN_CHROME_BIN"); const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, LocaleFixtureDirectory); diff --git a/tests/integration/browser/locale/dynamic.firefox.integration.test.ts b/tests/integration/browser/locale/dynamic.firefox.integration.test.ts new file mode 100644 index 00000000..20b6c702 --- /dev/null +++ b/tests/integration/browser/locale/dynamic.firefox.integration.test.ts @@ -0,0 +1,83 @@ +/** @jest-environment node */ + +import {spawn, type ChildProcess} from "child_process"; +import {mkdtemp, rm} from "fs/promises"; +import os from "os"; +import path from "path"; +import {createIntegrationFixture} from "../../utils/fixture"; +import BidiClient from "../utils/BidiClient"; +import {findFirefoxBinary} from "../utils/firefox"; +import {getFreePort, stop, waitFor} from "../utils/browser"; +import {startIntegrationSite, type IntegrationSite} from "../utils/site"; + +jest.setTimeout(90_000); + +test("Firefox uses DynamicLocale in content scripts, persists selection and restores it on another page without requests", async () => { + const binary = findFirefoxBinary(); + if (!binary) throw new Error("Install Firefox or set ADNBN_FIREFOX_BIN"); + const fixture = await createIntegrationFixture( + ADNBN_TEST_ROOT, + path.join(ADNBN_TEST_ROOT, "tests/integration/build/locale/dynamic-fixture") + ); + const profile = await mkdtemp(path.join(os.tmpdir(), "adnbn-dynamic-locale-firefox-")); + let browser: ChildProcess | undefined; + let client: BidiClient | undefined; + let site: IntegrationSite | undefined; + try { + const directory = await fixture.build({browser: "firefox"}); + const port = await getFreePort(); + browser = spawn( + binary, + ["--headless", "--no-remote", "--profile", profile, "--remote-debugging-port", String(port), "about:blank"], + {stdio: "ignore"} + ); + client = await waitFor(() => BidiClient.connect(`ws://127.0.0.1:${port}/session`)); + await client.send("session.new", {capabilities: {alwaysMatch: {}}}); + await client.send("session.subscribe", {events: ["log.entryAdded", "network.beforeRequestSent"]}); + await client.send("webExtension.install", {extensionData: {path: directory, type: "path"}}); + site = await startIntegrationSite(path.join(fixture.directory, "site")); + const expectLanguage = async (context: string, language: string) => + waitFor(async () => { + expect( + await client!.evaluate(context, `document.querySelector('[data-locale-context]')?.dataset.language`) + ).toBe(language); + return true; + }); + const {context} = await client.send("browsingContext.create", {type: "tab"}); + await client.send("browsingContext.navigate", {context, url: site.origin, wait: "complete"}); + await expectLanguage(context, "en"); + const before = client.requests.length; + const greeting = await client.evaluate( + context, + `(() => { + const panel = document.querySelector('[data-locale-context]'); + const select = panel.querySelector('select'); + select.value = 'fr'; + select.dispatchEvent(new Event('change', {bubbles: true})); + return panel.querySelector('p').textContent; + })()` + ); + expect(greeting).toBe("Bonjour depuis DynamicLocale !"); + const second = await client.send("browsingContext.create", {type: "tab"}); + await client.send("browsingContext.navigate", {context: second.context, url: site.origin, wait: "complete"}); + await expectLanguage(second.context, "fr"); + await client.evaluate( + second.context, + `(() => { + const select = document.querySelector('[data-locale-context] select'); + select.value = 'en'; + select.dispatchEvent(new Event('change', {bubbles: true})); + })()` + ); + await expectLanguage(context, "en"); + expect(client.requests.slice(before).filter(url => url.startsWith("moz-extension://"))).toEqual([]); + expect(client.requests.filter(url => /_locales\/|messages\.json/.test(url))).toEqual([]); + expect(client.runtimeErrors).toEqual([]); + } finally { + await client?.close(); + if (browser) await stop(browser); + await site?.close(); + await fixture.dispose(); + await rm(profile, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } +}); diff --git a/tests/integration/browser/locale/dynamic.integration.test.ts b/tests/integration/browser/locale/dynamic.integration.test.ts new file mode 100644 index 00000000..1e85ea9f --- /dev/null +++ b/tests/integration/browser/locale/dynamic.integration.test.ts @@ -0,0 +1,153 @@ +/** @jest-environment node */ + +import {spawn, type ChildProcess} from "child_process"; +import {mkdtemp, readFile, rm} from "fs/promises"; +import os from "os"; +import path from "path"; +import {createIntegrationFixture} from "../../utils/fixture"; +import {browserVersion, CdpClient, findChromeBinary} from "../utils/chrome"; +import {getFreePort, stop, waitFor} from "../utils/browser"; +import {startIntegrationSite, type IntegrationSite} from "../utils/site"; + +jest.setTimeout(90_000); + +test("Chrome switches DynamicLocale synchronously and synchronizes storage across views and content without loading translations", async () => { + const binary = findChromeBinary(ADNBN_TEST_ROOT); + if (!binary) throw new Error("Install Chrome for Testing or set ADNBN_CHROME_BIN"); + const fixture = await createIntegrationFixture( + ADNBN_TEST_ROOT, + path.join(ADNBN_TEST_ROOT, "tests/integration/build/locale/dynamic-fixture") + ); + const profile = await mkdtemp(path.join(os.tmpdir(), "adnbn-dynamic-locale-chrome-")); + let browser: ChildProcess | undefined; + let client: CdpClient | undefined; + let site: IntegrationSite | undefined; + try { + const directory = await fixture.build(); + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const port = await getFreePort(); + browser = spawn( + binary, + [ + "--headless=new", + "--no-sandbox", + "--no-first-run", + "--no-default-browser-check", + `--user-data-dir=${profile}`, + `--remote-debugging-port=${port}`, + "about:blank", + ], + {stdio: "ignore"} + ); + const {webSocketDebuggerUrl} = await waitFor(() => browserVersion(port)); + client = await CdpClient.connect(webSocketDebuggerUrl); + const extension = await client.send("Extensions.loadUnpacked", {path: directory}); + const origin = `chrome-extension://${extension.id}`; + const attach = async (targetId: string): Promise => { + const {sessionId} = await client!.send("Target.attachToTarget", {targetId, flatten: true}); + await client!.send("Runtime.enable", {}, sessionId); + await client!.send("Network.enable", {}, sessionId); + return sessionId; + }; + const evaluate = async (session: string, expression: string): Promise => { + const result = await client!.send( + "Runtime.evaluate", + {expression, returnByValue: true, awaitPromise: true}, + session + ); + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + const worker = await waitFor(async () => { + const {targetInfos} = await client!.send("Target.getTargets"); + return targetInfos.find( + (target: {url: string}) => target.url === `${origin}/${manifest.background.service_worker}` + ); + }); + const background = await attach(worker.targetId); + expect(await evaluate(background, 'dynamicLocale.trans("greeting")')).toBe("Hello from DynamicLocale!"); + site = await startIntegrationSite(path.join(fixture.directory, "site")); + const sessions: string[] = []; + const urls = [ + site.origin, + `${origin}/${manifest.action.default_popup}`, + `${origin}/${manifest.options_ui.page}`, + ]; + const expectLanguage = async (session: string, lang: string): Promise => { + await waitFor(async () => { + const panel = await evaluate( + session, + `(() => { const panel = document.querySelector('[data-locale-context]'); return panel && {lang: panel.dataset.language, message: panel.querySelector('p').textContent}; })()` + ); + expect(panel).toEqual({ + lang, + message: lang === "fr" ? "Bonjour depuis DynamicLocale !" : "Hello from DynamicLocale!", + }); + return true; + }); + }; + for (const url of urls) { + const {targetId} = await client.send("Target.createTarget", {url: "about:blank"}); + const session = await attach(targetId); + sessions.push(session); + await client.send("Page.navigate", {url}, session); + await expectLanguage(session, "en"); + } + const before = client.requests.length; + const state = await evaluate( + background, + `(async () => { + const saved = dynamicLocale.change('fr'); + const immediate = { + language: dynamicLocale.lang(), + greeting: dynamicLocale.trans('greeting'), + empty: dynamicLocale.trans('empty'), + fallback: dynamicLocale.trans('fallback'), + welcome: dynamicLocale.trans('welcome', {name: 'Ada'}), + plural: dynamicLocale.choice('items', 0, {count: 0}), + }; + await saved; + return {immediate, stored: (await chrome.storage.local.get('lang')).lang}; + })()` + ); + expect(state).toEqual({ + immediate: { + language: "fr", + greeting: "Bonjour depuis DynamicLocale !", + empty: "", + fallback: "Completed from English", + welcome: "Bienvenue Ada", + plural: "0 article", + }, + stored: "fr", + }); + for (const session of sessions) await expectLanguage(session, "fr"); + expect(client.requests.slice(before).filter(url => url.startsWith(origin))).toEqual([]); + await client.send("Page.navigate", {url: urls[1]}, sessions[1]); + await expectLanguage(sessions[1], "fr"); + const afterReload = client.requests.length; + expect( + await evaluate( + sessions[1], + `(() => { + const panel = document.querySelector('[data-locale-context]'); + const select = panel.querySelector('select'); + select.value = 'en'; + select.dispatchEvent(new Event('change', {bubbles: true})); + return panel.querySelector('p').textContent; + })()` + ) + ).toBe("Hello from DynamicLocale!"); + for (const session of sessions) await expectLanguage(session, "en"); + expect(await evaluate(background, "dynamicLocale.sync()")).toBe("en"); + expect(client.requests.slice(afterReload).filter(url => url.startsWith(origin))).toEqual([]); + expect(client.requests.filter(url => /_locales\/|messages\.json/.test(url))).toEqual([]); + expect(client.runtimeErrors).toEqual([]); + } finally { + await client?.close(); + if (browser) await stop(browser); + await site?.close(); + await fixture.dispose(); + await rm(profile, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } +}); diff --git a/tests/integration/browser/utils/BidiClient.ts b/tests/integration/browser/utils/BidiClient.ts index 3b6d2786..e492d06d 100644 --- a/tests/integration/browser/utils/BidiClient.ts +++ b/tests/integration/browser/utils/BidiClient.ts @@ -2,7 +2,7 @@ interface BidiMessage { id?: number; type: string; method?: string; - params?: {level?: string; text?: string}; + params?: {level?: string; text?: string; request?: {url: string}}; result?: Record; error?: string; message?: string; @@ -20,6 +20,7 @@ export default class BidiClient { private readonly pending = new Map(); public readonly runtimeErrors: string[] = []; + public readonly requests: string[] = []; private constructor(private readonly socket: WebSocket) { socket.addEventListener("message", event => this.receive(JSON.parse(String(event.data)))); @@ -109,6 +110,10 @@ export default class BidiClient { } private receive(message: BidiMessage): void { + if (message.method === "network.beforeRequestSent" && message.params?.request) { + this.requests.push(message.params.request.url); + } + if (message.method === "log.entryAdded" && message.params?.level === "error") { this.runtimeErrors.push(message.params.text ?? "Firefox runtime error"); } diff --git a/tests/integration/browser/utils/chrome.ts b/tests/integration/browser/utils/chrome.ts index 64155bb6..4df8663d 100644 --- a/tests/integration/browser/utils/chrome.ts +++ b/tests/integration/browser/utils/chrome.ts @@ -42,6 +42,7 @@ export class CdpClient { private readonly pending = new Map(); public readonly runtimeErrors: string[] = []; + public readonly requests: string[] = []; private constructor(private readonly socket: WebSocket) { socket.addEventListener("message", event => this.receive(JSON.parse(String(event.data)))); @@ -125,6 +126,11 @@ export class CdpClient { } private receive(message: CdpMessage): void { + if (message.method === "Network.requestWillBeSent") { + const request = message.params?.request as {url: string}; + this.requests.push(request.url); + } + if (message.method === "Runtime.exceptionThrown") { const details = message.params?.exceptionDetails as | {text?: string; exception?: {description?: string}} diff --git a/tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts b/tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts index 85ef57c9..c4966b12 100644 --- a/tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts +++ b/tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts @@ -1,4 +1,6 @@ declare module "virtual/locale" { const catalogue: Record>; + export const keys: readonly string[]; + export const languages: readonly import("adnbn/locale").Language[]; export default catalogue; } diff --git a/tests/integration/build/locale/chunks.integration.test.ts b/tests/integration/build/locale/chunks.integration.test.ts index 43064098..50bb1146 100644 --- a/tests/integration/build/locale/chunks.integration.test.ts +++ b/tests/integration/build/locale/chunks.integration.test.ts @@ -32,11 +32,15 @@ test.each([ {browser: "chrome", entries: ["main.content"]}, {browser: "chrome", entries: ["popup", "isolated.content"]}, {browser: "chrome", entries: ["isolated.content", "main.content"]}, + {browser: "chrome", entries: ["main.content", "main-secondary.content"]}, + {browser: "chrome", entries: ["main.content", "main-secondary.content"], bytes: 100_000}, {browser: "chrome", entries: Entries}, + {browser: "chrome", entries: Entries, bytes: 100_000}, {browser: "chrome", entries: ["popup"], bytes: 99_999}, {browser: "chrome", entries: ["popup"], bytes: 100_000}, {browser: "chrome", entries: ["isolated.content"], bytes: 100_000}, {browser: "chrome", entries: ["main.content"], bytes: 100_000}, + {browser: "chrome", entries: ["isolated.content", "main.content"], bytes: 100_000}, {browser: "chrome", entries: [], bytes: 100_000}, {browser: "chrome", entries: Entries, enabled: false}, {browser: "firefox", entries: Entries}, @@ -47,10 +51,11 @@ test.each([ if (!entries.includes(entry)) await rm(path.join(fixture.directory, "src", `${entry}.ts`)); } if (bytes !== undefined) { - const empty = createLocaleModule({en: {greeting: Greeting, padding: "", locale: "en"}}); + const keys = new Set(["greeting", "padding", "locale"]); + const empty = createLocaleModule({en: {greeting: Greeting, padding: "", locale: "en"}}, keys); const padding = "x".repeat(bytes - Buffer.byteLength(empty)); const messages = {greeting: Greeting, padding}; - expect(Buffer.byteLength(createLocaleModule({en: {...messages, locale: "en"}}))).toBe(bytes); + expect(Buffer.byteLength(createLocaleModule({en: {...messages, locale: "en"}}, keys))).toBe(bytes); await writeFile(path.join(fixture.directory, "src/locales/en.json"), JSON.stringify(messages)); await rm(path.join(fixture.directory, "src/locales/fr.json")); } @@ -97,39 +102,67 @@ test.each([ ); } - const consumers: string[][] = []; + const consumers: Array<{files: string[]; main: boolean}> = []; for (const filename of [manifest.action?.default_popup, manifest.options_ui?.page]) { if (!filename) continue; const html = await readFile(path.join(directory, filename), "utf8"); - consumers.push( - [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map(([, file]) => + consumers.push({ + files: [...html.matchAll(/]*\bsrc="([^"]+)"/g)].map(([, file]) => path.posix.normalize(path.posix.join(path.posix.dirname(filename), file)) - ) - ); + ), + main: false, + }); } - consumers.push(...(manifest.content_scripts ?? []).map((script: {js: string[]}) => script.js)); + consumers.push( + ...(manifest.content_scripts ?? []).map((script: {js: string[]; world: string}) => ({ + files: script.js, + main: script.world === "MAIN", + })) + ); expect(consumers).toHaveLength(entries.length); - const split = enabled && entries.length > 0 && (entries.length >= 2 || (bytes ?? 0) >= 100_000); + const mainConsumers = consumers.filter(consumer => consumer.main); + const localeConsumers = consumers.filter(consumer => !consumer.main); + const split = enabled && localeConsumers.length > 0 && (localeConsumers.length >= 2 || (bytes ?? 0) >= 100_000); + // The fixture's shared content runtime exceeds the regular 20,000-byte threshold. + const splitMain = enabled && mainConsumers.length >= 2; const sharedFiles = new Set(); - for (const files of consumers) { + const mainFiles = new Set(); + for (const {files, main} of consumers) { expect(files).not.toContain(backgroundFiles[0]); for (const file of files) expect(sources.has(file)).toBe(true); const found = files.filter(file => catalogueFiles.includes(file)); expect(found).toHaveLength(1); - sharedFiles.add(found[0]); - if (enabled) { + if (main) { + mainFiles.add(found[0]); + expect(found[0]).not.toMatch(/^js\/locale[.-]/); + if (splitMain) expect(found[0]).toMatch(/^js\/common-main\.content\.[a-f0-9]{8}\.js$/); + } else { + sharedFiles.add(found[0]); + } + if (enabled && !main) { expect(sources.get(found[0])!.includes("localeContext")).toBe(!split); if (split) expect(found[0]).toMatch(/^js\/locale\.[a-f0-9]{8}\.js$/); } } + const otherFiles = new Set(localeConsumers.flatMap(consumer => consumer.files)); + for (const {files} of mainConsumers) { + expect(files.filter(file => otherFiles.has(file))).toEqual([]); + } + expect([...sources.keys()].some(file => /locale-main/.test(file))).toBe(false); if (enabled) { - expect(catalogueFiles).toHaveLength(1 + (split ? 1 : entries.length)); + expect(catalogueFiles).toHaveLength( + 1 + (split ? 1 : localeConsumers.length) + (splitMain ? 1 : mainConsumers.length) + ); + expect(mainFiles.size).toBe(splitMain ? 1 : mainConsumers.length); + expect(sharedFiles.size).toBe(split ? 1 : localeConsumers.length); if (split) { - expect(sharedFiles.size).toBe(1); const source = sources.get([...sharedFiles][0])!; expect(source.split(Greeting)).toHaveLength(2); } + if (splitMain) { + expect(sources.get([...mainFiles][0])!.split(Greeting)).toHaveLength(2); + } } } finally { await fixture.dispose(); diff --git a/tests/integration/build/locale/dynamic-fixture/adnbn.config.ts b/tests/integration/build/locale/dynamic-fixture/adnbn.config.ts new file mode 100644 index 00000000..1e45e727 --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/adnbn.config.ts @@ -0,0 +1,11 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Dynamic locale integration", + version: "1.0.0", + manifest: {permissions: ["storage"]}, + commonChunks: true, + concatContentScripts: false, + jsFilename: "[name].js", + specific: {gecko: {id: "dynamic-locale@adnbn.test"}}, +}); diff --git a/tests/integration/build/locale/dynamic-fixture/package.json b/tests/integration/build/locale/dynamic-fixture/package.json new file mode 100644 index 00000000..7d731f2d --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-locale-chunks", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "*" + } +} diff --git a/tests/integration/build/locale/dynamic-fixture/site/top.html b/tests/integration/build/locale/dynamic-fixture/site/top.html new file mode 100644 index 00000000..318e69a2 --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/site/top.html @@ -0,0 +1,8 @@ + + + + + Locale chunks + + + diff --git a/tests/integration/build/locale/dynamic-fixture/src/background.ts b/tests/integration/build/locale/dynamic-fixture/src/background.ts new file mode 100644 index 00000000..c7a748cd --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/src/background.ts @@ -0,0 +1,7 @@ +import {DynamicLocale} from "adnbn/locale"; + +const locale = new DynamicLocale(); +(globalThis as typeof globalThis & {dynamicLocale: DynamicLocale}).dynamicLocale = locale; +export default async () => { + await locale.sync(); +}; diff --git a/tests/integration/build/locale/dynamic-fixture/src/contract.ts b/tests/integration/build/locale/dynamic-fixture/src/contract.ts new file mode 100644 index 00000000..21ba3e61 --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/src/contract.ts @@ -0,0 +1,22 @@ +import {DynamicLocale, Language} from "adnbn/locale"; + +export const checkContract = (locale: DynamicLocale): void => { + const changed: Promise = locale.change(Language.French); + const synced: Promise = locale.sync(); + const stop: () => void = locale.watch(language => { + const selected: Language = language; + }); + locale.unwatch(); + const translated: string = locale.trans("welcome", {name: "Ada"}); + const plural: string = locale.choice("items", 2, {count: 2}); + const keys: ReadonlySet<"locale" | "greeting" | "app.title" | "welcome" | "items" | "fallback" | "empty"> = + locale.keys(); + // @ts-expect-error substitutions remain required + locale.trans("welcome"); + // @ts-expect-error plural keys are not ordinary messages + locale.trans("items", {count: 2}); + // @ts-expect-error ordinary messages cannot use choice + locale.choice("greeting", 2); + // @ts-expect-error unknown substitution names are rejected + locale.trans("welcome", {name: "Ada", extra: "value"}); +}; diff --git a/tests/integration/build/locale/dynamic-fixture/src/locale.content.ts b/tests/integration/build/locale/dynamic-fixture/src/locale.content.ts new file mode 100644 index 00000000..835dd6d4 --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/src/locale.content.ts @@ -0,0 +1,8 @@ +import {defineContentScript} from "adnbn"; +import {createPanel} from "./panel"; + +export default defineContentScript({ + matches: ["http://127.0.0.1/*", "https://example.com/*"], + world: "ISOLATED", + render: () => createPanel("isolated"), +}); diff --git a/tests/integration/build/locale/dynamic-fixture/src/locales/en.json b/tests/integration/build/locale/dynamic-fixture/src/locales/en.json new file mode 100644 index 00000000..b9b494d3 --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/src/locales/en.json @@ -0,0 +1,10 @@ +{ + "greeting": "Hello from DynamicLocale!", + "app": { + "title": "Bundled translations" + }, + "welcome": "Welcome {{ name }}", + "items": "{{count}} item|{{count}} items", + "fallback": "Completed from English", + "empty": "" +} diff --git a/tests/integration/build/locale/dynamic-fixture/src/locales/fr.json b/tests/integration/build/locale/dynamic-fixture/src/locales/fr.json new file mode 100644 index 00000000..0eb6d937 --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/src/locales/fr.json @@ -0,0 +1,9 @@ +{ + "greeting": "Bonjour depuis DynamicLocale !", + "app": { + "title": "Traductions intégrées" + }, + "welcome": "Bienvenue {{ name }}", + "items": "{{count}} article|{{count}} articles", + "empty": "" +} diff --git a/tests/integration/build/locale/dynamic-fixture/src/options.ts b/tests/integration/build/locale/dynamic-fixture/src/options.ts new file mode 100644 index 00000000..14f8aaf3 --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/src/options.ts @@ -0,0 +1,4 @@ +import {defineOptions} from "adnbn"; +import {createPanel} from "./panel"; + +export default defineOptions({render: () => createPanel("options")}); diff --git a/tests/integration/build/locale/dynamic-fixture/src/panel.ts b/tests/integration/build/locale/dynamic-fixture/src/panel.ts new file mode 100644 index 00000000..4bc1138a --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/src/panel.ts @@ -0,0 +1,26 @@ +import {DynamicLocale, type Language} from "adnbn/locale"; + +export const createPanel = (context: string): HTMLElement => { + const locale = new DynamicLocale(); + const panel = document.createElement("div"); + panel.dataset.localeContext = context; + const select = document.createElement("select"); + for (const [language, name] of locale.languageNames()) select.add(new Option(name, language)); + const message = document.createElement("p"); + const update = () => { + select.value = locale.lang(); + message.textContent = locale.trans("greeting"); + panel.dataset.language = locale.lang(); + }; + select.addEventListener("change", () => { + const saved = locale.change(select.value as Language); + update(); + void saved.catch(console.error); + }); + update(); + void locale.sync().then(update).catch(console.error); + const stop = locale.watch(update); + window.addEventListener("pagehide", stop, {once: true}); + panel.append(select, message); + return panel; +}; diff --git a/tests/integration/build/locale/dynamic-fixture/src/popup.ts b/tests/integration/build/locale/dynamic-fixture/src/popup.ts new file mode 100644 index 00000000..fd4beabb --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/src/popup.ts @@ -0,0 +1,4 @@ +import {definePopup} from "adnbn"; +import {createPanel} from "./panel"; + +export default definePopup({render: () => createPanel("popup")}); diff --git a/tests/integration/build/locale/dynamic-fixture/tsconfig.json b/tests/integration/build/locale/dynamic-fixture/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/build/locale/dynamic-fixture/tsconfig.locale.json b/tests/integration/build/locale/dynamic-fixture/tsconfig.locale.json new file mode 100644 index 00000000..5b6cc844 --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/tsconfig.locale.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ESNext", + "strict": true, + "skipLibCheck": false, + "noEmit": true, + "types": ["chrome", "node"] + }, + "files": [ + ".adnbn/locale.d.ts", + "src/contract.ts" + ] +} diff --git a/tests/integration/build/locale/dynamic.integration.test.ts b/tests/integration/build/locale/dynamic.integration.test.ts new file mode 100644 index 00000000..c0a3f135 --- /dev/null +++ b/tests/integration/build/locale/dynamic.integration.test.ts @@ -0,0 +1,103 @@ +/** @jest-environment node */ + +import {copyFile, readFile, readdir, rm} from "fs/promises"; +import path from "path"; +import vm from "vm"; +import {createIntegrationFixture} from "../../utils/fixture"; +import {run} from "../../utils/process"; + +jest.setTimeout(90_000); + +const fixtureDirectory = path.join(__dirname, "dynamic-fixture"); + +test("the published package imports without bundler aliases or extension APIs", async () => { + await run(process.execPath, [path.join(__dirname, "states/package-import.mjs")], ADNBN_TEST_ROOT); +}); + +test.each(["chrome", "firefox"])( + "%s bundles DynamicLocale through the public package and preserves declarations", + async browser => { + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, fixtureDirectory); + try { + const directory = await fixture.build({browser}); + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const background = manifest.background.service_worker ?? manifest.background.scripts[0]; + const catalogue = await readFile(path.join(directory, "js/locale.js"), "utf8"); + expect(catalogue).toContain("Bonjour depuis DynamicLocale"); + expect(catalogue).toContain("Completed from English"); + expect(await readFile(path.join(directory, background), "utf8")).toContain("Bonjour depuis DynamicLocale"); + expect(manifest.content_scripts[0].js).toContain("js/locale.js"); + expect(manifest.web_accessible_resources).toBeUndefined(); + for (const filename of [manifest.action.default_popup, manifest.options_ui.page]) { + expect(await readFile(path.join(directory, filename), "utf8")).toContain("js/locale.js"); + } + await run( + process.execPath, + [path.join(ADNBN_TEST_ROOT, "node_modules/typescript/bin/tsc"), "--noEmit", "-p", "tsconfig.json"], + fixture.directory + ); + await run( + process.execPath, + [path.join(ADNBN_TEST_ROOT, "node_modules/typescript/bin/tsc"), "-p", "tsconfig.locale.json"], + fixture.directory + ); + const declaration = await readFile( + path.join(ADNBN_TEST_ROOT, "dist/locale/providers/DynamicLocale.d.ts"), + "utf8" + ); + expect(declaration).not.toContain("#adnbn/locale"); + expect(declaration).not.toContain("virtual/locale"); + expect(declaration).toContain("extends AbstractLocale"); + } finally { + await fixture.dispose(); + } + } +); + +test("native-only consumers do not include the catalogue despite importing the public barrel", async () => { + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, fixtureDirectory); + try { + for (const file of ["popup.ts", "options.ts", "locale.content.ts"]) + await rm(path.join(fixture.directory, "src", file)); + await copyFile( + path.join(__dirname, "states/native-background.ts"), + path.join(fixture.directory, "src/background.ts") + ); + const directory = await fixture.build(); + const files = (await readdir(path.join(directory, "js"))).filter(file => file.endsWith(".js")); + expect(files).toHaveLength(1); + expect(files[0]).toBe("background.js"); + const source = await readFile(path.join(directory, "js/background.js"), "utf8"); + expect(source).not.toContain("Hello from DynamicLocale!"); + expect(source).not.toContain("Bonjour depuis DynamicLocale"); + const sandbox = { + chrome: {i18n: {getMessage: (key: string) => (key === "locale" ? "en" : "")}}, + nativeLocale: undefined as + | { + keys(): Set; + languages(): Set; + languageNames(): Map; + trans(key: string): string; + } + | undefined, + }; + vm.runInNewContext(source, sandbox); + expect([...sandbox.nativeLocale!.keys()].sort()).toEqual([ + "app.title", + "empty", + "fallback", + "greeting", + "items", + "locale", + "welcome", + ]); + expect(sandbox.nativeLocale!.trans("empty")).toBe(""); + expect([...sandbox.nativeLocale!.languages()]).toEqual(["en", "fr"]); + expect([...sandbox.nativeLocale!.languageNames()]).toEqual([ + ["en", "English"], + ["fr", "Français"], + ]); + } finally { + await fixture.dispose(); + } +}); diff --git a/tests/integration/build/locale/fixture/src/background.ts b/tests/integration/build/locale/fixture/src/background.ts index 2a11f696..1d83709a 100644 --- a/tests/integration/build/locale/fixture/src/background.ts +++ b/tests/integration/build/locale/fixture/src/background.ts @@ -1,5 +1,7 @@ -import catalogue from "virtual/locale"; +import catalogue, {keys, languages} from "virtual/locale"; (globalThis as typeof globalThis & {readLocaleCatalogue: () => typeof catalogue}).readLocaleCatalogue = () => catalogue; +(globalThis as typeof globalThis & {readLocaleKeys: () => typeof keys}).readLocaleKeys = () => keys; +(globalThis as typeof globalThis & {readLocaleLanguages: () => typeof languages}).readLocaleLanguages = () => languages; export default () => {}; diff --git a/tests/integration/build/locale/fixture/src/catalogue.d.ts b/tests/integration/build/locale/fixture/src/catalogue.d.ts index 85ef57c9..c4966b12 100644 --- a/tests/integration/build/locale/fixture/src/catalogue.d.ts +++ b/tests/integration/build/locale/fixture/src/catalogue.d.ts @@ -1,4 +1,6 @@ declare module "virtual/locale" { const catalogue: Record>; + export const keys: readonly string[]; + export const languages: readonly import("adnbn/locale").Language[]; export default catalogue; } diff --git a/tests/integration/build/locale/fixture/src/locales/fr.json b/tests/integration/build/locale/fixture/src/locales/fr.json index 94997767..cdcd3b14 100644 --- a/tests/integration/build/locale/fixture/src/locales/fr.json +++ b/tests/integration/build/locale/fixture/src/locales/fr.json @@ -2,6 +2,7 @@ "app": { "title": "Catalogue de traductions" }, + "secondaryOnly": "Only in French", "items": [ "{{count}} article", "{{count}} articles" diff --git a/tests/integration/build/locale/locale.integration.test.ts b/tests/integration/build/locale/locale.integration.test.ts index a2c8859a..29a4f656 100644 --- a/tests/integration/build/locale/locale.integration.test.ts +++ b/tests/integration/build/locale/locale.integration.test.ts @@ -16,12 +16,18 @@ const inspect = async (directory: string) => { const files: string[] = manifest.background.service_worker ? [manifest.background.service_worker] : manifest.background.scripts; - const sandbox = {readLocaleCatalogue: undefined as (() => Catalogue) | undefined}; + const sandbox = { + readLocaleCatalogue: undefined as (() => Catalogue) | undefined, + readLocaleKeys: undefined as (() => readonly string[]) | undefined, + readLocaleLanguages: undefined as (() => readonly string[]) | undefined, + }; const context = vm.createContext(sandbox); for (const file of files) { vm.runInContext(await readFile(path.join(directory, file), "utf8"), context); } const catalogue = sandbox.readLocaleCatalogue!(); + const exportedLanguages = sandbox.readLocaleLanguages!(); + expect(exportedLanguages).toEqual(Object.keys(catalogue)); const languages = await readdir(path.join(directory, "_locales")); expect(Object.keys(catalogue).sort()).toEqual(languages.sort()); for (const lang of languages) { @@ -32,13 +38,23 @@ const inspect = async (directory: string) => { expect(catalogue[lang]).toEqual(flattened); expect(catalogue[lang].locale).toBe(lang); } - return catalogue; + return {catalogue, keys: sandbox.readLocaleKeys!(), languages: exportedLanguages}; }; test.each(["chrome", "firefox"])("%s build imports the catalogue and preserves the native JSON data", async browser => { const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, path.join(__dirname, "fixture")); try { - const catalogue = await inspect(await fixture.build({browser})); + const {catalogue, keys} = await inspect(await fixture.build({browser})); + expect([...keys].sort()).toEqual([ + "__proto__", + "app.greeting", + "app.title", + "empty", + "items", + "literal", + "locale", + ]); + expect(catalogue.fr.secondaryOnly).toBe("Only in French"); expect(catalogue.fr).toMatchObject({ app_title: browser === "chrome" ? "Catalogue pour Chrome" : "Catalogue de traductions", app_greeting: "Hello {{name}}", @@ -67,23 +83,30 @@ test("CLI watch refreshes the catalogue, JSON and declarations from the same loc watcher.stdout?.on("data", chunk => (output += chunk)); watcher.stderr?.on("data", chunk => (output += chunk)); await waitFor(() => inspect(directory), 15000, "initial locale watch build"); + await writeFile( + path.join(fixture.directory, "src/locales/de.json"), + await readFile(path.join(__dirname, "states/de.json")) + ); await writeFile( path.join(fixture.directory, "src/locales/en.json"), await readFile(path.join(__dirname, "states/en.json")) ); await waitFor( async () => { - const catalogue = await inspect(directory); + const {catalogue, keys, languages} = await inspect(directory); + expect([...languages].sort()).toEqual(["de", "en", "fr"]); + expect(catalogue.de.app_title).toBe("Übersetzungskatalog"); expect(catalogue.fr.app_greeting).toBe("Welcome {{name}}"); expect(catalogue.fr.newKey).toBe("Added during watch"); expect(catalogue.fr).not.toHaveProperty("empty"); + expect([...keys].sort()).toEqual(["app.greeting", "app.title", "items", "locale", "newKey"]); const declarations = await readFile(path.join(fixture.directory, ".adnbn/locale.d.ts"), "utf8"); expect(declarations).toContain('"newKey"'); expect(declarations).not.toContain('"empty"'); return true; }, 15000, - "updated locale catalogue and JSON" + "updated locale catalogue, languages and JSON" ); } catch (error) { throw new Error(`${String(error)}\n${output}`, {cause: error}); diff --git a/tests/integration/build/locale/states/de.json b/tests/integration/build/locale/states/de.json new file mode 100644 index 00000000..ced233e8 --- /dev/null +++ b/tests/integration/build/locale/states/de.json @@ -0,0 +1,9 @@ +{ + "app": { + "title": "Übersetzungskatalog" + }, + "items": [ + "{{count}} Artikel", + "{{count}} Artikel" + ] +} diff --git a/tests/integration/build/locale/states/native-background.ts b/tests/integration/build/locale/states/native-background.ts new file mode 100644 index 00000000..be306ddd --- /dev/null +++ b/tests/integration/build/locale/states/native-background.ts @@ -0,0 +1,4 @@ +import {NativeLocale} from "adnbn/locale"; + +(globalThis as typeof globalThis & {nativeLocale: NativeLocale}).nativeLocale = new NativeLocale(); +export default () => {}; diff --git a/tests/integration/build/locale/states/package-import.mjs b/tests/integration/build/locale/states/package-import.mjs new file mode 100644 index 00000000..1f7916af --- /dev/null +++ b/tests/integration/build/locale/states/package-import.mjs @@ -0,0 +1,6 @@ +import assert from "node:assert/strict"; +import {AbstractLocale, DynamicLocale, NativeLocale} from "adnbn/locale"; + +assert.equal(typeof globalThis.chrome, "undefined"); +assert.equal(Object.getPrototypeOf(DynamicLocale.prototype), AbstractLocale.prototype); +assert.equal(typeof NativeLocale, "function"); From a1479cac991ea6533a137938c9931098cb6fb23e Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:01:18 +0300 Subject: [PATCH 23/38] fix(locale): use build language when browser i18n is unavailable --- src/cli/plugins/locale/index.ts | 2 +- src/cli/plugins/locale/module.ts | 5 ++- src/locale/README.md | 6 ++- src/locale/catalogue/catalogue.ts | 3 +- src/locale/catalogue/module.d.ts | 1 + src/locale/providers/DynamicLocale.test.ts | 27 +++++++++++- src/locale/providers/DynamicLocale.ts | 12 ++--- .../dynamic.firefox.integration.test.ts | 44 +++++++++++++++++-- .../locale/dynamic.integration.test.ts | 28 +++++++++++- .../locale/chunks-fixture/src/catalogue.d.ts | 1 + .../build/locale/chunks.integration.test.ts | 11 ++++- .../dynamic-fixture/src/main.content.ts | 8 ++++ .../build/locale/dynamic-fixture/src/panel.ts | 12 ++--- .../build/locale/dynamic.integration.test.ts | 6 ++- .../build/locale/fixture/src/background.ts | 3 +- .../build/locale/fixture/src/catalogue.d.ts | 1 + .../build/locale/locale.integration.test.ts | 30 ++++++++++++- 17 files changed, 169 insertions(+), 31 deletions(-) create mode 100644 tests/integration/build/locale/dynamic-fixture/src/main.content.ts diff --git a/src/cli/plugins/locale/index.ts b/src/cli/plugins/locale/index.ts index 08604569..13dcd806 100644 --- a/src/cli/plugins/locale/index.ts +++ b/src/cli/plugins/locale/index.ts @@ -33,7 +33,7 @@ export default definePlugin(() => { }; const getModules = async () => ({ - [LocaleModuleName]: createLocaleModule(await locale.catalogue(), await locale.keys()), + [LocaleModuleName]: createLocaleModule(await locale.catalogue(), await locale.keys(), config.lang), }); await prepareLocale(); diff --git a/src/cli/plugins/locale/module.ts b/src/cli/plugins/locale/module.ts index d1e94118..4c166cf6 100644 --- a/src/cli/plugins/locale/module.ts +++ b/src/cli/plugins/locale/module.ts @@ -1,7 +1,8 @@ -import type {LocaleCatalogue} from "@typing/locale"; +import type {Language, LocaleCatalogue} from "@typing/locale"; -export const createLocaleModule = (catalogue: LocaleCatalogue, keys: ReadonlySet): string => +export const createLocaleModule = (catalogue: LocaleCatalogue, keys: ReadonlySet, lang: Language): string => // JSON.parse preserves own __proto__ keys; the pure annotation lets native consumers omit the catalogue. + `export const lang = ${JSON.stringify(lang)};\n` + `export const keys = ${JSON.stringify([...keys])};\n` + `export const languages = ${JSON.stringify(Object.keys(catalogue))};\n` + `export default /*#__PURE__*/ JSON.parse(${JSON.stringify(JSON.stringify(catalogue))});\n`; diff --git a/src/locale/README.md b/src/locale/README.md index 9ef30871..b12a3477 100644 --- a/src/locale/README.md +++ b/src/locale/README.md @@ -26,11 +26,11 @@ Shared languages, contracts, and module identifiers belong to `src/types/locale. Locale discovery, merging, validation, fallback preparation, native `_locales/*/messages.json`, and declaration generation belong to the CLI locale pipeline. The locale feature plugin supplies the generated data; Rspack integration and chunk delivery belong to the bundler. Shared message-flattening logic lives in `src/shared/locale`. -During extension builds, the private `#adnbn/locale` import resolves to `virtual/locale`: a default catalogue export plus named `keys` and `languages`. `DynamicLocale` reads the catalogue; `NativeLocale` imports only the named lists, allowing unused translations to be removed from optimized bundles. Outside these builds, `catalogue/` provides a resolvable empty module. +During extension builds, the private `#adnbn/locale` import resolves to `virtual/locale`: a default catalogue export plus named `keys`, `languages`, and `lang` (the configured default language). `DynamicLocale` reads the catalogue; `NativeLocale` imports only the named lists, allowing unused translations to be removed from optimized bundles. Outside these builds, `catalogue/` provides a resolvable empty module with `lang: "en"`. Views and ISOLATED content scripts can share `locale.js`. MAIN retains its own content layer: its catalogue stays in the entrypoint or joins `common-main.content.js` under the regular content chunk rules. Background keeps translations in its single bundle. This separates file delivery across content execution worlds; the same translations may occur in their respective bundles. -Core providers have no React dependency. Browser i18n access goes through `@addon-core/browser`; persistence goes through `@addon-core/storage`. Both native and dynamic providers determine their initial language from the browser's `locale` message. The empty package fallback does not make them operational without the required browser API and generated locale data. +Core providers have no React dependency. Browser i18n access goes through `@addon-core/browser`; persistence goes through `@addon-core/storage`. Both native and dynamic providers determine their initial language from the browser's `locale` message. If that call fails or its marker cannot be resolved, `DynamicLocale` uses the generated `lang`. `NativeLocale` still requires browser i18n. The empty package fallback contains no translations; `DynamicLocale` requires a generated catalogue containing its selected language. ## Runtime behavior @@ -38,4 +38,6 @@ Translations are synchronous strings. Providers share substitution and plural pr `DynamicLocale` reads bundled messages without fetching JSON or dynamically importing languages. `change()` updates its state immediately and returns a promise for saving the language code. Storage defaults to local storage with the key `"lang"`; a string changes the key, and `false` keeps state only in the instance. +Use `new DynamicLocale(false)` in MAIN: translations and language changes work from the bundle, starting with the configured default language. Extension storage is unavailable there; storage synchronization remains available in ISOLATED and extension pages. + Storage initialization is explicit: `sync()` reads the saved language, `watch()` observes subsequent storage events, and `unwatch()` disconnects. The constructor does neither. `watch()` is not a general subscription to in-memory state; `sync()` and `watch()` require enabled storage. The current React provider connects these methods on mount and exposes their results through context. diff --git a/src/locale/catalogue/catalogue.ts b/src/locale/catalogue/catalogue.ts index f1b756a8..9e8f0285 100644 --- a/src/locale/catalogue/catalogue.ts +++ b/src/locale/catalogue/catalogue.ts @@ -1,8 +1,9 @@ -import type {Language, LocaleCatalogue} from "@typing/locale"; +import {Language, type LocaleCatalogue} from "@typing/locale"; /** Package fallback; extension builds replace #adnbn/locale with virtual/locale. */ const catalogue: LocaleCatalogue = {}; +export const lang: Language = Language.English; export const keys: readonly string[] = []; export const languages: readonly Language[] = []; diff --git a/src/locale/catalogue/module.d.ts b/src/locale/catalogue/module.d.ts index c267d398..3841aad8 100644 --- a/src/locale/catalogue/module.d.ts +++ b/src/locale/catalogue/module.d.ts @@ -1,5 +1,6 @@ declare module "#adnbn/locale" { const catalogue: import("@typing/locale").LocaleCatalogue; + export const lang: import("@typing/locale").Language; export const keys: readonly string[]; export const languages: readonly import("@typing/locale").Language[]; export default catalogue; diff --git a/src/locale/providers/DynamicLocale.test.ts b/src/locale/providers/DynamicLocale.test.ts index 3f34b35f..d492d866 100644 --- a/src/locale/providers/DynamicLocale.test.ts +++ b/src/locale/providers/DynamicLocale.test.ts @@ -3,6 +3,7 @@ jest.mock("@addon-core/storage", () => ({Storage: {Local: jest.fn()}})); jest.mock("#adnbn/locale", () => ({ __esModule: true, default: require("./tests/fixtures/dynamic/catalogue.json"), + lang: "fr", keys: ["greeting", "items", "app.title", "fallback", "empty", "__proto__"], languages: ["en", "fr", "en_GB"], })); @@ -73,9 +74,31 @@ test("normalizes the native marker before selecting catalogue data", () => { expect(locale.trans("app.title")).toBe("Catalogue"); }); -test.each(["", "unknown", "de"])("rejects an unavailable native language: %s", marker => { +test.each([undefined, "", "unknown"])("uses the build language when the native marker is invalid: %s", marker => { jest.mocked(getI18nMessage).mockReturnValue(marker); - expect(() => new DynamicLocale(false)).toThrow(/Language/); + const locale = new DynamicLocale(false); + expect(locale.lang()).toBe(Language.French); + expect(locale.trans("greeting", {name: "Ada"})).toBe("Bonjour Ada"); +}); + +test("uses the build language when browser i18n is unavailable and changes synchronously in memory", async () => { + jest.mocked(getI18nMessage).mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'getMessage')"); + }); + const locale = new DynamicLocale(false); + expect(locale.lang()).toBe(Language.French); + expect(locale.trans("greeting", {name: "Ada"})).toBe("Bonjour Ada"); + const changed = locale.change(Language.English); + expect(locale.trans("greeting", {name: "Ada"})).toBe("Hello Ada"); + await expect(changed).resolves.toBe(Language.English); + expect(Storage.Local).not.toHaveBeenCalled(); + expect(getI18nMessage).toHaveBeenCalledTimes(1); +}); + +test("rejects a recognized native language that is absent from the catalogue", () => { + const marker = "de"; + jest.mocked(getI18nMessage).mockReturnValue(marker); + expect(() => new DynamicLocale(false)).toThrow('Language "de" is not available'); }); test("keeps public dot keys separate from catalogue keys and returns independent language collections", () => { diff --git a/src/locale/providers/DynamicLocale.ts b/src/locale/providers/DynamicLocale.ts index 1308fe91..1f2ef82b 100644 --- a/src/locale/providers/DynamicLocale.ts +++ b/src/locale/providers/DynamicLocale.ts @@ -1,7 +1,7 @@ import {getI18nMessage} from "@addon-core/browser"; import {Storage, type StorageProvider} from "@addon-core/storage"; -import catalogue, {keys, languages} from "#adnbn/locale"; +import catalogue, {keys, lang as defaultLanguage, languages} from "#adnbn/locale"; import AbstractLocale from "./AbstractLocale"; import type {LocaleNativeStructure} from "./NativeLocale"; @@ -23,13 +23,15 @@ export default class DynamicLocale constructor(storage: string | false = "lang") { super(); - const language = resolveLanguage(getI18nMessage(LocaleCustomKeyForLanguage)); + let marker: string | undefined; - if (!language) { - throw new Error("[DynamicLocale] Language is not defined. Failed to determine a supported locale."); + try { + marker = getI18nMessage(LocaleCustomKeyForLanguage); + } catch { + // MAIN has no extension i18n; use the configured language from the bundled catalogue. } - this.select(language); + this.select(resolveLanguage(marker) ?? defaultLanguage); if (storage) { this.storageKey = storage; diff --git a/tests/integration/browser/locale/dynamic.firefox.integration.test.ts b/tests/integration/browser/locale/dynamic.firefox.integration.test.ts index 20b6c702..7c9a1da4 100644 --- a/tests/integration/browser/locale/dynamic.firefox.integration.test.ts +++ b/tests/integration/browser/locale/dynamic.firefox.integration.test.ts @@ -12,7 +12,7 @@ import {startIntegrationSite, type IntegrationSite} from "../utils/site"; jest.setTimeout(90_000); -test("Firefox uses DynamicLocale in content scripts, persists selection and restores it on another page without requests", async () => { +test("Firefox uses DynamicLocale in both worlds and restores stored selection only in ISOLATED without requests", async () => { const binary = findFirefoxBinary(); if (!binary) throw new Error("Install Firefox or set ADNBN_FIREFOX_BIN"); const fixture = await createIntegrationFixture( @@ -39,18 +39,30 @@ test("Firefox uses DynamicLocale in content scripts, persists selection and rest const expectLanguage = async (context: string, language: string) => waitFor(async () => { expect( - await client!.evaluate(context, `document.querySelector('[data-locale-context]')?.dataset.language`) + await client!.evaluate( + context, + `document.querySelector('[data-locale-context="isolated"]')?.dataset.language` + ) ).toBe(language); return true; }); const {context} = await client.send("browsingContext.create", {type: "tab"}); await client.send("browsingContext.navigate", {context, url: site.origin, wait: "complete"}); await expectLanguage(context, "en"); + await waitFor(async () => { + expect( + await client!.evaluate( + context, + `document.querySelector('[data-locale-context="main"]')?.dataset.language` + ) + ).toBe("en"); + return true; + }); const before = client.requests.length; const greeting = await client.evaluate( context, `(() => { - const panel = document.querySelector('[data-locale-context]'); + const panel = document.querySelector('[data-locale-context="isolated"]'); const select = panel.querySelector('select'); select.value = 'fr'; select.dispatchEvent(new Event('change', {bubbles: true})); @@ -61,15 +73,39 @@ test("Firefox uses DynamicLocale in content scripts, persists selection and rest const second = await client.send("browsingContext.create", {type: "tab"}); await client.send("browsingContext.navigate", {context: second.context, url: site.origin, wait: "complete"}); await expectLanguage(second.context, "fr"); + await waitFor(async () => { + expect( + await client!.evaluate( + second.context, + `document.querySelector('[data-locale-context="main"]')?.dataset.language` + ) + ).toBe("en"); + return true; + }); + expect( + await client.evaluate( + context, + `(() => { + const panel = document.querySelector('[data-locale-context="main"]'); + const select = panel.querySelector('select'); + select.value = 'fr'; + select.dispatchEvent(new Event('change', {bubbles: true})); + return {lang: panel.dataset.language, message: panel.querySelector('p').textContent}; + })()` + ) + ).toEqual({lang: "fr", message: "Bonjour depuis DynamicLocale !"}); await client.evaluate( second.context, `(() => { - const select = document.querySelector('[data-locale-context] select'); + const select = document.querySelector('[data-locale-context="isolated"] select'); select.value = 'en'; select.dispatchEvent(new Event('change', {bubbles: true})); })()` ); await expectLanguage(context, "en"); + expect( + await client.evaluate(context, `document.querySelector('[data-locale-context="main"]').dataset.language`) + ).toBe("fr"); expect(client.requests.slice(before).filter(url => url.startsWith("moz-extension://"))).toEqual([]); expect(client.requests.filter(url => /_locales\/|messages\.json/.test(url))).toEqual([]); expect(client.runtimeErrors).toEqual([]); diff --git a/tests/integration/browser/locale/dynamic.integration.test.ts b/tests/integration/browser/locale/dynamic.integration.test.ts index 1e85ea9f..66fd2d40 100644 --- a/tests/integration/browser/locale/dynamic.integration.test.ts +++ b/tests/integration/browser/locale/dynamic.integration.test.ts @@ -11,7 +11,7 @@ import {startIntegrationSite, type IntegrationSite} from "../utils/site"; jest.setTimeout(90_000); -test("Chrome switches DynamicLocale synchronously and synchronizes storage across views and content without loading translations", async () => { +test("Chrome switches DynamicLocale in both worlds and synchronizes extension storage without loading translations", async () => { const binary = findChromeBinary(ADNBN_TEST_ROOT); if (!binary) throw new Error("Install Chrome for Testing or set ADNBN_CHROME_BIN"); const fixture = await createIntegrationFixture( @@ -77,7 +77,7 @@ test("Chrome switches DynamicLocale synchronously and synchronizes storage acros await waitFor(async () => { const panel = await evaluate( session, - `(() => { const panel = document.querySelector('[data-locale-context]'); return panel && {lang: panel.dataset.language, message: panel.querySelector('p').textContent}; })()` + `(() => { const panel = document.querySelector('[data-locale-context]:not([data-locale-context="main"])'); return panel && {lang: panel.dataset.language, message: panel.querySelector('p').textContent}; })()` ); expect(panel).toEqual({ lang, @@ -93,7 +93,28 @@ test("Chrome switches DynamicLocale synchronously and synchronizes storage acros await client.send("Page.navigate", {url}, session); await expectLanguage(session, "en"); } + await waitFor(async () => { + expect( + await evaluate(sessions[0], `document.querySelector('[data-locale-context="main"]')?.dataset.language`) + ).toBe("en"); + return true; + }); const before = client.requests.length; + expect( + await evaluate( + sessions[0], + `(() => { + const panel = document.querySelector('[data-locale-context="main"]'); + const select = panel.querySelector('select'); + select.value = 'fr'; + select.dispatchEvent(new Event('change', {bubbles: true})); + return {lang: panel.dataset.language, message: panel.querySelector('p').textContent}; + })()` + ) + ).toEqual({lang: "fr", message: "Bonjour depuis DynamicLocale !"}); + expect( + await evaluate(background, `(async () => (await chrome.storage.local.get('lang')).lang)()`) + ).toBeUndefined(); const state = await evaluate( background, `(async () => { @@ -139,6 +160,9 @@ test("Chrome switches DynamicLocale synchronously and synchronizes storage acros ) ).toBe("Hello from DynamicLocale!"); for (const session of sessions) await expectLanguage(session, "en"); + expect( + await evaluate(sessions[0], `document.querySelector('[data-locale-context="main"]').dataset.language`) + ).toBe("fr"); expect(await evaluate(background, "dynamicLocale.sync()")).toBe("en"); expect(client.requests.slice(afterReload).filter(url => url.startsWith(origin))).toEqual([]); expect(client.requests.filter(url => /_locales\/|messages\.json/.test(url))).toEqual([]); diff --git a/tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts b/tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts index c4966b12..aa08b107 100644 --- a/tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts +++ b/tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts @@ -1,4 +1,5 @@ declare module "virtual/locale" { + export const lang: import("adnbn/locale").Language; const catalogue: Record>; export const keys: readonly string[]; export const languages: readonly import("adnbn/locale").Language[]; diff --git a/tests/integration/build/locale/chunks.integration.test.ts b/tests/integration/build/locale/chunks.integration.test.ts index 50bb1146..75a2c675 100644 --- a/tests/integration/build/locale/chunks.integration.test.ts +++ b/tests/integration/build/locale/chunks.integration.test.ts @@ -5,6 +5,7 @@ import path from "path"; import vm from "vm"; import {createLocaleModule} from "@cli/plugins/locale/module"; +import {Language} from "@typing/locale"; import {createIntegrationFixture} from "../../utils/fixture"; jest.setTimeout(90_000); @@ -52,10 +53,16 @@ test.each([ } if (bytes !== undefined) { const keys = new Set(["greeting", "padding", "locale"]); - const empty = createLocaleModule({en: {greeting: Greeting, padding: "", locale: "en"}}, keys); + const empty = createLocaleModule( + {en: {greeting: Greeting, padding: "", locale: "en"}}, + keys, + Language.English + ); const padding = "x".repeat(bytes - Buffer.byteLength(empty)); const messages = {greeting: Greeting, padding}; - expect(Buffer.byteLength(createLocaleModule({en: {...messages, locale: "en"}}, keys))).toBe(bytes); + expect( + Buffer.byteLength(createLocaleModule({en: {...messages, locale: "en"}}, keys, Language.English)) + ).toBe(bytes); await writeFile(path.join(fixture.directory, "src/locales/en.json"), JSON.stringify(messages)); await rm(path.join(fixture.directory, "src/locales/fr.json")); } diff --git a/tests/integration/build/locale/dynamic-fixture/src/main.content.ts b/tests/integration/build/locale/dynamic-fixture/src/main.content.ts new file mode 100644 index 00000000..404285b3 --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/src/main.content.ts @@ -0,0 +1,8 @@ +import {defineContentScript} from "adnbn"; +import {createPanel} from "./panel"; + +export default defineContentScript({ + matches: ["http://127.0.0.1/*", "https://example.com/*"], + world: "MAIN", + render: () => createPanel("main", false), +}); diff --git a/tests/integration/build/locale/dynamic-fixture/src/panel.ts b/tests/integration/build/locale/dynamic-fixture/src/panel.ts index 4bc1138a..59839ab0 100644 --- a/tests/integration/build/locale/dynamic-fixture/src/panel.ts +++ b/tests/integration/build/locale/dynamic-fixture/src/panel.ts @@ -1,7 +1,7 @@ import {DynamicLocale, type Language} from "adnbn/locale"; -export const createPanel = (context: string): HTMLElement => { - const locale = new DynamicLocale(); +export const createPanel = (context: string, storage: string | false = "lang"): HTMLElement => { + const locale = new DynamicLocale(storage); const panel = document.createElement("div"); panel.dataset.localeContext = context; const select = document.createElement("select"); @@ -18,9 +18,11 @@ export const createPanel = (context: string): HTMLElement => { void saved.catch(console.error); }); update(); - void locale.sync().then(update).catch(console.error); - const stop = locale.watch(update); - window.addEventListener("pagehide", stop, {once: true}); + if (storage) { + void locale.sync().then(update).catch(console.error); + const stop = locale.watch(update); + window.addEventListener("pagehide", stop, {once: true}); + } panel.append(select, message); return panel; }; diff --git a/tests/integration/build/locale/dynamic.integration.test.ts b/tests/integration/build/locale/dynamic.integration.test.ts index c0a3f135..068075eb 100644 --- a/tests/integration/build/locale/dynamic.integration.test.ts +++ b/tests/integration/build/locale/dynamic.integration.test.ts @@ -26,7 +26,9 @@ test.each(["chrome", "firefox"])( expect(catalogue).toContain("Bonjour depuis DynamicLocale"); expect(catalogue).toContain("Completed from English"); expect(await readFile(path.join(directory, background), "utf8")).toContain("Bonjour depuis DynamicLocale"); - expect(manifest.content_scripts[0].js).toContain("js/locale.js"); + expect(manifest.content_scripts.find((entry: {world: string}) => entry.world === "ISOLATED").js).toContain( + "js/locale.js" + ); expect(manifest.web_accessible_resources).toBeUndefined(); for (const filename of [manifest.action.default_popup, manifest.options_ui.page]) { expect(await readFile(path.join(directory, filename), "utf8")).toContain("js/locale.js"); @@ -57,7 +59,7 @@ test.each(["chrome", "firefox"])( test("native-only consumers do not include the catalogue despite importing the public barrel", async () => { const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, fixtureDirectory); try { - for (const file of ["popup.ts", "options.ts", "locale.content.ts"]) + for (const file of ["popup.ts", "options.ts", "locale.content.ts", "main.content.ts"]) await rm(path.join(fixture.directory, "src", file)); await copyFile( path.join(__dirname, "states/native-background.ts"), diff --git a/tests/integration/build/locale/fixture/src/background.ts b/tests/integration/build/locale/fixture/src/background.ts index 1d83709a..4f77a054 100644 --- a/tests/integration/build/locale/fixture/src/background.ts +++ b/tests/integration/build/locale/fixture/src/background.ts @@ -1,7 +1,8 @@ -import catalogue, {keys, languages} from "virtual/locale"; +import catalogue, {keys, lang, languages} from "virtual/locale"; (globalThis as typeof globalThis & {readLocaleCatalogue: () => typeof catalogue}).readLocaleCatalogue = () => catalogue; (globalThis as typeof globalThis & {readLocaleKeys: () => typeof keys}).readLocaleKeys = () => keys; +(globalThis as typeof globalThis & {readLocaleLanguage: () => typeof lang}).readLocaleLanguage = () => lang; (globalThis as typeof globalThis & {readLocaleLanguages: () => typeof languages}).readLocaleLanguages = () => languages; export default () => {}; diff --git a/tests/integration/build/locale/fixture/src/catalogue.d.ts b/tests/integration/build/locale/fixture/src/catalogue.d.ts index c4966b12..aa08b107 100644 --- a/tests/integration/build/locale/fixture/src/catalogue.d.ts +++ b/tests/integration/build/locale/fixture/src/catalogue.d.ts @@ -1,4 +1,5 @@ declare module "virtual/locale" { + export const lang: import("adnbn/locale").Language; const catalogue: Record>; export const keys: readonly string[]; export const languages: readonly import("adnbn/locale").Language[]; diff --git a/tests/integration/build/locale/locale.integration.test.ts b/tests/integration/build/locale/locale.integration.test.ts index 29a4f656..61717d60 100644 --- a/tests/integration/build/locale/locale.integration.test.ts +++ b/tests/integration/build/locale/locale.integration.test.ts @@ -1,7 +1,7 @@ /** @jest-environment node */ import {spawn, type ChildProcess} from "child_process"; -import {readFile, readdir, rm, writeFile} from "fs/promises"; +import {copyFile, readFile, readdir, rm, writeFile} from "fs/promises"; import path from "path"; import vm from "vm"; import {createIntegrationFixture} from "../../utils/fixture"; @@ -19,6 +19,7 @@ const inspect = async (directory: string) => { const sandbox = { readLocaleCatalogue: undefined as (() => Catalogue) | undefined, readLocaleKeys: undefined as (() => readonly string[]) | undefined, + readLocaleLanguage: undefined as (() => string) | undefined, readLocaleLanguages: undefined as (() => readonly string[]) | undefined, }; const context = vm.createContext(sandbox); @@ -26,6 +27,8 @@ const inspect = async (directory: string) => { vm.runInContext(await readFile(path.join(directory, file), "utf8"), context); } const catalogue = sandbox.readLocaleCatalogue!(); + const lang = sandbox.readLocaleLanguage!(); + expect(lang).toBe(manifest.default_locale); const exportedLanguages = sandbox.readLocaleLanguages!(); expect(exportedLanguages).toEqual(Object.keys(catalogue)); const languages = await readdir(path.join(directory, "_locales")); @@ -38,7 +41,7 @@ const inspect = async (directory: string) => { expect(catalogue[lang]).toEqual(flattened); expect(catalogue[lang].locale).toBe(lang); } - return {catalogue, keys: sandbox.readLocaleKeys!(), languages: exportedLanguages}; + return {catalogue, keys: sandbox.readLocaleKeys!(), lang, languages: exportedLanguages}; }; test.each(["chrome", "firefox"])("%s build imports the catalogue and preserves the native JSON data", async browser => { @@ -69,6 +72,27 @@ test.each(["chrome", "firefox"])("%s build imports the catalogue and preserves t } }); +test("exports the configured default language even when it is not the first catalogue language", async () => { + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, path.join(__dirname, "fixture")); + try { + // Give the new default the complete message contract, including own __proto__ keys. + await copyFile( + path.join(fixture.directory, "src/locales/en.json"), + path.join(fixture.directory, "src/locales/fr.json") + ); + const filename = path.join(fixture.directory, "adnbn.config.ts"); + await writeFile( + filename, + (await readFile(filename, "utf8")).replace('version: "1.0.0",', 'version: "1.0.0", lang: "fr",') + ); + const {lang, languages} = await inspect(await fixture.build()); + expect(languages[0]).toBe("en"); + expect(lang).toBe("fr"); + } finally { + await fixture.dispose(); + } +}); + test("CLI watch refreshes the catalogue, JSON and declarations from the same locale edit", async () => { const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, path.join(__dirname, "fixture")); let watcher: ChildProcess | undefined; @@ -82,6 +106,8 @@ test("CLI watch refreshes the catalogue, JSON and declarations from the same loc }); watcher.stdout?.on("data", chunk => (output += chunk)); watcher.stderr?.on("data", chunk => (output += chunk)); + // Assets exist before Rspack finishes the compilation and reconnects its watcher. + await waitFor(async () => output.includes("compiled") || undefined, 15000, "initial locale watch compilation"); await waitFor(() => inspect(directory), 15000, "initial locale watch build"); await writeFile( path.join(fixture.directory, "src/locales/de.json"), From b711e70c81d9feeb6b9a40c2aefed9ef878727f9 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:55:33 +0300 Subject: [PATCH 24/38] feat(locale): support custom storage drivers --- AGENTS.md | 5 + src/entry/content/core/MountBuilder.ts | 10 +- src/locale/README.md | 9 +- src/locale/adapters/react/LocaleProvider.tsx | 10 +- .../adapters/react/localeProvider.test.ts | 28 +++++- src/locale/index.ts | 2 + src/locale/providers/DynamicLocale.test.ts | 98 ++++++++++++++----- src/locale/providers/DynamicLocale.ts | 47 +++++---- src/locale/storage/LocaleStorage.test.ts | 97 ++++++++++++++++++ src/locale/storage/LocaleStorage.ts | 45 +++++++++ src/locale/storage/index.ts | 1 + src/locale/tests/fixtures/storage.ts | 22 +++++ src/types/locale.ts | 11 +++ .../locale/dynamic.integration.test.ts | 7 +- .../locale/dynamic-fixture/src/contract.ts | 20 +++- .../build/locale/dynamic-fixture/src/panel.ts | 6 +- 16 files changed, 349 insertions(+), 69 deletions(-) create mode 100644 src/locale/storage/LocaleStorage.test.ts create mode 100644 src/locale/storage/LocaleStorage.ts create mode 100644 src/locale/storage/index.ts create mode 100644 src/locale/tests/fixtures/storage.ts diff --git a/AGENTS.md b/AGENTS.md index 61d987cf..c29cfba0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,11 @@ - Preserve behavior while separating responsibilities: public exports, generated declarations, loading order, lifecycle, user filename templates, manifest/WAR semantics, and watch updates. Verify at the owning layer and use existing integration tests for cross-layer guarantees. - Apply these rules to new and changed code. Report unrelated existing violations rather than expanding a focused task into an unrequested architectural rewrite. +## Built-in storage + +- Every built-in storage uses a namespace exactly matching its owning package name, including case. In this framework, use `PackageName` from `@typing/app` (`adnbn`) rather than repeating a literal. +- Features define default keys inside that shared namespace; locale selection defaults to `locale`. Keep the namespace inside the built-in storage implementation; callers may configure the key through its constructor. Custom sources implement the feature's storage driver contract. + ## Internal imports - Import internal TypeScript modules through configured aliases or relative paths without a file extension. Do not write the future `.js` output path into a TypeScript source import. diff --git a/src/entry/content/core/MountBuilder.ts b/src/entry/content/core/MountBuilder.ts index 41dc56b6..a7872086 100644 --- a/src/entry/content/core/MountBuilder.ts +++ b/src/entry/content/core/MountBuilder.ts @@ -1,10 +1,8 @@ -import FrameNode from "./nodes/FrameNode"; -import {isContentScriptFrameNavigation} from "@shared/content"; import Builder from "./Builder"; -import Node from "./nodes/Node"; -import MountNode from "./nodes/MountNode"; -import MarkerNode from "./nodes/MarkerNode"; -import ShadowNode from "./nodes/ShadowNode"; + +import {FrameNode, MountNode, MarkerNode, ShadowNode, Node} from "./nodes"; + +import {isContentScriptFrameNavigation} from "@shared/content"; import {ContentScriptIsolation, ContentScriptNode, ContentScriptProps, ContentScriptRenderValue} from "@typing/content"; diff --git a/src/locale/README.md b/src/locale/README.md index b12a3477..64a09110 100644 --- a/src/locale/README.md +++ b/src/locale/README.md @@ -17,6 +17,7 @@ description: Runtime translation providers, shared formatting, catalogue access, | `helpers.ts` | Native shortcuts: `t`, `choice`, `key` for browser message references, and `resolve` for strings prefixed with `@`. | | `utils.ts` | Key conversion, locale markers, language resolution, and text direction. | | `catalogue/` | Empty package fallback and typing for the generated data module. | +| `storage/` | Built-in `LocaleStorage` implementation of the public `LocaleStorageDriver` contract. | | `adapters/react/` | `LocaleProvider`, its context, and `useLocale`; connects dynamic localization to React state and DOM `lang`/`dir` attributes. | | `index.ts` | Public providers, helpers, and selected shared contracts. | @@ -30,14 +31,16 @@ During extension builds, the private `#adnbn/locale` import resolves to `virtual Views and ISOLATED content scripts can share `locale.js`. MAIN retains its own content layer: its catalogue stays in the entrypoint or joins `common-main.content.js` under the regular content chunk rules. Background keeps translations in its single bundle. This separates file delivery across content execution worlds; the same translations may occur in their respective bundles. -Core providers have no React dependency. Browser i18n access goes through `@addon-core/browser`; persistence goes through `@addon-core/storage`. Both native and dynamic providers determine their initial language from the browser's `locale` message. If that call fails or its marker cannot be resolved, `DynamicLocale` uses the generated `lang`. `NativeLocale` still requires browser i18n. The empty package fallback contains no translations; `DynamicLocale` requires a generated catalogue containing its selected language. +Core providers have no React dependency. Browser i18n access goes through `@addon-core/browser`; `DynamicLocale` delegates persistence to `LocaleStorageDriver`. The built-in `LocaleStorage` uses `@addon-core/storage`. Both native and dynamic providers determine their initial language from the browser's `locale` message. If that call fails or its marker cannot be resolved, `DynamicLocale` uses the generated `lang`. `NativeLocale` still requires browser i18n. The empty package fallback contains no translations; `DynamicLocale` requires a generated catalogue containing its selected language. ## Runtime behavior Translations are synchronous strings. Providers share substitution and plural processing through `AbstractLocale`; `trans` handles non-plural keys and `choice` selects a form by count. Substitutions accept strings and numbers. Generated public keys use dot notation, while message lookup converts them to underscore keys. Missing messages warn and return the key; empty translations remain empty strings. -`DynamicLocale` reads bundled messages without fetching JSON or dynamically importing languages. `change()` updates its state immediately and returns a promise for saving the language code. Storage defaults to local storage with the key `"lang"`; a string changes the key, and `false` keeps state only in the instance. +`DynamicLocale` reads bundled messages without fetching JSON or dynamically importing languages. `change()` updates its state immediately and returns a promise for saving the language code. Without a constructor argument it creates `LocaleStorage`, which uses extension local storage with the fixed namespace `PackageName` (`adnbn`) and default key `locale`. Pass another key to `new LocaleStorage("customLocale")`; the constructor stores it in the protected `key` property used for reading, writing, and watching. Passing `false` to `DynamicLocale` keeps state only in the instance; passing a `LocaleStorageDriver` uses that object directly. -Use `new DynamicLocale(false)` in MAIN: translations and language changes work from the bundle, starting with the configured default language. Extension storage is unavailable there; storage synchronization remains available in ISOLATED and extension pages. +In MAIN, use `new DynamicLocale(false)` or supply a custom driver that does not require extension APIs. Translations and language changes work from the bundle, starting with the configured default language. The built-in driver is intended for ISOLATED and extension pages. Storage initialization is explicit: `sync()` reads the saved language, `watch()` observes subsequent storage events, and `unwatch()` disconnects. The constructor does neither. `watch()` is not a general subscription to in-memory state; `sync()` and `watch()` require enabled storage. The current React provider connects these methods on mount and exposes their results through context. + +`LocaleStorageDriver` provides `get(): Promise`, `set(lang): Promise`, and `watch(handler): () => void`. Reading an absent value leaves the current language unchanged. Notifications contain only valid language codes, including own writes, with no initial notification or deletion event. `LocaleStorage` ignores absent values and diagnoses malformed codes; `DynamicLocale` additionally checks that a language exists in its catalogue. Read and write errors propagate to callers. Custom drivers own their persistence and notifications; pass the same driver to providers that should share its state. diff --git a/src/locale/adapters/react/LocaleProvider.tsx b/src/locale/adapters/react/LocaleProvider.tsx index 0d668235..18af1948 100644 --- a/src/locale/adapters/react/LocaleProvider.tsx +++ b/src/locale/adapters/react/LocaleProvider.tsx @@ -6,10 +6,10 @@ import {getLocaleDir, isLocaleRtl} from "@locale/utils"; import {DynamicLocale} from "@locale/providers"; -import {Language} from "@typing/locale"; +import {Language, type LocaleStorageDriver} from "@typing/locale"; export interface LocaleProviderProps { - storage?: string | false; + storage?: LocaleStorageDriver | false; container?: string | Element | false; } @@ -31,6 +31,8 @@ const LocaleProvider = ({children, storage, container = "html"}: PropsWithChildr locale .change(lang) .catch(err => console.error(`[LocaleProvider] Cannot find locale file for "${lang}" language`, err)); + + setLang(locale.lang()); }, []); useEffect(() => { @@ -52,10 +54,14 @@ const LocaleProvider = ({children, storage, container = "html"}: PropsWithChildr }, [lang, container]); useEffect(() => { + if (storage === false) return; + locale.sync().then(lang => setLang(lang)); }, []); useEffect(() => { + if (storage === false) return; + return locale.watch(lang => setLang(lang)); }, []); diff --git a/src/locale/adapters/react/localeProvider.test.ts b/src/locale/adapters/react/localeProvider.test.ts index f317eb82..97c4008c 100644 --- a/src/locale/adapters/react/localeProvider.test.ts +++ b/src/locale/adapters/react/localeProvider.test.ts @@ -12,6 +12,7 @@ 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 {createLocaleStorage} from "../../tests/fixtures/storage"; import {useLocale, type LocaleContract} from "./context"; import {Language} from "@typing/locale"; @@ -62,8 +63,7 @@ describe("React locale provider", () => { test("preserves an empty catalogue translation on the first render without warnings or fetching", async () => { jest.mocked(getI18nMessage).mockImplementation(key => (key === "locale" ? "ru" : "")); const warn = jest.spyOn(console, "warn").mockImplementation(); - const wrapper = ({children}: PropsWithChildren) => - createElement(LocaleProvider, {container: false, storage: "native-empty-test"}, children); + const wrapper = ({children}: PropsWithChildren) => createElement(LocaleProvider, {container: false}, children); const {result} = renderHook( () => { const locale = useLocale() as LocaleContract<{ @@ -84,8 +84,7 @@ describe("React locale provider", () => { }); 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 wrapper = ({children}: PropsWithChildren) => createElement(LocaleProvider, {container: false}, children); const {result, rerender} = renderHook(() => useLocale(), {wrapper}); await act(async () => {}); @@ -103,4 +102,25 @@ describe("React locale provider", () => { expect(result.current.langs).toBe(langs); expect([...result.current.langs.keys()]).toEqual([Language.English, Language.French, Language.Russian]); }); + + test("uses the supplied driver for saved and subsequent selections", async () => { + const storage = createLocaleStorage(Language.French); + const wrapper = ({children}: PropsWithChildren) => + createElement(LocaleProvider, {container: false, storage}, children); + const {result} = renderHook(() => useLocale(), {wrapper}); + await waitFor(() => expect(result.current.lang).toBe(Language.French)); + act(() => result.current.change(Language.Russian)); + await waitFor(() => expect(result.current.lang).toBe(Language.Russian)); + await expect(storage.get()).resolves.toBe(Language.Russian); + }); + + test("changes language without storage synchronization when storage is disabled", async () => { + const wrapper = ({children}: PropsWithChildren) => + createElement(LocaleProvider, {container: false, storage: false}, children); + const {result} = renderHook(() => useLocale(), {wrapper}); + await act(async () => {}); + expect(result.current.lang).toBe(Language.English); + act(() => result.current.change(Language.French)); + expect(result.current.lang).toBe(Language.French); + }); }); diff --git a/src/locale/index.ts b/src/locale/index.ts index 19d6d8d9..0c9c5da6 100644 --- a/src/locale/index.ts +++ b/src/locale/index.ts @@ -1,5 +1,6 @@ export * from "./providers"; export * from "./helpers"; +export * from "./storage"; export { Language, @@ -8,6 +9,7 @@ export { type LocaleStructure, type LocaleProvider, type LocaleDynamicProvider, + type LocaleStorageDriver, type LocaleKeys, type LocaleSubstitutionArgs, type LocaleSubstitutionKeys, diff --git a/src/locale/providers/DynamicLocale.test.ts b/src/locale/providers/DynamicLocale.test.ts index d492d866..bd324529 100644 --- a/src/locale/providers/DynamicLocale.test.ts +++ b/src/locale/providers/DynamicLocale.test.ts @@ -11,6 +11,7 @@ jest.mock("#adnbn/locale", () => ({ import {getI18nMessage} from "@addon-core/browser"; import {Storage, type StorageProvider} from "@addon-core/storage"; import DynamicLocale from "./DynamicLocale"; +import {createLocaleStorage} from "../tests/fixtures/storage"; import {Language} from "@typing/locale"; interface Structure { @@ -127,15 +128,16 @@ test("switches messages immediately while waiting for storage persistence", asyn expect(locale.choice("items", 0, {count: 0})).toBe("0 article"); expect(locale.trans("fallback")).toBe("Shared default message"); expect(locale.trans("empty")).toBe(""); - expect(driver.set).toHaveBeenCalledWith("lang", Language.French); + expect(driver.set).toHaveBeenCalledWith("locale", Language.French); write.resolve(); await expect(saved).resolves.toBe(Language.French); }); -test("persists a repeated selection of the current language under the configured key", async () => { - const locale = new DynamicLocale("chosen-language"); +test("persists a repeated selection of the current language under the framework namespace and locale key", async () => { + const locale = new DynamicLocale(); await locale.change(Language.English); - expect(driver.set).toHaveBeenCalledWith("chosen-language", Language.English); + expect(Storage.Local).toHaveBeenCalledWith({namespace: "adnbn"}); + expect(driver.set).toHaveBeenCalledWith("locale", Language.English); expect(stored).toBe(Language.English); }); @@ -164,26 +166,23 @@ test("ignores prototype properties and rejects languages absent from the applica test("restores the saved language only through explicit sync, without writing it back", async () => { stored = Language.French; - const locale = new DynamicLocale("chosen-language"); + const locale = new DynamicLocale(); expect(locale.lang()).toBe(Language.English); expect(driver.get).not.toHaveBeenCalled(); await expect(locale.sync()).resolves.toBe(Language.French); - expect(driver.get).toHaveBeenCalledWith("chosen-language"); + expect(driver.get).toHaveBeenCalledWith("locale"); expect(driver.set).not.toHaveBeenCalled(); }); -test.each([undefined, "de", "unknown", "__proto__"])( - "keeps the current language for invalid or absent storage: %s", - async value => { - stored = value; - const warn = jest.spyOn(console, "warn").mockImplementation(); - const locale = new DynamicLocale(); - await expect(locale.sync()).resolves.toBe(Language.English); - expect(driver.set).not.toHaveBeenCalled(); - if (value) expect(warn).toHaveBeenCalledWith(`Incorrect language code in storage - "${value}"`); - else expect(warn).not.toHaveBeenCalled(); - } -); +test.each([undefined, "de"])("keeps the current language for invalid or absent storage: %s", async value => { + stored = value; + const warn = jest.spyOn(console, "warn").mockImplementation(); + const locale = new DynamicLocale(); + await expect(locale.sync()).resolves.toBe(Language.English); + expect(driver.set).not.toHaveBeenCalled(); + if (value) expect(warn).toHaveBeenCalledWith(`Incorrect language code in storage - "${value}"`); + else expect(warn).not.toHaveBeenCalled(); +}); test("propagates storage read errors without changing the language", async () => { const error = new Error("Storage read failed"); @@ -194,10 +193,10 @@ test("propagates storage read errors without changing the language", async () => }); test("applies external changes and own storage events without another write", async () => { - const locale = new DynamicLocale("chosen-language"); + const locale = new DynamicLocale(); const handler = jest.fn(); const stop = locale.watch(handler); - notify("chosen-language", Language.French); + notify("locale", Language.French); expect(locale.lang()).toBe(Language.French); expect(handler).toHaveBeenLastCalledWith(Language.French); expect(driver.set).not.toHaveBeenCalled(); @@ -207,7 +206,7 @@ test("applies external changes and own storage events without another write", as expect(handler).toHaveBeenCalledTimes(2); stop(); stop(); - notify("chosen-language", Language.French); + notify("locale", Language.French); expect(locale.lang()).toBe(Language.English); expect(listeners.size).toBe(0); }); @@ -228,8 +227,8 @@ test("reports invalid external changes without changing state or notifying the h const error = jest.spyOn(console, "error").mockImplementation(); const handler = jest.fn(); const stop = locale.watch(handler); - notify("lang", Language.German); - notify("lang", undefined); + notify("locale", Language.German); + notify("locale", undefined); expect(error).toHaveBeenCalledWith("Error while changing language:", expect.any(Error)); expect(handler).not.toHaveBeenCalled(); expect(locale.lang()).toBe(Language.English); @@ -248,3 +247,56 @@ test("supports memory-only changes without creating storage", async () => { expect(() => locale.watch()).toThrow("Language is not saved in storage"); expect(() => locale.unwatch()).not.toThrow(); }); + +test("uses a custom driver without constructing extension storage, including without browser i18n", async () => { + jest.mocked(getI18nMessage).mockImplementationOnce(() => { + throw new TypeError("Browser i18n is unavailable"); + }); + const storage = createLocaleStorage(Language.English); + const write = jest.spyOn(storage, "set"); + const locale = new DynamicLocale(storage); + expect(locale.lang()).toBe(Language.French); + await expect(locale.sync()).resolves.toBe(Language.English); + const handler = jest.fn(); + const stop = locale.watch(handler); + const saved = locale.change(Language.French); + expect(locale.trans("greeting", {name: "Ada"})).toBe("Bonjour Ada"); + await expect(saved).resolves.toBe(Language.French); + expect(handler).toHaveBeenCalledWith(Language.French); + expect(write).toHaveBeenCalledTimes(1); + expect(write).toHaveBeenCalledWith(Language.French); + stop(); + await storage.set(Language.English); + expect(handler).toHaveBeenCalledTimes(1); + expect(locale.lang()).toBe(Language.French); + expect(Storage.Local).not.toHaveBeenCalled(); +}); + +test("shares a supplied driver while keeping different drivers independent", async () => { + const storage = createLocaleStorage(); + const first = new DynamicLocale(storage); + const second = new DynamicLocale(storage); + const independent = new DynamicLocale(createLocaleStorage()); + const stopFirst = first.watch(); + const stopSecond = second.watch(); + const stopIndependent = independent.watch(); + await first.change(Language.French); + expect(second.lang()).toBe(Language.French); + expect(independent.lang()).toBe(Language.English); + stopSecond(); + await first.change(Language.EnglishGreatBritain); + expect(second.lang()).toBe(Language.French); + stopFirst(); + stopIndependent(); +}); + +test("propagates custom driver read and write errors without rolling back the selected language", async () => { + const storage = createLocaleStorage(); + const error = new Error("Custom storage failed"); + jest.spyOn(storage, "get").mockRejectedValueOnce(error); + jest.spyOn(storage, "set").mockRejectedValueOnce(error); + const locale = new DynamicLocale(storage); + await expect(locale.sync()).rejects.toBe(error); + await expect(locale.change(Language.French)).rejects.toBe(error); + expect(locale.lang()).toBe(Language.French); +}); diff --git a/src/locale/providers/DynamicLocale.ts b/src/locale/providers/DynamicLocale.ts index 1f2ef82b..6bb30889 100644 --- a/src/locale/providers/DynamicLocale.ts +++ b/src/locale/providers/DynamicLocale.ts @@ -1,13 +1,18 @@ import {getI18nMessage} from "@addon-core/browser"; -import {Storage, type StorageProvider} from "@addon-core/storage"; import catalogue, {keys, lang as defaultLanguage, languages} from "#adnbn/locale"; import AbstractLocale from "./AbstractLocale"; import type {LocaleNativeStructure} from "./NativeLocale"; +import {LocaleStorage} from "../storage"; import {convertLocaleKey, resolveLanguage} from "../utils"; -import {Language, LocaleCustomKeyForLanguage, type LocaleDynamicProvider} from "@typing/locale"; +import { + Language, + LocaleCustomKeyForLanguage, + type LocaleDynamicProvider, + type LocaleStorageDriver, +} from "@typing/locale"; export default class DynamicLocale extends AbstractLocale @@ -16,11 +21,10 @@ export default class DynamicLocale private language!: Language; private messages!: Record; - protected storage?: StorageProvider>; - protected storageKey?: string; + protected storage?: LocaleStorageDriver; protected unsubscribe?: () => void; - constructor(storage: string | false = "lang") { + constructor(storage?: LocaleStorageDriver | false) { super(); let marker: string | undefined; @@ -33,28 +37,25 @@ export default class DynamicLocale this.select(resolveLanguage(marker) ?? defaultLanguage); - if (storage) { - this.storageKey = storage; - this.storage = Storage.Local(); - } + this.storage = storage === false ? undefined : (storage ?? new LocaleStorage()); } public async change(lang: Language): Promise { this.select(lang); - if (this.storage && this.storageKey) { - await this.storage.set(this.storageKey, lang); + if (this.storage) { + await this.storage.set(lang); } return lang; } public async sync(): Promise { - if (!this.storage || !this.storageKey) { + if (!this.storage) { throw new Error("Language is not saving in storage"); } - const lang = await this.storage.get(this.storageKey); + const lang = await this.storage.get(); if (!lang) { return this.lang(); @@ -70,7 +71,7 @@ export default class DynamicLocale } public watch(handler?: (lang: Language) => void): () => void { - if (!this.storage || !this.storageKey) { + if (!this.storage) { throw new Error("Language is not saved in storage"); } @@ -78,17 +79,13 @@ export default class DynamicLocale throw new Error("Already subscribed to language changes in storage"); } - this.unsubscribe = this.storage.watch({ - [this.storageKey]: newValue => { - if (newValue) { - try { - this.select(newValue); - handler?.(newValue); - } catch (error) { - console.error("Error while changing language:", error); - } - } - }, + this.unsubscribe = this.storage.watch(lang => { + try { + this.select(lang); + handler?.(lang); + } catch (error) { + console.error("Error while changing language:", error); + } }); return this.unwatch.bind(this); diff --git a/src/locale/storage/LocaleStorage.test.ts b/src/locale/storage/LocaleStorage.test.ts new file mode 100644 index 00000000..622cf460 --- /dev/null +++ b/src/locale/storage/LocaleStorage.test.ts @@ -0,0 +1,97 @@ +jest.mock("@addon-core/storage", () => ({Storage: {Local: jest.fn()}})); + +import {Storage, type StorageProvider} from "@addon-core/storage"; +import {LocaleStorage} from "./LocaleStorage"; +import {Language} from "@typing/locale"; + +type Watcher = {locale?: (value: unknown) => void}; +let value: unknown; +const listeners = new Set(); +const emit = (next: unknown) => { + value = next; + for (const watcher of listeners) watcher.locale?.(next); +}; +const provider = { + get: jest.fn(async (_key: string): Promise => value), + set: jest.fn(async (_key: string, next: Language) => emit(next)), + watch: jest.fn((watcher: Watcher) => { + listeners.add(watcher); + return () => listeners.delete(watcher); + }), +}; + +beforeEach(() => { + value = undefined; + listeners.clear(); + jest.clearAllMocks(); + jest.mocked(Storage.Local).mockReturnValue(provider as unknown as StorageProvider<{locale: Language}>); +}); + +afterEach(() => jest.restoreAllMocks()); + +test("uses the package namespace and locale key without reading or subscribing at construction", async () => { + const storage = new LocaleStorage(); + expect(Storage.Local).toHaveBeenCalledWith({namespace: "adnbn"}); + expect(provider.get).not.toHaveBeenCalled(); + expect(provider.watch).not.toHaveBeenCalled(); + await expect(storage.get()).resolves.toBeUndefined(); + expect(provider.get).toHaveBeenCalledWith("locale"); + await storage.set(Language.French); + expect(provider.set).toHaveBeenCalledWith("locale", Language.French); + await expect(storage.get()).resolves.toBe(Language.French); +}); + +test.each([Language.English, Language.EnglishGreatBritain, Language.German])( + "reads a language independently of the application catalogue: %s", + async lang => { + value = lang; + await expect(new LocaleStorage().get()).resolves.toBe(lang); + } +); + +test.each([null, "", "unknown", "__proto__", "en-GB", 1, {}, ["en"]])( + "ignores invalid stored values in reads and notifications: %p", + async invalid => { + const warn = jest.spyOn(console, "warn").mockImplementation(); + const storage = new LocaleStorage(); + const handler = jest.fn(); + const stop = storage.watch(handler); + emit(invalid); + await expect(storage.get()).resolves.toBeUndefined(); + expect(handler).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith("[LocaleStorage] Invalid language code in storage:", invalid); + expect(provider.set).not.toHaveBeenCalled(); + stop(); + } +); + +test("notifies every subscriber of own and external selections, ignores deletion and unsubscribes independently", async () => { + const storage = new LocaleStorage(); + const first = jest.fn(); + const second = jest.fn(); + const stopFirst = storage.watch(first); + const stopSecond = storage.watch(second); + expect(first).not.toHaveBeenCalled(); + emit(Language.German); + await storage.set(Language.French); + emit(undefined); + expect(first.mock.calls).toEqual([[Language.German], [Language.French]]); + expect(second.mock.calls).toEqual(first.mock.calls); + stopFirst(); + stopFirst(); + emit(Language.English); + expect(first).toHaveBeenCalledTimes(2); + expect(second).toHaveBeenLastCalledWith(Language.English); + stopSecond(); + expect(listeners.size).toBe(0); + expect(provider.set).toHaveBeenCalledTimes(1); +}); + +test("propagates storage errors to the caller", async () => { + const error = new Error("Storage denied"); + provider.get.mockRejectedValueOnce(error); + provider.set.mockRejectedValueOnce(error); + const storage = new LocaleStorage(); + await expect(storage.get()).rejects.toBe(error); + await expect(storage.set(Language.French)).rejects.toBe(error); +}); diff --git a/src/locale/storage/LocaleStorage.ts b/src/locale/storage/LocaleStorage.ts new file mode 100644 index 00000000..5e3b91d9 --- /dev/null +++ b/src/locale/storage/LocaleStorage.ts @@ -0,0 +1,45 @@ +import {Storage} from "@addon-core/storage"; + +import {PackageName} from "@typing/app"; +import {Language, LanguageCodes, type LocaleStorageDriver} from "@typing/locale"; + +/** Stores the selected language in extension local storage under the framework namespace. */ +export class LocaleStorage implements LocaleStorageDriver { + private readonly storage = Storage.Local>({namespace: PackageName}); + + constructor(protected key: string = "locale") {} + + public async get(): Promise { + return this.parse(await this.storage.get(this.key)); + } + + public set(lang: Language): Promise { + return this.storage.set(this.key, lang); + } + + public watch(handler: (lang: Language) => void): () => void { + return this.storage.watch({ + [this.key]: value => { + const lang = this.parse(value); + + if (lang !== undefined) { + handler(lang); + } + }, + }); + } + + private parse(value: unknown): Language | undefined { + if (value === undefined) { + return undefined; + } + + if (typeof value === "string" && LanguageCodes.has(value)) { + return value as Language; + } + + console.warn("[LocaleStorage] Invalid language code in storage:", value); + + return undefined; + } +} diff --git a/src/locale/storage/index.ts b/src/locale/storage/index.ts new file mode 100644 index 00000000..0e2a1944 --- /dev/null +++ b/src/locale/storage/index.ts @@ -0,0 +1 @@ +export {LocaleStorage} from "./LocaleStorage"; diff --git a/src/locale/tests/fixtures/storage.ts b/src/locale/tests/fixtures/storage.ts new file mode 100644 index 00000000..8876d0ca --- /dev/null +++ b/src/locale/tests/fixtures/storage.ts @@ -0,0 +1,22 @@ +import type {Language, LocaleStorageDriver} from "@typing/locale"; + +export const createLocaleStorage = (initial?: Language): LocaleStorageDriver => { + let value = initial; + const listeners = new Set<(lang: Language) => void>(); + + return { + async get() { + return value; + }, + async set(lang) { + value = lang; + for (const handler of listeners) handler(lang); + }, + watch(handler) { + listeners.add(handler); + return () => { + listeners.delete(handler); + }; + }, + }; +}; diff --git a/src/types/locale.ts b/src/types/locale.ts index 1a9c888c..f477e1a8 100644 --- a/src/types/locale.ts +++ b/src/types/locale.ts @@ -245,3 +245,14 @@ export interface LocaleProvider { export interface LocaleDynamicProvider extends LocaleProvider { change(lang: Language): Promise; } + +/** Persists one language selection independently of the application's translation catalogue. */ +export interface LocaleStorageDriver { + /** No saved selection leaves the provider's current language unchanged. */ + get(): Promise; + + set(lang: Language): Promise; + + /** Observes valid selections, including own writes; does not emit an initial value or deletions. */ + watch(handler: (lang: Language) => void): () => void; +} diff --git a/tests/integration/browser/locale/dynamic.integration.test.ts b/tests/integration/browser/locale/dynamic.integration.test.ts index 66fd2d40..54d9c4b6 100644 --- a/tests/integration/browser/locale/dynamic.integration.test.ts +++ b/tests/integration/browser/locale/dynamic.integration.test.ts @@ -113,7 +113,10 @@ test("Chrome switches DynamicLocale in both worlds and synchronizes extension st ) ).toEqual({lang: "fr", message: "Bonjour depuis DynamicLocale !"}); expect( - await evaluate(background, `(async () => (await chrome.storage.local.get('lang')).lang)()`) + await evaluate( + background, + `(async () => (await chrome.storage.local.get('adnbn:locale'))['adnbn:locale'])()` + ) ).toBeUndefined(); const state = await evaluate( background, @@ -128,7 +131,7 @@ test("Chrome switches DynamicLocale in both worlds and synchronizes extension st plural: dynamicLocale.choice('items', 0, {count: 0}), }; await saved; - return {immediate, stored: (await chrome.storage.local.get('lang')).lang}; + return {immediate, stored: (await chrome.storage.local.get('adnbn:locale'))['adnbn:locale']}; })()` ); expect(state).toEqual({ diff --git a/tests/integration/build/locale/dynamic-fixture/src/contract.ts b/tests/integration/build/locale/dynamic-fixture/src/contract.ts index 21ba3e61..05226804 100644 --- a/tests/integration/build/locale/dynamic-fixture/src/contract.ts +++ b/tests/integration/build/locale/dynamic-fixture/src/contract.ts @@ -1,4 +1,22 @@ -import {DynamicLocale, Language} from "adnbn/locale"; +import {DynamicLocale, Language, LocaleStorage, type LocaleStorageDriver} from "adnbn/locale"; +import type {LocaleProviderProps} from "adnbn/locale/react"; + +export const checkStorageContract = (storage: LocaleStorageDriver): void => { + const builtin: LocaleStorageDriver = new LocaleStorage(); + new DynamicLocale(); + new DynamicLocale(builtin); + new DynamicLocale(storage); + new DynamicLocale(false); + const read: Promise = storage.get(); + const write: Promise = storage.set(Language.French); + const stop: () => void = storage.watch(lang => { + const selected: Language = lang; + }); + const customProps: LocaleProviderProps = {storage}; + const memoryProps: LocaleProviderProps = {storage: false}; + // @ts-expect-error persisting an absent language is not part of the driver contract + storage.set(undefined); +}; export const checkContract = (locale: DynamicLocale): void => { const changed: Promise = locale.change(Language.French); diff --git a/tests/integration/build/locale/dynamic-fixture/src/panel.ts b/tests/integration/build/locale/dynamic-fixture/src/panel.ts index 59839ab0..475a4ec5 100644 --- a/tests/integration/build/locale/dynamic-fixture/src/panel.ts +++ b/tests/integration/build/locale/dynamic-fixture/src/panel.ts @@ -1,6 +1,6 @@ -import {DynamicLocale, type Language} from "adnbn/locale"; +import {DynamicLocale, type Language, type LocaleStorageDriver} from "adnbn/locale"; -export const createPanel = (context: string, storage: string | false = "lang"): HTMLElement => { +export const createPanel = (context: string, storage?: LocaleStorageDriver | false): HTMLElement => { const locale = new DynamicLocale(storage); const panel = document.createElement("div"); panel.dataset.localeContext = context; @@ -18,7 +18,7 @@ export const createPanel = (context: string, storage: string | false = "lang"): void saved.catch(console.error); }); update(); - if (storage) { + if (storage !== false) { void locale.sync().then(update).catch(console.error); const stop = locale.watch(update); window.addEventListener("pagehide", stop, {once: true}); From e7b49ad20e47fb9c8e049ac626cc80216478e2fb Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:02:01 +0300 Subject: [PATCH 25/38] refactor(locale): move generated types to LocaleRegistry --- .../declaration/LocaleDeclaration.test.ts | 201 +++--------------- .../plugins/locale/declaration/locale.d.ts | 2 +- .../tests/fixtures/custom-structure.ts | 18 ++ .../tests/fixtures/empty-registry.ts | 24 +++ .../tests/fixtures/generated-registry.ts | 68 ++++++ .../tests/fixtures/language-names.ts | 16 ++ src/locale/README.md | 2 +- src/locale/adapters/react/context.ts | 15 +- src/locale/helpers.ts | 14 +- src/locale/index.ts | 1 + src/locale/providers/DynamicLocale.ts | 4 +- src/locale/providers/NativeLocale.ts | 11 +- src/locale/providers/index.ts | 2 +- src/types/locale.ts | 3 + .../locale/dynamic-fixture/src/contract.ts | 3 +- 15 files changed, 185 insertions(+), 199 deletions(-) create mode 100644 src/cli/plugins/locale/declaration/tests/fixtures/custom-structure.ts create mode 100644 src/cli/plugins/locale/declaration/tests/fixtures/empty-registry.ts create mode 100644 src/cli/plugins/locale/declaration/tests/fixtures/generated-registry.ts create mode 100644 src/cli/plugins/locale/declaration/tests/fixtures/language-names.ts diff --git a/src/cli/plugins/locale/declaration/LocaleDeclaration.test.ts b/src/cli/plugins/locale/declaration/LocaleDeclaration.test.ts index 4b9511e2..aa275de2 100644 --- a/src/cli/plugins/locale/declaration/LocaleDeclaration.test.ts +++ b/src/cli/plugins/locale/declaration/LocaleDeclaration.test.ts @@ -4,20 +4,18 @@ import path from "path"; import ts from "typescript"; import LocaleDeclaration from "./LocaleDeclaration"; -import {ReadonlyConfig} from "@typing/config"; -import {LocaleStructure} from "@typing/locale"; +import type {ReadonlyConfig} from "@typing/config"; +import type {LocaleStructure} from "@typing/locale"; -const packageRoot = path.resolve(__dirname, "../../../../.."); +const fixtureDirectory = path.join(__dirname, "tests/fixtures"); -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 typecheck = (files: string[], declaration?: string, target: "source" | "package" = "package"): string[] => { + const config = ts.readConfigFile(path.join(ADNBN_TEST_ROOT, "tsconfig.json"), ts.sys.readFile); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, ADNBN_TEST_ROOT); + expect(config.error).toBeUndefined(); + expect(parsed.errors).toEqual([]); const options: ts.CompilerOptions = { - ...(target === "source" ? sourceOptions : {}), + ...(target === "source" ? parsed.options : {}), module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, target: ts.ScriptTarget.ESNext, @@ -27,96 +25,23 @@ const typecheck = (source: string, declaration?: string, target: "source" | "pac types: [], paths: target === "source" - ? sourceOptions.paths + ? parsed.options.paths : { - "adnbn/locale": [path.join(packageRoot, "dist/locale/index.d.ts")], - "adnbn/locale/react": [path.join(packageRoot, "dist/locale/adapters/react/index.d.ts")], + "adnbn/locale": [path.join(ADNBN_TEST_ROOT, "dist/locale/index.d.ts")], + "adnbn/locale/react": [path.join(ADNBN_TEST_ROOT, "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 rootNames = files.map(file => path.join(fixtureDirectory, file)); + if (declaration) rootNames.push(declaration); + const program = ts.createProgram(rootNames, options); + + return 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}`; + }); }; -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; @@ -140,7 +65,7 @@ describe("locale declarations", () => { expect(content).toContain('import "adnbn/locale";'); expect(content).toContain('declare module "adnbn/locale"'); - expect(content).toContain("export interface LocaleNativeStructure"); + expect(content).toContain("export interface LocaleRegistry"); expect(content).toContain('"app.title"'); expect(content).not.toContain("__LOCALE_DICTIONARY__"); expect(content).not.toContain("GeneratedNativeStructure"); @@ -156,13 +81,15 @@ describe("locale declarations", () => { 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([]); + expect( + typecheck(["language-names.ts", "empty-registry.ts", "custom-structure.ts"], undefined, target) + ).toEqual([]); }); test("keep an empty generated registry strict", () => { const declaration = generate({}); - expect(typecheck(namesUsage + emptyRegistryUsage, declaration, target)).toEqual([]); + expect(typecheck(["language-names.ts", "empty-registry.ts"], declaration, target)).toEqual([]); }); test("derive every API from the generated registry", () => { @@ -174,81 +101,7 @@ describe("locale declarations", () => { }); 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 - ) + typecheck(["language-names.ts", "custom-structure.ts", "generated-registry.ts"], declaration, target) ).toEqual([]); }); }); diff --git a/src/cli/plugins/locale/declaration/locale.d.ts b/src/cli/plugins/locale/declaration/locale.d.ts index cce32e87..f31cff8e 100644 --- a/src/cli/plugins/locale/declaration/locale.d.ts +++ b/src/cli/plugins/locale/declaration/locale.d.ts @@ -2,7 +2,7 @@ import ":package/locale"; declare module ":package/locale" { // prettier-ignore - export interface LocaleNativeStructure { + export interface LocaleRegistry { __LOCALE_DICTIONARY__ } } diff --git a/src/cli/plugins/locale/declaration/tests/fixtures/custom-structure.ts b/src/cli/plugins/locale/declaration/tests/fixtures/custom-structure.ts new file mode 100644 index 00000000..79a9a582 --- /dev/null +++ b/src/cli/plugins/locale/declaration/tests/fixtures/custom-structure.ts @@ -0,0 +1,18 @@ +import {NativeLocale, DynamicLocale} from "adnbn/locale"; +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"); diff --git a/src/cli/plugins/locale/declaration/tests/fixtures/empty-registry.ts b/src/cli/plugins/locale/declaration/tests/fixtures/empty-registry.ts new file mode 100644 index 00000000..84eb25a7 --- /dev/null +++ b/src/cli/plugins/locale/declaration/tests/fixtures/empty-registry.ts @@ -0,0 +1,24 @@ +import {NativeLocale, DynamicLocale, t, choice, key, resolve, type LocaleRegistry} from "adnbn/locale"; +import {useLocale} from "adnbn/locale/react"; + +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 LocaleRegistry = "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"); diff --git a/src/cli/plugins/locale/declaration/tests/fixtures/generated-registry.ts b/src/cli/plugins/locale/declaration/tests/fixtures/generated-registry.ts new file mode 100644 index 00000000..41ad4ebb --- /dev/null +++ b/src/cli/plugins/locale/declaration/tests/fixtures/generated-registry.ts @@ -0,0 +1,68 @@ +import {Language, NativeLocale, DynamicLocale, t, choice, key, resolve, type LocaleRegistry} from "adnbn/locale"; +import {useLocale} from "adnbn/locale/react"; + +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 LocaleRegistry = "app.title"; +// @ts-expect-error no index signature widens the registry +const arbitraryKey: keyof LocaleRegistry = "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); diff --git a/src/cli/plugins/locale/declaration/tests/fixtures/language-names.ts b/src/cli/plugins/locale/declaration/tests/fixtures/language-names.ts new file mode 100644 index 00000000..cac033c1 --- /dev/null +++ b/src/cli/plugins/locale/declaration/tests/fixtures/language-names.ts @@ -0,0 +1,16 @@ +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"); diff --git a/src/locale/README.md b/src/locale/README.md index 64a09110..24648583 100644 --- a/src/locale/README.md +++ b/src/locale/README.md @@ -23,7 +23,7 @@ description: Runtime translation providers, shared formatting, catalogue access, ## Ownership and data flow -Shared languages, contracts, and module identifiers belong to `src/types/locale.ts`. Generated `.adnbn/locale.d.ts` augments `LocaleNativeStructure` through `adnbn/locale`, defining the application's keys and required substitutions from its default language. +Shared languages, contracts, and module identifiers belong to `src/types/locale.ts`. It also declares the empty `LocaleRegistry`, re-exported through `adnbn/locale`. Generated `.adnbn/locale.d.ts` augments that registry, defining the application's keys and required substitutions from its default language for providers, helpers, and adapters. Locale discovery, merging, validation, fallback preparation, native `_locales/*/messages.json`, and declaration generation belong to the CLI locale pipeline. The locale feature plugin supplies the generated data; Rspack integration and chunk delivery belong to the bundler. Shared message-flattening logic lives in `src/shared/locale`. diff --git a/src/locale/adapters/react/context.ts b/src/locale/adapters/react/context.ts index 243a2cc9..08ef4f18 100644 --- a/src/locale/adapters/react/context.ts +++ b/src/locale/adapters/react/context.ts @@ -1,10 +1,15 @@ import {createContext, useContext} from "react"; -import {LocaleDir, Language, LocaleNonPluralKeys, LocaleSubstitutionArgs, LocalePluralKeys} from "@typing/locale"; - -import {LocaleNativeStructure} from "@locale/providers"; - -export interface LocaleContract { +import { + LocaleDir, + Language, + type LocaleNonPluralKeys, + type LocaleSubstitutionArgs, + type LocalePluralKeys, + type LocaleRegistry, +} from "@typing/locale"; + +export interface LocaleContract { lang: Language; langs: ReadonlyMap; diff --git a/src/locale/helpers.ts b/src/locale/helpers.ts index 793d997d..141d5509 100644 --- a/src/locale/helpers.ts +++ b/src/locale/helpers.ts @@ -1,6 +1,6 @@ -import {LocaleNativeStructure, NativeLocale} from "@locale/providers"; +import {NativeLocale} from "@locale/providers"; import {convertLocaleMessageKey, extractLocaleKey} from "@locale/utils"; -import {LocaleNonPluralKeys, LocalePluralKeys, LocaleSubstitutionArgs} from "@typing/locale"; +import type {LocaleNonPluralKeys, LocalePluralKeys, LocaleRegistry, LocaleSubstitutionArgs} from "@typing/locale"; /** * Translates a non-plural locale key. @@ -19,9 +19,9 @@ import {LocaleNonPluralKeys, LocalePluralKeys, LocaleSubstitutionArgs} from "@ty * @param args * @returns The translated string. */ -export const t = >( +export const t = >( key: K, - ...args: LocaleSubstitutionArgs + ...args: LocaleSubstitutionArgs ): string => { return NativeLocale.getInstance().trans(key, ...args); }; @@ -43,10 +43,10 @@ export const t = >( * @param args * @returns The translated string. */ -export const choice = >( +export const choice = >( key: K, count: number, - ...args: LocaleSubstitutionArgs + ...args: LocaleSubstitutionArgs ): string => { return NativeLocale.getInstance().choice(key, count, ...args); }; @@ -67,7 +67,7 @@ export const choice = >( * @param value - The locale key to convert. * @returns The browser message reference. */ -export const key = (value: keyof LocaleNativeStructure & string): string => { +export const key = (value: keyof LocaleRegistry & string): string => { const locale = NativeLocale.getInstance(); if (!locale.keys().has(value)) { diff --git a/src/locale/index.ts b/src/locale/index.ts index 0c9c5da6..4dc14a4d 100644 --- a/src/locale/index.ts +++ b/src/locale/index.ts @@ -7,6 +7,7 @@ export { LanguageCodes, LocaleDir, type LocaleStructure, + type LocaleRegistry, type LocaleProvider, type LocaleDynamicProvider, type LocaleStorageDriver, diff --git a/src/locale/providers/DynamicLocale.ts b/src/locale/providers/DynamicLocale.ts index 6bb30889..deb6206b 100644 --- a/src/locale/providers/DynamicLocale.ts +++ b/src/locale/providers/DynamicLocale.ts @@ -3,7 +3,6 @@ import {getI18nMessage} from "@addon-core/browser"; import catalogue, {keys, lang as defaultLanguage, languages} from "#adnbn/locale"; import AbstractLocale from "./AbstractLocale"; -import type {LocaleNativeStructure} from "./NativeLocale"; import {LocaleStorage} from "../storage"; import {convertLocaleKey, resolveLanguage} from "../utils"; @@ -11,10 +10,11 @@ import { Language, LocaleCustomKeyForLanguage, type LocaleDynamicProvider, + type LocaleRegistry, type LocaleStorageDriver, } from "@typing/locale"; -export default class DynamicLocale +export default class DynamicLocale extends AbstractLocale implements LocaleDynamicProvider { diff --git a/src/locale/providers/NativeLocale.ts b/src/locale/providers/NativeLocale.ts index b960ee76..7c191dd8 100644 --- a/src/locale/providers/NativeLocale.ts +++ b/src/locale/providers/NativeLocale.ts @@ -5,15 +5,12 @@ import AbstractLocale from "./AbstractLocale"; import {convertLocaleKey, resolveLanguage} from "@locale/utils"; -import {Language, LocaleCustomKeyForLanguage, LocaleProvider} from "@typing/locale"; +import {Language, LocaleCustomKeyForLanguage, type LocaleProvider, type LocaleRegistry} from "@typing/locale"; -/** 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; -export default class NativeLocale extends AbstractLocale { - private static instance?: LocaleProvider; - - public static getInstance(): LocaleProvider { + public static getInstance(): LocaleProvider { return (NativeLocale.instance ??= new NativeLocale()); } diff --git a/src/locale/providers/index.ts b/src/locale/providers/index.ts index 4e1efc68..481d401d 100644 --- a/src/locale/providers/index.ts +++ b/src/locale/providers/index.ts @@ -1,3 +1,3 @@ export {default as AbstractLocale} from "./AbstractLocale"; export {default as DynamicLocale} from "./DynamicLocale"; -export {default as NativeLocale, type LocaleNativeStructure} from "./NativeLocale"; +export {default as NativeLocale} from "./NativeLocale"; diff --git a/src/types/locale.ts b/src/types/locale.ts index f477e1a8..16e13806 100644 --- a/src/types/locale.ts +++ b/src/types/locale.ts @@ -200,6 +200,9 @@ export interface LocaleStructure { [key: string]: LocaleFutures; } +/** Augmented through adnbn/locale by the generated .adnbn/locale.d.ts for the current app. */ +export interface LocaleRegistry {} + export type LocaleNonPluralKeys = { [K in keyof T]: T[K] extends {plural: false} ? K : never; }[keyof T] & diff --git a/tests/integration/build/locale/dynamic-fixture/src/contract.ts b/tests/integration/build/locale/dynamic-fixture/src/contract.ts index 05226804..25865364 100644 --- a/tests/integration/build/locale/dynamic-fixture/src/contract.ts +++ b/tests/integration/build/locale/dynamic-fixture/src/contract.ts @@ -1,4 +1,4 @@ -import {DynamicLocale, Language, LocaleStorage, type LocaleStorageDriver} from "adnbn/locale"; +import {DynamicLocale, Language, LocaleStorage, type LocaleRegistry, type LocaleStorageDriver} from "adnbn/locale"; import type {LocaleProviderProps} from "adnbn/locale/react"; export const checkStorageContract = (storage: LocaleStorageDriver): void => { @@ -29,6 +29,7 @@ export const checkContract = (locale: DynamicLocale): void => { const plural: string = locale.choice("items", 2, {count: 2}); const keys: ReadonlySet<"locale" | "greeting" | "app.title" | "welcome" | "items" | "fallback" | "empty"> = locale.keys(); + const registryKeys: ReadonlySet = keys; // @ts-expect-error substitutions remain required locale.trans("welcome"); // @ts-expect-error plural keys are not ordinary messages From 3b60f5eb5333476c79ebc338d029cb727ffa633d Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:02:55 +0300 Subject: [PATCH 26/38] fix(types): include tests in the editor project --- tsconfig.json | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index 2359aa26..2e833b0a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -85,11 +85,14 @@ "./src/**/*", "./src/**/*.d.ts", "./src/cli/virtual/virtual.d.ts", - "node_modules/@types/node/*.d.ts" + "node_modules/@types/node/*.d.ts", + "tests/**/*.test.ts", + "tests/integration/*.ts", + "tests/jest*.ts" ], "exclude": [ - "**/*.test.ts", - "**/tests/**/*.ts", - "dist" + "**/tests/fixtures/**", + "dist", + "node_modules" ] } From 20016ca72d6f41c24fabdc46f832a196f1ce941c Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:21:09 +0300 Subject: [PATCH 27/38] refactor(locale): centralize shared translation helpers --- AGENTS.md | 7 +- src/cli/builders/locale/LocaleBuilder.test.ts | 2 +- src/cli/builders/locale/LocaleBuilder.ts | 17 +-- src/cli/builders/locale/index.ts | 2 +- .../GenerateModulePlugin.test.ts | 2 +- .../generate-module/GenerateModulePlugin.ts | 2 +- .../bundler/plugins/generate-module/index.ts | 2 +- src/cli/entrypoint/parser/CommandParser.ts | 2 +- src/cli/plugins/locale/Locale.test.ts | 51 +------ src/cli/plugins/locale/Locale.ts | 4 +- src/cli/plugins/locale/index.ts | 2 +- src/cli/plugins/popup/Popup.ts | 2 +- src/cli/plugins/sidebar/Sidebar.ts | 2 +- src/locale/README.md | 15 +- src/locale/adapters/react/LocaleProvider.tsx | 2 +- src/locale/helpers.ts | 2 +- src/locale/providers/AbstractLocale.ts | 137 ++---------------- src/locale/providers/CustomLocale.test.ts | 11 ++ src/locale/providers/CustomLocale.ts | 9 +- src/locale/providers/DynamicLocale.ts | 2 +- src/locale/providers/NativeLocale.test.ts | 8 + src/locale/providers/NativeLocale.ts | 2 +- src/locale/storage/LocaleStorage.test.ts | 2 +- src/locale/storage/LocaleStorage.ts | 2 +- src/locale/storage/index.ts | 2 +- src/locale/utils.ts | 69 --------- src/shared/locale/index.ts | 5 + src/shared/locale/keys.ts | 33 +++++ src/shared/locale/language.ts | 23 +++ src/shared/locale/messages.ts | 6 +- src/shared/locale/plural.ts | 113 +++++++++++++++ src/shared/locale/substitutions.ts | 49 +++++++ 32 files changed, 300 insertions(+), 289 deletions(-) delete mode 100644 src/locale/utils.ts create mode 100644 src/shared/locale/index.ts create mode 100644 src/shared/locale/keys.ts create mode 100644 src/shared/locale/language.ts create mode 100644 src/shared/locale/plural.ts create mode 100644 src/shared/locale/substitutions.ts diff --git a/AGENTS.md b/AGENTS.md index c29cfba0..5024246c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,11 @@ - Name classes after their responsibility and role, such as `LocaleFinder` or `ContentParser`. Avoid names tied only to a temporary implementation detail or former file location. - Keep private helpers and local variables concise when their surrounding scope already supplies the domain. Do not mechanically repeat a long public prefix everywhere. +## Class exports + +- When a class is the main export of a file, always export it by default. Keep the class explicitly named to match the file, for example `export default class ReactLocale` in `ReactLocale.ts`. +- When a directory's `index.ts` exposes that class, use a named re-export of its default: `export {default as ReactLocale} from "./ReactLocale";`. Consumers import the class by name from that index. + ## Public API naming - Use **verb + domain concept** for exported functions. Prefer explicit names over ambiguous shortcuts, and use the same terminology as the associated types. @@ -93,7 +98,7 @@ ## File and directory naming -- Use PascalCase for a file whose primary export is a class or React component, matching that export's name: `LocaleFinder.ts`, `BuildAssetsMapPlugin.ts`, and `DiagnosticPanel.tsx`. Prefer a named class/component export so the relationship is explicit. +- Use PascalCase for a file whose primary export is a class or React component, matching that export's name: `LocaleFinder.ts`, `BuildAssetsMapPlugin.ts`, and `DiagnosticPanel.tsx`. Prefer a named export for function components; primary classes follow the default-export rule above. - Name tests for a specific class after that class, preserving PascalCase and adding the test suffix: `BuildAssetsMapPlugin.test.ts`, `ChunkLoaderPlugin.test.ts`, and `ContentManager.test.ts`. - Use lowercase kebab-case for other multiword filenames, including helper modules, types, enums, configuration modules, and tests that are not dedicated to a class: `file-precedence.ts`, `entrypoint-assets.ts`, and `entrypoint-assets.integration.test.ts`. - Use lowercase kebab-case for multiword directory names, including directories containing classes or React components: `entrypoint-assets` and `diagnostic-panel`. diff --git a/src/cli/builders/locale/LocaleBuilder.test.ts b/src/cli/builders/locale/LocaleBuilder.test.ts index 00adfcb0..5f8e662c 100644 --- a/src/cli/builders/locale/LocaleBuilder.test.ts +++ b/src/cli/builders/locale/LocaleBuilder.test.ts @@ -59,7 +59,7 @@ describe("LocaleBuilder", () => { test("normalizes substitution names in locale structure", () => { const builder = new LocaleBuilder(Browser.Chrome, Language.English).merge({ - greeting: "Hello {{ name }} {{name}} {{ count }}", + greeting: "Hello {{ name }} {{name}} {{ count }} {{}} {{ }} {{broken} {broken}}", cars: ["{{count}} car", "{{count}} cars"], }); diff --git a/src/cli/builders/locale/LocaleBuilder.ts b/src/cli/builders/locale/LocaleBuilder.ts index ffed7aea..6a1edc44 100644 --- a/src/cli/builders/locale/LocaleBuilder.ts +++ b/src/cli/builders/locale/LocaleBuilder.ts @@ -1,6 +1,6 @@ import _ from "lodash"; -import {convertLocaleKey} from "@locale/utils"; +import {convertLocaleKey, parsePlaceholders} from "@shared/locale"; import {LocaleError} from "./LocaleValidator"; @@ -74,19 +74,6 @@ export default class LocaleBuilder implements LocaleBuilderContract { } public structure(): LocaleStructure { - const substitutions = (value: string): string[] => { - const pattern = /{{([^{}]+)}}/g; - const substitutions: string[] = []; - - let match: RegExpExecArray | null; - - while ((match = pattern.exec(value)) !== null) { - substitutions.push(match[1].trim()); - } - - return _.uniq(substitutions.filter(Boolean)).sort(); - }; - return this.get() .entries() .reduce( @@ -94,7 +81,7 @@ export default class LocaleBuilder implements LocaleBuilderContract { ...structure, [key]: { plural: value.includes(LocaleValuesSeparator), - substitutions: substitutions(value), + substitutions: _.uniq(parsePlaceholders(value).map(placeholder => placeholder.name)).sort(), }, }), {} as LocaleStructure diff --git a/src/cli/builders/locale/index.ts b/src/cli/builders/locale/index.ts index cfa4cfe7..43f374a1 100644 --- a/src/cli/builders/locale/index.ts +++ b/src/cli/builders/locale/index.ts @@ -4,7 +4,7 @@ import OperaLocaleValidator from "./OperaLocaleValidator"; import FirefoxLocaleValidator from "./FirefoxLocaleValidator"; import LocaleStructureValidator from "./LocaleStructureValidator"; -import {extractLocaleKey} from "@locale/utils"; +import {extractLocaleKey} from "@shared/locale"; import {Language, LocaleBuilder as LocaleBuilderContract} from "@typing/locale"; import {ReadonlyConfig} from "@typing/config"; diff --git a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts index 0035058f..19c002c8 100644 --- a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts +++ b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts @@ -5,7 +5,7 @@ import os from "os"; import path from "path"; import vm from "vm"; import {rspack, type Compiler, type NormalModule, type Stats} from "@rspack/core"; -import {GenerateModulePlugin} from "./GenerateModulePlugin"; +import {GenerateModulePlugin} from "./index"; const fixtures = path.join(__dirname, "tests/fixtures"); const modules = { diff --git a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts index da4d364d..74b3436d 100644 --- a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts +++ b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts @@ -9,7 +9,7 @@ export type GenerateModulePluginModules = Record; /** Returns source updates for the registered modules. */ export type GenerateModulePluginUpdate = () => Promise; -export class GenerateModulePlugin { +export default class GenerateModulePlugin { private readonly pluginName = "GenerateModulePlugin"; private update?: GenerateModulePluginUpdate; private files: readonly string[] = []; diff --git a/src/cli/bundler/plugins/generate-module/index.ts b/src/cli/bundler/plugins/generate-module/index.ts index 8cf1f8ed..0f0b1f37 100644 --- a/src/cli/bundler/plugins/generate-module/index.ts +++ b/src/cli/bundler/plugins/generate-module/index.ts @@ -1,5 +1,5 @@ export { - GenerateModulePlugin, + default as GenerateModulePlugin, type GenerateModulePluginModules, type GenerateModulePluginUpdate, } from "./GenerateModulePlugin"; diff --git a/src/cli/entrypoint/parser/CommandParser.ts b/src/cli/entrypoint/parser/CommandParser.ts index fdaf8fbe..e5440901 100644 --- a/src/cli/entrypoint/parser/CommandParser.ts +++ b/src/cli/entrypoint/parser/CommandParser.ts @@ -2,7 +2,7 @@ import {z} from "zod"; import BackgroundParser from "./BackgroundParser"; -import {modifyLocaleMessageKey} from "@locale/utils"; +import {modifyLocaleMessageKey} from "@shared/locale"; import {CommandEntrypointOptions, CommandExecuteActionName} from "@typing/command"; import {EntrypointFile} from "@typing/entrypoint"; diff --git a/src/cli/plugins/locale/Locale.test.ts b/src/cli/plugins/locale/Locale.test.ts index c12d99f6..386dfbbc 100644 --- a/src/cli/plugins/locale/Locale.test.ts +++ b/src/cli/plugins/locale/Locale.test.ts @@ -1,24 +1,14 @@ -jest.mock("@addon-core/browser", () => ({ - getI18nMessage: jest.fn(() => "de"), -})); -jest.mock("#adnbn/locale", () => ({__esModule: true, default: {}, keys: [], languages: []})); - import fs from "fs"; import os from "os"; import path from "path"; -import {getI18nMessage} from "@addon-core/browser"; -import catalogue from "#adnbn/locale"; 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"; -import {flattenLocaleMessages} from "@shared/locale/messages"; +import {flattenLocaleMessages, getLocaleFilename} from "@shared/locale"; const fixtures = path.resolve(__dirname, "tests/fixtures/completion"); @@ -63,23 +53,13 @@ const messages = (json: GenerateJsonPluginData, lang: Language): 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(); - for (const lang of Object.keys(catalogue)) delete catalogue[lang as Language]; - - 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}); } @@ -253,33 +233,4 @@ describe("locale JSON completion", () => { locale.clear(); expect((await locale.catalogue()).fr!.cart_items).toBe("{{count}} article|{{count}} articles"); }); - - test("dynamic selection uses the generated default rather than a third native language", async () => { - const locale = makeLocale("single"); - const json = await locale.json(); - Object.assign(catalogue, await locale.catalogue()); - const german = messages(json, Language.German); - jest.mocked(getI18nMessage).mockImplementation(key => german[key]?.message ?? ""); - Object.defineProperty(globalThis, "fetch", { - configurable: true, - value: jest.fn(() => { - throw new Error("Translations must come from the catalogue"); - }), - }); - 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).not.toHaveBeenCalled(); - }); }); diff --git a/src/cli/plugins/locale/Locale.ts b/src/cli/plugins/locale/Locale.ts index f52f1646..ec3bc2f7 100644 --- a/src/cli/plugins/locale/Locale.ts +++ b/src/cli/plugins/locale/Locale.ts @@ -1,9 +1,7 @@ -import {getLocaleFilename} from "@locale/utils"; - import {LocaleFinder} from "@cli/entrypoint"; import {GenerateJsonPluginData} from "@cli/bundler"; -import {flattenLocaleMessages} from "@shared/locale/messages"; +import {flattenLocaleMessages, getLocaleFilename} from "@shared/locale"; import type {Language, LocaleCatalogue, LocaleMessages} from "@typing/locale"; diff --git a/src/cli/plugins/locale/index.ts b/src/cli/plugins/locale/index.ts index 13dcd806..f47ea899 100644 --- a/src/cli/plugins/locale/index.ts +++ b/src/cli/plugins/locale/index.ts @@ -4,7 +4,7 @@ import type {Configuration as RspackConfig} from "@rspack/core"; import {definePlugin} from "@main/plugin"; import {GenerateJsonPlugin, GenerateModulePlugin} from "@cli/bundler"; import {getContentLayer} from "@cli/bundler/utils/layers"; -import {extractLocaleKey, modifyLocaleMessageKey} from "@locale/utils"; +import {extractLocaleKey, modifyLocaleMessageKey} from "@shared/locale"; import Locale from "./Locale"; import {LocaleDeclaration} from "./declaration"; diff --git a/src/cli/plugins/popup/Popup.ts b/src/cli/plugins/popup/Popup.ts index a0487642..2f276f08 100644 --- a/src/cli/plugins/popup/Popup.ts +++ b/src/cli/plugins/popup/Popup.ts @@ -1,6 +1,6 @@ import {View} from "../view"; -import {modifyLocaleMessageKey} from "@locale/utils"; +import {modifyLocaleMessageKey} from "@shared/locale"; import {PopupFinder} from "@cli/entrypoint"; diff --git a/src/cli/plugins/sidebar/Sidebar.ts b/src/cli/plugins/sidebar/Sidebar.ts index 9d74fb15..6e48d5ab 100644 --- a/src/cli/plugins/sidebar/Sidebar.ts +++ b/src/cli/plugins/sidebar/Sidebar.ts @@ -1,6 +1,6 @@ import {View} from "../view"; -import {modifyLocaleMessageKey} from "@locale/utils"; +import {modifyLocaleMessageKey} from "@shared/locale"; import {SidebarFinder} from "@cli/entrypoint"; diff --git a/src/locale/README.md b/src/locale/README.md index 24648583..6b322a9c 100644 --- a/src/locale/README.md +++ b/src/locale/README.md @@ -10,12 +10,11 @@ description: Runtime translation providers, shared formatting, catalogue access, | Area | Responsibility | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `providers/AbstractLocale.ts` | Common string rendering, plural rules, missing-message diagnostics, and language names. | +| `providers/AbstractLocale.ts` | Common string rendering, missing-message diagnostics, and language names. | | `providers/NativeLocale.ts` | Browser i18n message lookup and a singleton used by the public helpers. | | `providers/DynamicLocale.ts` | Compiled translations, language state per instance, and optional storage synchronization. | | `providers/CustomLocale.ts` | Internal provider for an explicitly supplied language and flat message dictionary; not re-exported by the public entrypoint. | | `helpers.ts` | Native shortcuts: `t`, `choice`, `key` for browser message references, and `resolve` for strings prefixed with `@`. | -| `utils.ts` | Key conversion, locale markers, language resolution, and text direction. | | `catalogue/` | Empty package fallback and typing for the generated data module. | | `storage/` | Built-in `LocaleStorage` implementation of the public `LocaleStorageDriver` contract. | | `adapters/react/` | `LocaleProvider`, its context, and `useLocale`; connects dynamic localization to React state and DOM `lang`/`dir` attributes. | @@ -25,7 +24,17 @@ description: Runtime translation providers, shared formatting, catalogue access, Shared languages, contracts, and module identifiers belong to `src/types/locale.ts`. It also declares the empty `LocaleRegistry`, re-exported through `adnbn/locale`. Generated `.adnbn/locale.d.ts` augments that registry, defining the application's keys and required substitutions from its default language for providers, helpers, and adapters. -Locale discovery, merging, validation, fallback preparation, native `_locales/*/messages.json`, and declaration generation belong to the CLI locale pipeline. The locale feature plugin supplies the generated data; Rspack integration and chunk delivery belong to the bundler. Shared message-flattening logic lives in `src/shared/locale`. +Locale discovery, merging, validation, fallback preparation, native `_locales/*/messages.json`, and declaration generation belong to the CLI locale pipeline. The locale feature plugin supplies the generated data; Rspack integration and chunk delivery belong to the bundler. + +Common algorithms live in `src/shared/locale`. Its `index.ts` re-exports the helpers; CLI, providers, and adapters import them from `@shared/locale`: + +- `keys.ts`: key conversion and locale markers. +- `language.ts`: language resolution and text direction. +- `messages.ts`: flattening browser messages into a string dictionary and `getLocaleFilename` for native message paths. +- `plural.ts`: plural rules and form selection through `selectPluralForm`. +- `substitutions.ts`: `parsePlaceholders` finds placeholder names and positions; `applySubstitutions` inserts values into message parts, preserving their types. + +These modules depend only on shared contracts and have no browser, DOM, React, storage, or CLI dependencies. Consumers supply missing-substitution diagnostics; React node rendering stays inside the React adapter. During extension builds, the private `#adnbn/locale` import resolves to `virtual/locale`: a default catalogue export plus named `keys`, `languages`, and `lang` (the configured default language). `DynamicLocale` reads the catalogue; `NativeLocale` imports only the named lists, allowing unused translations to be removed from optimized bundles. Outside these builds, `catalogue/` provides a resolvable empty module with `lang: "en"`. diff --git a/src/locale/adapters/react/LocaleProvider.tsx b/src/locale/adapters/react/LocaleProvider.tsx index 18af1948..c02603a2 100644 --- a/src/locale/adapters/react/LocaleProvider.tsx +++ b/src/locale/adapters/react/LocaleProvider.tsx @@ -2,7 +2,7 @@ import React, {PropsWithChildren, useCallback, useEffect, useMemo, useState} fro import {LocaleContext, LocaleContract} from "./context"; -import {getLocaleDir, isLocaleRtl} from "@locale/utils"; +import {getLocaleDir, isLocaleRtl} from "@shared/locale"; import {DynamicLocale} from "@locale/providers"; diff --git a/src/locale/helpers.ts b/src/locale/helpers.ts index 141d5509..35eab270 100644 --- a/src/locale/helpers.ts +++ b/src/locale/helpers.ts @@ -1,5 +1,5 @@ import {NativeLocale} from "@locale/providers"; -import {convertLocaleMessageKey, extractLocaleKey} from "@locale/utils"; +import {convertLocaleMessageKey, extractLocaleKey} from "@shared/locale"; import type {LocaleNonPluralKeys, LocalePluralKeys, LocaleRegistry, LocaleSubstitutionArgs} from "@typing/locale"; /** diff --git a/src/locale/providers/AbstractLocale.ts b/src/locale/providers/AbstractLocale.ts index fb4bebf7..1c78f23d 100644 --- a/src/locale/providers/AbstractLocale.ts +++ b/src/locale/providers/AbstractLocale.ts @@ -1,3 +1,5 @@ +import {selectPluralForm, applySubstitutions} from "@shared/locale"; + import { Language, LanguageNames, @@ -7,10 +9,9 @@ import { LocaleStructure, LocaleSubstitutionArgs, LocaleSubstitutionValue, - LocaleValuesSeparator, } from "@typing/locale"; -export default abstract class implements LocaleProvider { +export default abstract class AbstractLocale implements LocaleProvider { public abstract lang(): Language; public abstract keys(): Set; @@ -32,11 +33,9 @@ export default abstract class implements Loc public choice>(key: K, count: number, ...args: LocaleSubstitutionArgs): string { const [substitutions] = args; - const parts = this.get(key, substitutions).split(LocaleValuesSeparator); - - const idx = this.getPluralIndex(count); + const message = selectPluralForm(this.get(key), this.lang(), count); - return parts[idx] ?? parts[0] ?? (key as string); + return this.substitute(message, key, substitutions); } public get(key: K, substitutions?: Record): string { @@ -48,128 +47,12 @@ export default abstract class implements Loc return key as string; } - if (substitutions) { - return template.replace(/{{(.*?)}}/g, (_, placeholder: string) => { - const substitution = placeholder.trim(); - - if (substitution in substitutions) { - return substitutions[substitution]!.toString(); - } - - console.warn( - `Locale substitution "${substitution}" not found for key "${key}" in "${this.lang()}" language.` - ); - - return substitution; - }); - } - - return template; + return this.substitute(template, key, substitutions); } - private getPluralIndex(count: number): number { - switch (this.lang()) { - case Language.Indonesian: - case Language.Japanese: - case Language.Kannada: - case Language.Korean: - case Language.Malay: - case Language.Thai: - case Language.Turkish: - case Language.Vietnamese: - return 0; - - case Language.Bengali: - case Language.Bulgarian: - case Language.Catalan: - case Language.Danish: - case Language.German: - case Language.Greek: - case Language.English: - case Language.EnglishAustralia: - case Language.EnglishGreatBritain: - case Language.EnglishUSA: - case Language.Spanish: - case Language.SpanishLatinAmericaAndCaribbean: - case Language.Estonian: - case Language.Persian: - case Language.Finnish: - case Language.Gujarati: - case Language.Hebrew: - case Language.Hungarian: - case Language.Italian: - case Language.Malayalam: - case Language.Marathi: - case Language.Dutch: - case Language.Norwegian: - case Language.Swedish: - case Language.Swahili: - case Language.Tamil: - case Language.Telugu: - case Language.ChineseChina: - case Language.ChineseTaiwan: - case Language.PortugueseBrazil: - case Language.PortuguesePortugal: - return count == 1 ? 0 : 1; - - case Language.Amharic: - case Language.Filipino: - case Language.French: - case Language.Hindi: - return count == 0 || count == 1 ? 0 : 1; - - case Language.Croatian: - case Language.Russian: - case Language.Serbian: - case Language.Ukrainian: - return count % 10 == 1 && count % 100 != 11 - ? 0 - : count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 10 || count % 100 >= 20) - ? 1 - : 2; - - case Language.Czech: - case Language.Slovak: - return count == 1 ? 0 : count >= 2 && count <= 4 ? 1 : 2; - - case Language.Lithuanian: - return count % 10 == 1 && count % 100 != 11 - ? 0 - : count % 10 >= 2 && (count % 100 < 10 || count % 100 >= 20) - ? 1 - : 2; - - case Language.Slovenian: - return count % 100 == 1 ? 0 : count % 100 == 2 ? 1 : count % 100 == 3 || count % 100 == 4 ? 2 : 3; - - case Language.Latvian: - return count == 0 ? 0 : count % 10 == 1 && count % 100 != 11 ? 1 : 2; - - case Language.Polish: - return count == 1 - ? 0 - : count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 12 || count % 100 > 14) - ? 1 - : 2; - - case Language.Romanian: - return count == 1 ? 0 : count == 0 || (count % 100 > 0 && count % 100 < 20) ? 1 : 2; - - case Language.Arabic: - return count == 0 - ? 0 - : count == 1 - ? 1 - : count == 2 - ? 2 - : count % 100 >= 3 && count % 100 <= 10 - ? 3 - : count % 100 >= 11 && count % 100 <= 99 - ? 4 - : 5; - - default: - return 0; - } + private substitute(message: string, key: string, substitutions?: Record): string { + return applySubstitutions(message, substitutions, name => { + console.warn(`Locale substitution "${name}" not found for key "${key}" in "${this.lang()}" language.`); + }).join(""); } } diff --git a/src/locale/providers/CustomLocale.test.ts b/src/locale/providers/CustomLocale.test.ts index 5d112c9d..49a9ff4b 100644 --- a/src/locale/providers/CustomLocale.test.ts +++ b/src/locale/providers/CustomLocale.test.ts @@ -50,6 +50,12 @@ describe("CustomLocale", () => { }); describe("trans()", () => { + test("leaves malformed and empty placeholders as text", () => { + locale.setData({literal: "{{ name }} {{}} {{ }} {{broken} {broken}}"}); + expect(locale.get("literal", {name: "Ada"})).toBe("Ada {{}} {{ }} {{broken} {broken}}"); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + test("returned the correct message if key exists and is non-empty", () => { expect(locale.trans("title" as never)).toBe("Adnbn"); }); @@ -80,6 +86,11 @@ describe("CustomLocale", () => { describe("choice()", () => { const key = "car" as never; + test("selects the form before substituting values containing the plural separator", () => { + locale.setData({car: "{{count}} car|{{count}} cars"}); + expect(locale.choice(key, 2, {count: "one|two"})).toBe("one|two cars"); + }); + test("languages with 1 plural forms", () => { const arr = ["車"]; const messages = {[key]: arr.join(LocaleValuesSeparator)}; diff --git a/src/locale/providers/CustomLocale.ts b/src/locale/providers/CustomLocale.ts index 390b341c..d7469f51 100644 --- a/src/locale/providers/CustomLocale.ts +++ b/src/locale/providers/CustomLocale.ts @@ -1,12 +1,12 @@ -import {convertLocaleKey} from "../utils"; +import {convertLocaleKey} from "@shared/locale"; import AbstractLocale from "./AbstractLocale"; import {Language, LocaleStructure} from "@typing/locale"; -export type CustomLocaleData = Record; +export type CustomLocaleData = Readonly>; -export default class extends AbstractLocale { +export default class CustomLocale extends AbstractLocale { constructor( protected language: Language = Language.English, protected data: CustomLocaleData = {} @@ -39,7 +39,8 @@ export default class extends AbstractLocale< } protected value(key: string): string | undefined { - const value = this.data[convertLocaleKey(key)]; + const name = convertLocaleKey(key); + const value = Object.hasOwn(this.data, name) ? this.data[name] : undefined; if (value === undefined) { return undefined; diff --git a/src/locale/providers/DynamicLocale.ts b/src/locale/providers/DynamicLocale.ts index deb6206b..31fce895 100644 --- a/src/locale/providers/DynamicLocale.ts +++ b/src/locale/providers/DynamicLocale.ts @@ -5,7 +5,7 @@ import catalogue, {keys, lang as defaultLanguage, languages} from "#adnbn/locale import AbstractLocale from "./AbstractLocale"; import {LocaleStorage} from "../storage"; -import {convertLocaleKey, resolveLanguage} from "../utils"; +import {convertLocaleKey, resolveLanguage} from "@shared/locale"; import { Language, LocaleCustomKeyForLanguage, diff --git a/src/locale/providers/NativeLocale.test.ts b/src/locale/providers/NativeLocale.test.ts index 64ee6851..a057b025 100644 --- a/src/locale/providers/NativeLocale.test.ts +++ b/src/locale/providers/NativeLocale.test.ts @@ -59,6 +59,14 @@ describe("NativeLocale", () => { expect(warn).not.toHaveBeenCalled(); }); + test("can translate a known message even when the language marker is missing", () => { + jest.mocked(getI18nMessage).mockImplementation(key => (key === "locale" ? "" : "Hello {{name}}")); + const withoutMarker = new NativeLocale(); + + expect(withoutMarker.trans("demo.title")).toBe("Hello {{name}}"); + expect(withoutMarker.get("demo.title", {name: "Ada"})).toBe("Hello Ada"); + }); + test("does not treat an undefined API result as a valid empty translation", () => { jest.mocked(getI18nMessage).mockReturnValue(undefined); diff --git a/src/locale/providers/NativeLocale.ts b/src/locale/providers/NativeLocale.ts index 7c191dd8..2521a1ff 100644 --- a/src/locale/providers/NativeLocale.ts +++ b/src/locale/providers/NativeLocale.ts @@ -3,7 +3,7 @@ import {keys, languages} from "#adnbn/locale"; import AbstractLocale from "./AbstractLocale"; -import {convertLocaleKey, resolveLanguage} from "@locale/utils"; +import {convertLocaleKey, resolveLanguage} from "@shared/locale"; import {Language, LocaleCustomKeyForLanguage, type LocaleProvider, type LocaleRegistry} from "@typing/locale"; diff --git a/src/locale/storage/LocaleStorage.test.ts b/src/locale/storage/LocaleStorage.test.ts index 622cf460..2a310142 100644 --- a/src/locale/storage/LocaleStorage.test.ts +++ b/src/locale/storage/LocaleStorage.test.ts @@ -1,7 +1,7 @@ jest.mock("@addon-core/storage", () => ({Storage: {Local: jest.fn()}})); import {Storage, type StorageProvider} from "@addon-core/storage"; -import {LocaleStorage} from "./LocaleStorage"; +import {LocaleStorage} from "./index"; import {Language} from "@typing/locale"; type Watcher = {locale?: (value: unknown) => void}; diff --git a/src/locale/storage/LocaleStorage.ts b/src/locale/storage/LocaleStorage.ts index 5e3b91d9..e53d7603 100644 --- a/src/locale/storage/LocaleStorage.ts +++ b/src/locale/storage/LocaleStorage.ts @@ -4,7 +4,7 @@ import {PackageName} from "@typing/app"; import {Language, LanguageCodes, type LocaleStorageDriver} from "@typing/locale"; /** Stores the selected language in extension local storage under the framework namespace. */ -export class LocaleStorage implements LocaleStorageDriver { +export default class LocaleStorage implements LocaleStorageDriver { private readonly storage = Storage.Local>({namespace: PackageName}); constructor(protected key: string = "locale") {} diff --git a/src/locale/storage/index.ts b/src/locale/storage/index.ts index 0e2a1944..64941301 100644 --- a/src/locale/storage/index.ts +++ b/src/locale/storage/index.ts @@ -1 +1 @@ -export {LocaleStorage} from "./LocaleStorage"; +export {default as LocaleStorage} from "./LocaleStorage"; diff --git a/src/locale/utils.ts b/src/locale/utils.ts deleted file mode 100644 index 148fe465..00000000 --- a/src/locale/utils.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - Language, - LanguageCodes, - LocaleDir, - LocaleKeyMarker, - LocaleKeysSeparator, - LocaleNestedKeysSeparator, - RtlLanguages, -} from "@typing/locale"; - -export {flattenLocaleMessages} from "@shared/locale/messages"; - -export const getLocaleFilename = (lang: Language): string => { - return `_locales/${lang}/messages.json`; -}; - -export const convertLocaleKey = (key: string): string => { - return key.replaceAll(LocaleNestedKeysSeparator, LocaleKeysSeparator); -}; - -export const convertLocaleMessageKey = (key: string): string => { - return `__MSG_${convertLocaleKey(key)}__`; -}; - -export const extractLocaleKey = (key?: string): string | undefined => { - if (isLocaleKey(key)) { - return key.substring(1); - } -}; - -export const isLocaleKey = (key?: string): key is string => { - return key?.startsWith(LocaleKeyMarker) ?? false; -}; - -export const modifyLocaleMessageKey = (key?: string): string | undefined => { - if (typeof key !== "string") { - return undefined; - } - - const localeKey = extractLocaleKey(key); - - if (localeKey) { - return convertLocaleMessageKey(localeKey); - } - - return key; -}; - -export const isLocaleRtl = (lang: Language): boolean => { - return RtlLanguages.has(lang); -}; - -export const getLocaleDir = (lang: Language): LocaleDir => { - return isLocaleRtl(lang) ? LocaleDir.RightToLeft : LocaleDir.LeftToRight; -}; - -export const resolveLanguage = (language?: string): Language | undefined => { - if (!language) { - return undefined; - } - - if (LanguageCodes.has(language as Language)) { - return language as Language; - } - - const shortLang = language.slice(0, 2) as Language; - - return LanguageCodes.has(shortLang) ? shortLang : undefined; -}; diff --git a/src/shared/locale/index.ts b/src/shared/locale/index.ts new file mode 100644 index 00000000..b3cb55d2 --- /dev/null +++ b/src/shared/locale/index.ts @@ -0,0 +1,5 @@ +export {convertLocaleKey, convertLocaleMessageKey, extractLocaleKey, isLocaleKey, modifyLocaleMessageKey} from "./keys"; +export {getLocaleDir, isLocaleRtl, resolveLanguage} from "./language"; +export {flattenLocaleMessages, getLocaleFilename} from "./messages"; +export {selectPluralForm} from "./plural"; +export {applySubstitutions, parsePlaceholders} from "./substitutions"; diff --git a/src/shared/locale/keys.ts b/src/shared/locale/keys.ts new file mode 100644 index 00000000..057ba7d4 --- /dev/null +++ b/src/shared/locale/keys.ts @@ -0,0 +1,33 @@ +import {LocaleKeyMarker, LocaleKeysSeparator, LocaleNestedKeysSeparator} from "@typing/locale"; + +export const convertLocaleKey = (key: string): string => { + return key.replaceAll(LocaleNestedKeysSeparator, LocaleKeysSeparator); +}; + +export const convertLocaleMessageKey = (key: string): string => { + return `__MSG_${convertLocaleKey(key)}__`; +}; + +export const extractLocaleKey = (key?: string): string | undefined => { + if (isLocaleKey(key)) { + return key.substring(1); + } +}; + +export const isLocaleKey = (key?: string): key is string => { + return key?.startsWith(LocaleKeyMarker) ?? false; +}; + +export const modifyLocaleMessageKey = (key?: string): string | undefined => { + if (typeof key !== "string") { + return undefined; + } + + const localeKey = extractLocaleKey(key); + + if (localeKey) { + return convertLocaleMessageKey(localeKey); + } + + return key; +}; diff --git a/src/shared/locale/language.ts b/src/shared/locale/language.ts new file mode 100644 index 00000000..9a5fb1d9 --- /dev/null +++ b/src/shared/locale/language.ts @@ -0,0 +1,23 @@ +import {Language, LanguageCodes, LocaleDir, RtlLanguages} from "@typing/locale"; + +export const isLocaleRtl = (lang: Language): boolean => { + return RtlLanguages.has(lang); +}; + +export const getLocaleDir = (lang: Language): LocaleDir => { + return isLocaleRtl(lang) ? LocaleDir.RightToLeft : LocaleDir.LeftToRight; +}; + +export const resolveLanguage = (language?: string): Language | undefined => { + if (!language) { + return undefined; + } + + if (LanguageCodes.has(language as Language)) { + return language as Language; + } + + const shortLang = language.slice(0, 2) as Language; + + return LanguageCodes.has(shortLang) ? shortLang : undefined; +}; diff --git a/src/shared/locale/messages.ts b/src/shared/locale/messages.ts index 132d1a23..48c992b6 100644 --- a/src/shared/locale/messages.ts +++ b/src/shared/locale/messages.ts @@ -1,4 +1,8 @@ -import type {LocaleMessages} from "@typing/locale"; +import type {Language, LocaleMessages} from "@typing/locale"; + +export const getLocaleFilename = (lang: Language): string => { + return `_locales/${lang}/messages.json`; +}; export const flattenLocaleMessages = (messages: LocaleMessages): Record => Object.fromEntries(Object.entries(messages).map(([key, value]) => [key, value.message])); diff --git a/src/shared/locale/plural.ts b/src/shared/locale/plural.ts new file mode 100644 index 00000000..8cee2cec --- /dev/null +++ b/src/shared/locale/plural.ts @@ -0,0 +1,113 @@ +import {Language, LocaleValuesSeparator} from "@typing/locale"; + +export const selectPluralForm = (message: string, lang: Language, count: number): string => { + const parts = message.split(LocaleValuesSeparator); + + return parts[getPluralIndex(lang, count)] ?? parts[0] ?? message; +}; + +const getPluralIndex = (lang: Language, count: number): number => { + switch (lang) { + case Language.Indonesian: + case Language.Japanese: + case Language.Kannada: + case Language.Korean: + case Language.Malay: + case Language.Thai: + case Language.Turkish: + case Language.Vietnamese: + return 0; + + case Language.Bengali: + case Language.Bulgarian: + case Language.Catalan: + case Language.Danish: + case Language.German: + case Language.Greek: + case Language.English: + case Language.EnglishAustralia: + case Language.EnglishGreatBritain: + case Language.EnglishUSA: + case Language.Spanish: + case Language.SpanishLatinAmericaAndCaribbean: + case Language.Estonian: + case Language.Persian: + case Language.Finnish: + case Language.Gujarati: + case Language.Hebrew: + case Language.Hungarian: + case Language.Italian: + case Language.Malayalam: + case Language.Marathi: + case Language.Dutch: + case Language.Norwegian: + case Language.Swedish: + case Language.Swahili: + case Language.Tamil: + case Language.Telugu: + case Language.ChineseChina: + case Language.ChineseTaiwan: + case Language.PortugueseBrazil: + case Language.PortuguesePortugal: + return count == 1 ? 0 : 1; + + case Language.Amharic: + case Language.Filipino: + case Language.French: + case Language.Hindi: + return count == 0 || count == 1 ? 0 : 1; + + case Language.Croatian: + case Language.Russian: + case Language.Serbian: + case Language.Ukrainian: + return count % 10 == 1 && count % 100 != 11 + ? 0 + : count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 10 || count % 100 >= 20) + ? 1 + : 2; + + case Language.Czech: + case Language.Slovak: + return count == 1 ? 0 : count >= 2 && count <= 4 ? 1 : 2; + + case Language.Lithuanian: + return count % 10 == 1 && count % 100 != 11 + ? 0 + : count % 10 >= 2 && (count % 100 < 10 || count % 100 >= 20) + ? 1 + : 2; + + case Language.Slovenian: + return count % 100 == 1 ? 0 : count % 100 == 2 ? 1 : count % 100 == 3 || count % 100 == 4 ? 2 : 3; + + case Language.Latvian: + return count == 0 ? 0 : count % 10 == 1 && count % 100 != 11 ? 1 : 2; + + case Language.Polish: + return count == 1 + ? 0 + : count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 12 || count % 100 > 14) + ? 1 + : 2; + + case Language.Romanian: + return count == 1 ? 0 : count == 0 || (count % 100 > 0 && count % 100 < 20) ? 1 : 2; + + case Language.Arabic: + return count == 0 + ? 0 + : count == 1 + ? 1 + : count == 2 + ? 2 + : count % 100 >= 3 && count % 100 <= 10 + ? 3 + : count % 100 >= 11 && count % 100 <= 99 + ? 4 + : 5; + + default: + return 0; + } +}; diff --git a/src/shared/locale/substitutions.ts b/src/shared/locale/substitutions.ts new file mode 100644 index 00000000..4732c565 --- /dev/null +++ b/src/shared/locale/substitutions.ts @@ -0,0 +1,49 @@ +interface LocalePlaceholder { + name: string; + start: number; + end: number; +} + +/** Empty names and malformed placeholders remain literal message text. */ +export const parsePlaceholders = (message: string): LocalePlaceholder[] => { + const placeholders: LocalePlaceholder[] = []; + + for (const match of message.matchAll(/{{([^{}]+)}}/g)) { + const name = match[1].trim(); + + if (name) { + placeholders.push({name, start: match.index, end: match.index + match[0].length}); + } + } + + return placeholders; +}; + +/** Keeps supplied values intact so each adapter can render them in its own format. */ +export const applySubstitutions = ( + message: string, + substitutions: Record | undefined, + onMissing: (name: string) => void +): (string | T)[] => { + if (!substitutions) return [message]; + + const parts: (string | T)[] = []; + let offset = 0; + + for (const placeholder of parsePlaceholders(message)) { + parts.push(message.slice(offset, placeholder.start)); + + if (Object.prototype.hasOwnProperty.call(substitutions, placeholder.name)) { + parts.push(substitutions[placeholder.name]); + } else { + onMissing(placeholder.name); + parts.push(placeholder.name); + } + + offset = placeholder.end; + } + + parts.push(message.slice(offset)); + + return parts; +}; From 5160519f37ffe02e48ef7a7bf8da4ef69b10f744 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:25:49 +0300 Subject: [PATCH 28/38] fix(locale): refresh generated translations reliably in watch mode --- .../generate-json/GenerateJsonPlugin.test.ts | 66 ++++++++++++++++-- .../generate-json/GenerateJsonPlugin.ts | 46 ++++++++++++- .../GenerateModulePlugin.test.ts | 39 ++++++++++- .../generate-module/GenerateModulePlugin.ts | 27 ++++++-- .../bundler/plugins/generate-module/index.ts | 2 + src/cli/bundler/plugins/index.ts | 2 + src/cli/bundler/plugins/utils/index.ts | 1 + .../plugins/utils/watch-compilation.ts | 20 ++++++ src/cli/bundler/plugins/watch/WatchPlugin.ts | 13 +--- src/cli/plugins/locale/Locale.ts | 18 ++++- src/cli/plugins/locale/index.ts | 43 ++++++------ src/cli/plugins/locale/module.ts | 3 + src/locale/README.md | 6 +- src/locale/catalogue/catalogue.ts | 2 +- src/types/locale.ts | 4 -- .../locale/chunks-fixture/adnbn.config.ts | 6 -- .../locale/chunks-fixture/src/background.ts | 2 +- .../locale/chunks-fixture/src/catalogue.d.ts | 2 +- .../src/isolated-secondary.content.ts | 2 +- .../chunks-fixture/src/isolated.content.ts | 2 +- .../src/main-secondary.content.ts | 2 +- .../locale/chunks-fixture/src/main.content.ts | 2 +- .../locale/chunks-fixture/src/options.ts | 2 +- .../build/locale/chunks-fixture/src/popup.ts | 2 +- .../build/locale/chunks.integration.test.ts | 6 +- .../build/locale/dynamic.integration.test.ts | 1 - .../build/locale/fixture/src/background.ts | 2 +- .../build/locale/fixture/src/catalogue.d.ts | 2 +- .../build/locale/locale.integration.test.ts | 69 ++++++++++++++++++- .../build/locale/states/de-updated.json | 9 +++ .../build/locale/states/invalid-json.txt | 1 + .../build/locale/states/invalid-plural.json | 5 ++ 32 files changed, 334 insertions(+), 75 deletions(-) create mode 100644 src/cli/bundler/plugins/utils/watch-compilation.ts create mode 100644 tests/integration/build/locale/states/de-updated.json create mode 100644 tests/integration/build/locale/states/invalid-json.txt create mode 100644 tests/integration/build/locale/states/invalid-plural.json diff --git a/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.test.ts b/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.test.ts index 0c2903f5..ecad344a 100644 --- a/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.test.ts +++ b/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.test.ts @@ -1,28 +1,86 @@ -import {rspack, Compiler} from "@rspack/core"; +import {rspack, Compiler, type Stats} from "@rspack/core"; +import {createFsFromVolume, Volume} from "memfs"; import GenerateJsonPlugin from "./GenerateJsonPlugin"; describe("GenerateJsonPlugin watch updates", () => { let compiler: Compiler; + const run = () => + new Promise((resolve, reject) => { + compiler.run((error, stats) => (error ? reject(error) : resolve(stats!))); + }); + 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 () => { + test("reports validation failures without emitting stale data 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: {}, + output: {path: "/dist"}, + optimization: {emitOnErrors: false}, plugins: [new GenerateJsonPlugin({}).watch(update)], }); + const filesystem = createFsFromVolume(new Volume()); + compiler.outputFileSystem = filesystem as Compiler["outputFileSystem"]; - await expect(compiler.hooks.watchRun.promise(compiler)).rejects.toThrow(error); - await expect(compiler.hooks.watchRun.promise(compiler)).resolves.toBeUndefined(); + await compiler.hooks.watchRun.promise(compiler); + const failed = await run(); + expect(failed.hasErrors()).toBe(true); + expect(failed.toString({all: false, errors: true})).toContain(error.message); + expect(filesystem.existsSync("/dist/messages.json")).toBe(false); + + await compiler.hooks.watchRun.promise(compiler); + expect((await run()).hasErrors()).toBe(false); + expect(JSON.parse(filesystem.readFileSync("/dist/messages.json", "utf8") as string)).toEqual({ + title: "Updated", + }); expect(update).toHaveBeenCalledTimes(2); }); + test("removes obsolete JSON and empty directories only after a successful update", async () => { + let data: Record = { + "_locales/en/messages.json": {message: "Hello"}, + "_locales/fr/messages.json": {message: "Bonjour"}, + }; + let fail = false; + compiler = rspack({ + mode: "none", + entry: {}, + output: {path: "/dist", clean: false}, + optimization: {emitOnErrors: false}, + plugins: [ + new GenerateJsonPlugin(data).watch(async () => { + if (fail) throw new Error("Invalid input"); + return data; + }), + ], + }); + const filesystem = createFsFromVolume(new Volume()); + compiler.outputFileSystem = filesystem as Compiler["outputFileSystem"]; + await run(); + filesystem.writeFileSync("/dist/unrelated.txt", "Keep this file"); + + data = {"_locales/en/messages.json": {message: "Updated"}}; + fail = true; + await compiler.hooks.watchRun.promise(compiler); + expect((await run()).hasErrors()).toBe(true); + expect(filesystem.existsSync("/dist/_locales/fr/messages.json")).toBe(true); + + fail = false; + await compiler.hooks.watchRun.promise(compiler); + expect((await run()).hasErrors()).toBe(false); + expect(filesystem.existsSync("/dist/_locales/fr")).toBe(false); + expect(filesystem.readFileSync("/dist/unrelated.txt", "utf8")).toBe("Keep this file"); + expect(JSON.parse(filesystem.readFileSync("/dist/_locales/en/messages.json", "utf8") as string)).toEqual({ + message: "Updated", + }); + }); + test("allows watch builds without an update callback", async () => { compiler = rspack({mode: "none", entry: {}, plugins: [new GenerateJsonPlugin({})]}); diff --git a/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.ts b/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.ts index 890de2d5..c5de4353 100644 --- a/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.ts +++ b/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.ts @@ -1,4 +1,7 @@ import {Compilation, Compiler, sources} from "@rspack/core"; +import path from "path"; +import {promisify} from "util"; +import {watchCompilation} from "../utils"; type JsonValue = string | number | boolean | null | JsonObject | JsonArray; @@ -20,11 +23,12 @@ export default class GenerateJsonPlugin { constructor(protected data: GenerateJsonPluginData) {} public apply(compiler: Compiler): void { - compiler.hooks.watchRun.tapPromise(this.pluginName, async () => { + let emitted = new Set(); + + watchCompilation(compiler, this.pluginName, async () => { const update = this.update; if (update) { - // Let the compiler fail this rebuild rather than emit stale JSON. this.data = await update(); } }); @@ -38,6 +42,18 @@ export default class GenerateJsonPlugin { () => this.generateFiles(compilation) ); }); + + compiler.hooks.afterEmit.tapPromise(this.pluginName, async compilation => { + if (compilation.errors.length) return; + + const current = new Set(Object.keys(this.data)); + for (const filename of emitted) { + if (!current.has(filename) && !compilation.getAsset(filename)) { + await this.removeFile(compiler, filename); + } + } + emitted = current; + }); } public watch(update: GenerateJsonPluginUpdate): this { @@ -53,4 +69,30 @@ export default class GenerateJsonPlugin { compilation.emitAsset(filename, new sources.RawSource(json)); }); } + + private async removeFile(compiler: Compiler, filename: string): Promise { + const filesystem = compiler.outputFileSystem!; + const output = compiler.options.output.path!; + const file = path.join(output, filename); + await promisify(filesystem.unlink.bind(filesystem))(file).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + + // Remove only empty parent directories owned by the removed output, stopping at output.path. + for ( + let directory = path.dirname(file); + directory.startsWith(output + path.sep); + directory = path.dirname(directory) + ) { + const removed = await promisify(filesystem.rmdir.bind(filesystem))(directory).then( + () => true, + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return true; + if (error.code === "ENOTEMPTY" || error.code === "EEXIST") return false; + throw error; + } + ); + if (!removed) break; + } + } } diff --git a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts index 19c002c8..23a90d2c 100644 --- a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts +++ b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts @@ -69,7 +69,7 @@ test("bundles imports and arbitrary JavaScript exports without executing unused await expect(fs.access(file)).rejects.toMatchObject({code: "ENOENT"}); }); -test("updates before compilation, skips unchanged sources and propagates callback errors", async () => { +test("updates before compilation, skips unchanged sources and reports recoverable callback errors", async () => { let value = 3; let fail = false; const compiler = createCompiler( @@ -93,7 +93,8 @@ test("updates before compilation, skips unchanged sources and propagates callbac expect(await run(compiler)).toBe(14); fail = true; - await expect(compiler.hooks.watchRun.promise(compiler)).rejects.toThrow("Cannot generate module"); + await compiler.hooks.watchRun.promise(compiler); + await expect(run(compiler)).rejects.toThrow("Cannot generate module"); expect(await fs.readFile(file, "utf8")).toBe("export default 7;"); fail = false; value = 9; @@ -101,6 +102,40 @@ test("updates before compilation, skips unchanged sources and propagates callbac expect(await run(compiler)).toBe(18); }); +test("recomputes file and directory dependencies after each refresh, including invalid updates", async () => { + const first = path.join(directory, "first.json"); + const second = path.join(directory, "second.json"); + const missing = path.join(directory, "new-directory"); + let files = [first]; + let fail = false; + const compiler = createCompiler( + new GenerateModulePlugin(modules).watch( + async () => { + if (fail) throw new Error("Invalid source"); + return modules; + }, + async () => ({files, directories: [directory, missing]}) + ) + ); + const dependencies = () => { + const compilation = compiler._lastCompilation!; + expect(compilation.contextDependencies.has(directory)).toBe(true); + expect(compilation.missingDependencies.has(missing)).toBe(true); + return compilation.fileDependencies; + }; + + await compiler.hooks.watchRun.promise(compiler); + await run(compiler); + expect(dependencies().has(first)).toBe(true); + + files = [second]; + fail = true; + await compiler.hooks.watchRun.promise(compiler); + await expect(run(compiler)).rejects.toThrow("Invalid source"); + expect(dependencies().has(first)).toBe(false); + expect(dependencies().has(second)).toBe(true); +}); + test("isolates compilers with identical initial sources, updates and cleanup", async () => { const first = createCompiler( new GenerateModulePlugin(modules).watch(async () => ({"virtual/value": "export default 5;"})), diff --git a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts index 74b3436d..2aeb6f64 100644 --- a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts +++ b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts @@ -1,7 +1,9 @@ import {randomUUID} from "crypto"; +import {existsSync} from "fs"; import path from "path"; import type {Compiler, RuleSetCondition} from "@rspack/core"; import {RspackVirtualModulePlugin} from "rspack-plugin-virtual-module"; +import {watchCompilation} from "../utils"; /** JavaScript source keyed by the module's import specifier. */ export type GenerateModulePluginModules = Record; @@ -9,10 +11,17 @@ export type GenerateModulePluginModules = Record; /** Returns source updates for the registered modules. */ export type GenerateModulePluginUpdate = () => Promise; +export interface GenerateModulePluginDependencies { + files?: Iterable; + directories?: Iterable; +} + +export type GenerateModulePluginDependenciesResolver = () => Promise; + export default class GenerateModulePlugin { private readonly pluginName = "GenerateModulePlugin"; private update?: GenerateModulePluginUpdate; - private files: readonly string[] = []; + private dependencies?: GenerateModulePluginDependenciesResolver; private moduleLayer?: string; private issuerLayer?: RuleSetCondition; @@ -25,14 +34,15 @@ export default class GenerateModulePlugin { return this; } - public watch(update: GenerateModulePluginUpdate, files: Iterable = []): this { + public watch(update: GenerateModulePluginUpdate, dependencies?: GenerateModulePluginDependenciesResolver): this { this.update = update; - this.files = [...files]; + this.dependencies = dependencies; return this; } public apply(compiler: Compiler): void { const modules = {...this.modules}; + let dependencies: GenerateModulePluginDependencies = {}; // The underlying plugin removes its entire directory on shutdown. const plugin = new RspackVirtualModulePlugin(modules, `generate-module-${randomUUID()}`); @@ -48,12 +58,19 @@ export default class GenerateModulePlugin { } compiler.hooks.compilation.tap(this.pluginName, compilation => { - for (const file of this.files) { + for (const file of dependencies.files ?? []) { compilation.fileDependencies.add(path.resolve(compiler.context, file)); } + + for (const directory of dependencies.directories ?? []) { + const absolute = path.resolve(compiler.context, directory); + const target = existsSync(absolute) ? compilation.contextDependencies : compilation.missingDependencies; + target.add(absolute); + } }); - compiler.hooks.watchRun.tapPromise(this.pluginName, async () => { + watchCompilation(compiler, this.pluginName, async () => { + dependencies = (await this.dependencies?.()) ?? {}; if (!this.update) return; for (const [name, source] of Object.entries(await this.update())) { diff --git a/src/cli/bundler/plugins/generate-module/index.ts b/src/cli/bundler/plugins/generate-module/index.ts index 0f0b1f37..3359d8aa 100644 --- a/src/cli/bundler/plugins/generate-module/index.ts +++ b/src/cli/bundler/plugins/generate-module/index.ts @@ -1,5 +1,7 @@ export { default as GenerateModulePlugin, + type GenerateModulePluginDependencies, + type GenerateModulePluginDependenciesResolver, type GenerateModulePluginModules, type GenerateModulePluginUpdate, } from "./GenerateModulePlugin"; diff --git a/src/cli/bundler/plugins/index.ts b/src/cli/bundler/plugins/index.ts index 0fe4664e..b791773c 100644 --- a/src/cli/bundler/plugins/index.ts +++ b/src/cli/bundler/plugins/index.ts @@ -27,6 +27,8 @@ export { export { GenerateModulePlugin, + type GenerateModulePluginDependencies, + type GenerateModulePluginDependenciesResolver, type GenerateModulePluginModules, type GenerateModulePluginUpdate, } from "./generate-module"; diff --git a/src/cli/bundler/plugins/utils/index.ts b/src/cli/bundler/plugins/utils/index.ts index 4a67b9b9..36d91164 100644 --- a/src/cli/bundler/plugins/utils/index.ts +++ b/src/cli/bundler/plugins/utils/index.ts @@ -1,2 +1,3 @@ export {getManifestHooks, type ManifestHooks} from "./manifest-hooks"; export {renderRuntimeTemplate} from "./runtime-template"; +export {watchCompilation} from "./watch-compilation"; diff --git a/src/cli/bundler/plugins/utils/watch-compilation.ts b/src/cli/bundler/plugins/utils/watch-compilation.ts new file mode 100644 index 00000000..e423c62d --- /dev/null +++ b/src/cli/bundler/plugins/utils/watch-compilation.ts @@ -0,0 +1,20 @@ +import type {Compiler} from "@rspack/core"; + +/** Report failed updates as compilation errors so watch stays connected for the next edit. */ +export const watchCompilation = (compiler: Compiler, name: string, update: () => Promise): void => { + let failure: Error | undefined; + + compiler.hooks.watchRun.tapPromise(name, async () => { + failure = undefined; + + try { + await update(); + } catch (error) { + failure = error instanceof Error ? error : new Error(String(error)); + } + }); + + compiler.hooks.thisCompilation.tap(name, compilation => { + if (failure) compilation.errors.push(failure); + }); +}; diff --git a/src/cli/bundler/plugins/watch/WatchPlugin.ts b/src/cli/bundler/plugins/watch/WatchPlugin.ts index d1f3a0fa..d5b92d21 100644 --- a/src/cli/bundler/plugins/watch/WatchPlugin.ts +++ b/src/cli/bundler/plugins/watch/WatchPlugin.ts @@ -1,4 +1,5 @@ import {Compiler} from "@rspack/core"; +import {watchCompilation} from "../utils"; export type WatchPluginCallback = (files: ReadonlySet) => Promise; @@ -6,16 +7,8 @@ export default class WatchPlugin { constructor(private readonly callback: WatchPluginCallback) {} public apply(compiler: Compiler): void { - compiler.hooks.watchRun.tapAsync("WatchPlugin", async (compiler, callback) => { - try { - const {modifiedFiles = new Set()} = compiler; - - await this.callback(modifiedFiles); - - callback(); - } catch (e) { - callback(e as Error); - } + watchCompilation(compiler, "WatchPlugin", async () => { + await this.callback(compiler.modifiedFiles ?? new Set()); }); } } diff --git a/src/cli/plugins/locale/Locale.ts b/src/cli/plugins/locale/Locale.ts index ec3bc2f7..5189dac6 100644 --- a/src/cli/plugins/locale/Locale.ts +++ b/src/cli/plugins/locale/Locale.ts @@ -1,5 +1,7 @@ +import path from "path"; import {LocaleFinder} from "@cli/entrypoint"; -import {GenerateJsonPluginData} from "@cli/bundler"; +import type {GenerateJsonPluginData, GenerateModulePluginDependencies} from "@cli/bundler"; +import {getSourcePath, getSharedPath, getAppPath, getAppSourcePath} from "@cli/resolvers/path"; import {flattenLocaleMessages, getLocaleFilename} from "@shared/locale"; @@ -8,6 +10,20 @@ import type {Language, LocaleCatalogue, LocaleMessages} from "@typing/locale"; export default class Locale extends LocaleFinder { private _messages?: Promise>; + public async dependencies(): Promise { + const files = [...(await this.plugin().files())].map(({file}) => file); + const directory = this.getDirectory(); + const directories = new Set([ + getSourcePath(this.config, directory), + getSharedPath(this.config, directory), + getAppPath(this.config, directory), + getAppSourcePath(this.config, directory), + ...files.map(file => path.dirname(file)), + ]); + + return {files, directories}; + } + public async json(): Promise { return Object.fromEntries( [...(await this.messages())].map(([lang, messages]) => [getLocaleFilename(lang), messages]) diff --git a/src/cli/plugins/locale/index.ts b/src/cli/plugins/locale/index.ts index f47ea899..25adb747 100644 --- a/src/cli/plugins/locale/index.ts +++ b/src/cli/plugins/locale/index.ts @@ -1,19 +1,18 @@ import _ from "lodash"; -import type {Configuration as RspackConfig} from "@rspack/core"; +import type {Configuration as RspackConfig, RspackPluginInstance} from "@rspack/core"; import {definePlugin} from "@main/plugin"; -import {GenerateJsonPlugin, GenerateModulePlugin} from "@cli/bundler"; +import {GenerateJsonPlugin, GenerateModulePlugin, WatchPlugin} from "@cli/bundler"; import {getContentLayer} from "@cli/bundler/utils/layers"; import {extractLocaleKey, modifyLocaleMessageKey} from "@shared/locale"; import Locale from "./Locale"; import {LocaleDeclaration} from "./declaration"; -import {createLocaleModule} from "./module"; +import {createLocaleModule, LocaleModuleLayer, LocaleModuleName} from "./module"; import {Command} from "@typing/app"; import {Browser} from "@typing/browser"; import {ContentScriptWorld} from "@typing/content"; -import {LocaleModuleLayer, LocaleModuleName} from "@typing/locale"; export default definePlugin(() => { let locale: Locale; @@ -43,26 +42,28 @@ export default definePlugin(() => { const modulePlugin = new GenerateModulePlugin(await getModules()).layer(LocaleModuleLayer, { not: [getContentLayer(ContentScriptWorld.Main)], }); + const plugins: RspackPluginInstance[] = []; if (config.command === Command.Watch) { - jsonPlugin.watch(async () => { - locale.clear(); - await prepareLocale(); - - return locale.json(); - }); + plugins.push( + new WatchPlugin(async () => { + locale.clear(); + await prepareLocale(); + }) + ); - const watchFiles = [...(await locale.plugin().files())].map(({file}) => file); - modulePlugin.watch(getModules, watchFiles); + jsonPlugin.watch(() => locale.json()); + modulePlugin.watch(getModules, () => locale.dependencies()); } + plugins.push(jsonPlugin, modulePlugin); + return { - // JSON refresh clears and prepares the shared cache before the module reads it. - plugins: [jsonPlugin, modulePlugin], - resolve: {alias: {"#adnbn/locale$": LocaleModuleName}}, - optimization: config.commonChunks - ? { - splitChunks: { + plugins, + optimization: { + emitOnErrors: false, + splitChunks: config.commonChunks + ? { cacheGroups: { adnbnLocaleShared: { layer: LocaleModuleLayer, @@ -84,9 +85,9 @@ export default definePlugin(() => { priority: 70, }, }, - }, - } - : undefined, + } + : undefined, + }, } satisfies RspackConfig; }, manifest: async ({config, manifest}) => { diff --git a/src/cli/plugins/locale/module.ts b/src/cli/plugins/locale/module.ts index 4c166cf6..dc35b91a 100644 --- a/src/cli/plugins/locale/module.ts +++ b/src/cli/plugins/locale/module.ts @@ -1,5 +1,8 @@ import type {Language, LocaleCatalogue} from "@typing/locale"; +export const LocaleModuleName = "#adnbn/locale"; +export const LocaleModuleLayer = "adnbn:locale"; + export const createLocaleModule = (catalogue: LocaleCatalogue, keys: ReadonlySet, lang: Language): string => // JSON.parse preserves own __proto__ keys; the pure annotation lets native consumers omit the catalogue. `export const lang = ${JSON.stringify(lang)};\n` + diff --git a/src/locale/README.md b/src/locale/README.md index 6b322a9c..c964167f 100644 --- a/src/locale/README.md +++ b/src/locale/README.md @@ -22,10 +22,12 @@ description: Runtime translation providers, shared formatting, catalogue access, ## Ownership and data flow -Shared languages, contracts, and module identifiers belong to `src/types/locale.ts`. It also declares the empty `LocaleRegistry`, re-exported through `adnbn/locale`. Generated `.adnbn/locale.d.ts` augments that registry, defining the application's keys and required substitutions from its default language for providers, helpers, and adapters. +Shared languages and contracts belong to `src/types/locale.ts`. It also declares the empty `LocaleRegistry`, re-exported through `adnbn/locale`. Generated `.adnbn/locale.d.ts` augments that registry, defining the application's keys and required substitutions from its default language for providers, helpers, and adapters. The private module name and bundler layer belong to `src/cli/plugins/locale/module.ts`. Locale discovery, merging, validation, fallback preparation, native `_locales/*/messages.json`, and declaration generation belong to the CLI locale pipeline. The locale feature plugin supplies the generated data; Rspack integration and chunk delivery belong to the bundler. +In watch mode, one refresh prepares the locale data before the JSON and module generators read it. File and directory dependencies are refreshed on every rebuild, including newly added languages. Invalid edits produce compilation errors without replacing the last successful output; fixing the source allows the next rebuild to proceed. + Common algorithms live in `src/shared/locale`. Its `index.ts` re-exports the helpers; CLI, providers, and adapters import them from `@shared/locale`: - `keys.ts`: key conversion and locale markers. @@ -36,7 +38,7 @@ Common algorithms live in `src/shared/locale`. Its `index.ts` re-exports the hel These modules depend only on shared contracts and have no browser, DOM, React, storage, or CLI dependencies. Consumers supply missing-substitution diagnostics; React node rendering stays inside the React adapter. -During extension builds, the private `#adnbn/locale` import resolves to `virtual/locale`: a default catalogue export plus named `keys`, `languages`, and `lang` (the configured default language). `DynamicLocale` reads the catalogue; `NativeLocale` imports only the named lists, allowing unused translations to be removed from optimized bundles. Outside these builds, `catalogue/` provides a resolvable empty module with `lang: "en"`. +During extension builds, the private `#adnbn/locale` import resolves directly to a generated module: a default catalogue export plus named `keys`, `languages`, and `lang` (the configured default language). `DynamicLocale` reads the catalogue; `NativeLocale` imports only the named lists, allowing unused translations to be removed from optimized bundles. Outside these builds, `catalogue/` provides a resolvable empty module with `lang: "en"`. Views and ISOLATED content scripts can share `locale.js`. MAIN retains its own content layer: its catalogue stays in the entrypoint or joins `common-main.content.js` under the regular content chunk rules. Background keeps translations in its single bundle. This separates file delivery across content execution worlds; the same translations may occur in their respective bundles. diff --git a/src/locale/catalogue/catalogue.ts b/src/locale/catalogue/catalogue.ts index 9e8f0285..048b3835 100644 --- a/src/locale/catalogue/catalogue.ts +++ b/src/locale/catalogue/catalogue.ts @@ -1,6 +1,6 @@ import {Language, type LocaleCatalogue} from "@typing/locale"; -/** Package fallback; extension builds replace #adnbn/locale with virtual/locale. */ +/** Package fallback; extension builds supply #adnbn/locale as a generated module. */ const catalogue: LocaleCatalogue = {}; export const lang: Language = Language.English; diff --git a/src/types/locale.ts b/src/types/locale.ts index 16e13806..bd6d48a2 100644 --- a/src/types/locale.ts +++ b/src/types/locale.ts @@ -63,10 +63,6 @@ export enum LocaleDir { export const RtlLanguages: ReadonlySet = new Set([Language.Arabic, Language.Persian, Language.Hebrew]); -export const LocaleModuleName = "virtual/locale"; - -export const LocaleModuleLayer = "adnbn:locale"; - export const LocaleNestedKeysSeparator = "."; export const LocaleKeysSeparator = "_"; diff --git a/tests/integration/build/locale/chunks-fixture/adnbn.config.ts b/tests/integration/build/locale/chunks-fixture/adnbn.config.ts index a0fe5276..fbc7297a 100644 --- a/tests/integration/build/locale/chunks-fixture/adnbn.config.ts +++ b/tests/integration/build/locale/chunks-fixture/adnbn.config.ts @@ -1,5 +1,4 @@ import {defineConfig} from "adnbn"; -import {writeFileSync} from "node:fs"; export default defineConfig({ name: "Locale chunks integration", @@ -8,9 +7,4 @@ export default defineConfig({ concatContentScripts: false, jsFilename: "[name].[chunkhash:8].js", specific: {gecko: {id: "locale-chunks@adnbn.test"}}, - bundler: config => { - const splitChunks = config.optimization?.splitChunks; - writeFileSync("cache-groups.json", JSON.stringify(Object.keys((splitChunks && splitChunks.cacheGroups) || {}))); - return {}; - }, }); diff --git a/tests/integration/build/locale/chunks-fixture/src/background.ts b/tests/integration/build/locale/chunks-fixture/src/background.ts index 38e0761d..4b01f923 100644 --- a/tests/integration/build/locale/chunks-fixture/src/background.ts +++ b/tests/integration/build/locale/chunks-fixture/src/background.ts @@ -1,4 +1,4 @@ -import catalogue from "virtual/locale"; +import catalogue from "#adnbn/locale"; (globalThis as typeof globalThis & {catalogue: typeof catalogue}).catalogue = catalogue; diff --git a/tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts b/tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts index aa08b107..455394a2 100644 --- a/tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts +++ b/tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts @@ -1,4 +1,4 @@ -declare module "virtual/locale" { +declare module "#adnbn/locale" { export const lang: import("adnbn/locale").Language; const catalogue: Record>; export const keys: readonly string[]; diff --git a/tests/integration/build/locale/chunks-fixture/src/isolated-secondary.content.ts b/tests/integration/build/locale/chunks-fixture/src/isolated-secondary.content.ts index 1ac29256..22127e8a 100644 --- a/tests/integration/build/locale/chunks-fixture/src/isolated-secondary.content.ts +++ b/tests/integration/build/locale/chunks-fixture/src/isolated-secondary.content.ts @@ -1,5 +1,5 @@ import {defineContentScript} from "adnbn"; -import catalogue from "virtual/locale"; +import catalogue from "#adnbn/locale"; import {createPanel} from "./panel"; export default defineContentScript({ diff --git a/tests/integration/build/locale/chunks-fixture/src/isolated.content.ts b/tests/integration/build/locale/chunks-fixture/src/isolated.content.ts index 92f47417..ef4a2dc1 100644 --- a/tests/integration/build/locale/chunks-fixture/src/isolated.content.ts +++ b/tests/integration/build/locale/chunks-fixture/src/isolated.content.ts @@ -1,5 +1,5 @@ import {defineContentScript} from "adnbn"; -import catalogue from "virtual/locale"; +import catalogue from "#adnbn/locale"; import {createPanel} from "./panel"; export default defineContentScript({ diff --git a/tests/integration/build/locale/chunks-fixture/src/main-secondary.content.ts b/tests/integration/build/locale/chunks-fixture/src/main-secondary.content.ts index 5a4099ee..e495fbe4 100644 --- a/tests/integration/build/locale/chunks-fixture/src/main-secondary.content.ts +++ b/tests/integration/build/locale/chunks-fixture/src/main-secondary.content.ts @@ -1,5 +1,5 @@ import {defineContentScript} from "adnbn"; -import catalogue from "virtual/locale"; +import catalogue from "#adnbn/locale"; import {createPanel} from "./panel"; export default defineContentScript({ diff --git a/tests/integration/build/locale/chunks-fixture/src/main.content.ts b/tests/integration/build/locale/chunks-fixture/src/main.content.ts index 168c29b9..fb18b1e7 100644 --- a/tests/integration/build/locale/chunks-fixture/src/main.content.ts +++ b/tests/integration/build/locale/chunks-fixture/src/main.content.ts @@ -1,5 +1,5 @@ import {defineContentScript} from "adnbn"; -import catalogue from "virtual/locale"; +import catalogue from "#adnbn/locale"; import {createPanel} from "./panel"; export default defineContentScript({ diff --git a/tests/integration/build/locale/chunks-fixture/src/options.ts b/tests/integration/build/locale/chunks-fixture/src/options.ts index 3d6663b4..f89f66b0 100644 --- a/tests/integration/build/locale/chunks-fixture/src/options.ts +++ b/tests/integration/build/locale/chunks-fixture/src/options.ts @@ -1,5 +1,5 @@ import {defineOptions} from "adnbn"; -import catalogue from "virtual/locale"; +import catalogue from "#adnbn/locale"; import {createPanel} from "./panel"; export default defineOptions({render: () => createPanel(catalogue, "options")}); diff --git a/tests/integration/build/locale/chunks-fixture/src/popup.ts b/tests/integration/build/locale/chunks-fixture/src/popup.ts index c1aa9b81..a95d21cc 100644 --- a/tests/integration/build/locale/chunks-fixture/src/popup.ts +++ b/tests/integration/build/locale/chunks-fixture/src/popup.ts @@ -1,5 +1,5 @@ import {definePopup} from "adnbn"; -import catalogue from "virtual/locale"; +import catalogue from "#adnbn/locale"; import {createPanel} from "./panel"; export default definePopup({render: () => createPanel(catalogue, "popup")}); diff --git a/tests/integration/build/locale/chunks.integration.test.ts b/tests/integration/build/locale/chunks.integration.test.ts index 75a2c675..72873bae 100644 --- a/tests/integration/build/locale/chunks.integration.test.ts +++ b/tests/integration/build/locale/chunks.integration.test.ts @@ -75,10 +75,6 @@ test.each([ } const directory = await fixture.build({browser}); - const groups: string[] = JSON.parse(await readFile(path.join(fixture.directory, "cache-groups.json"), "utf8")); - expect(groups.filter(name => name.startsWith("adnbnLocale")).sort()).toEqual( - enabled ? ["adnbnLocaleLarge", "adnbnLocaleShared"] : [] - ); const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); const backgroundFiles: string[] = manifest.background.service_worker ? [manifest.background.service_worker] @@ -91,6 +87,8 @@ test.each([ sources.set(filename.split(path.sep).join("/"), await readFile(path.join(directory, filename), "utf8")); } } + if (!enabled) expect([...sources.keys()].filter(file => /^js\/locale\.[^.]+\.js$/.test(file))).toEqual([]); + const catalogueFiles = [...sources.keys()].filter(file => sources.get(file)!.includes(Greeting)); expect(catalogueFiles).toContain(backgroundFiles[0]); diff --git a/tests/integration/build/locale/dynamic.integration.test.ts b/tests/integration/build/locale/dynamic.integration.test.ts index 068075eb..5d776041 100644 --- a/tests/integration/build/locale/dynamic.integration.test.ts +++ b/tests/integration/build/locale/dynamic.integration.test.ts @@ -48,7 +48,6 @@ test.each(["chrome", "firefox"])( "utf8" ); expect(declaration).not.toContain("#adnbn/locale"); - expect(declaration).not.toContain("virtual/locale"); expect(declaration).toContain("extends AbstractLocale"); } finally { await fixture.dispose(); diff --git a/tests/integration/build/locale/fixture/src/background.ts b/tests/integration/build/locale/fixture/src/background.ts index 4f77a054..99ea1d7f 100644 --- a/tests/integration/build/locale/fixture/src/background.ts +++ b/tests/integration/build/locale/fixture/src/background.ts @@ -1,4 +1,4 @@ -import catalogue, {keys, lang, languages} from "virtual/locale"; +import catalogue, {keys, lang, languages} from "#adnbn/locale"; (globalThis as typeof globalThis & {readLocaleCatalogue: () => typeof catalogue}).readLocaleCatalogue = () => catalogue; (globalThis as typeof globalThis & {readLocaleKeys: () => typeof keys}).readLocaleKeys = () => keys; diff --git a/tests/integration/build/locale/fixture/src/catalogue.d.ts b/tests/integration/build/locale/fixture/src/catalogue.d.ts index aa08b107..455394a2 100644 --- a/tests/integration/build/locale/fixture/src/catalogue.d.ts +++ b/tests/integration/build/locale/fixture/src/catalogue.d.ts @@ -1,4 +1,4 @@ -declare module "virtual/locale" { +declare module "#adnbn/locale" { export const lang: import("adnbn/locale").Language; const catalogue: Record>; export const keys: readonly string[]; diff --git a/tests/integration/build/locale/locale.integration.test.ts b/tests/integration/build/locale/locale.integration.test.ts index 61717d60..0243d545 100644 --- a/tests/integration/build/locale/locale.integration.test.ts +++ b/tests/integration/build/locale/locale.integration.test.ts @@ -93,7 +93,7 @@ test("exports the configured default language even when it is not the first cata } }); -test("CLI watch refreshes the catalogue, JSON and declarations from the same locale edit", async () => { +test("CLI watch discovers, edits and removes locales and recovers from invalid translations", async () => { const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, path.join(__dirname, "fixture")); let watcher: ChildProcess | undefined; let output = ""; @@ -113,6 +113,30 @@ test("CLI watch refreshes the catalogue, JSON and declarations from the same loc path.join(fixture.directory, "src/locales/de.json"), await readFile(path.join(__dirname, "states/de.json")) ); + await waitFor( + async () => { + const {catalogue, languages} = await inspect(directory); + expect([...languages].sort()).toEqual(["de", "en", "fr"]); + expect(catalogue.de.app_title).toBe("Übersetzungskatalog"); + return true; + }, + 15000, + "a new locale without editing an existing file" + ); + + await copyFile( + path.join(__dirname, "states/de-updated.json"), + path.join(fixture.directory, "src/locales/de.json") + ); + await waitFor( + async () => { + expect((await inspect(directory)).catalogue.de.app_title).toBe("Aktualisierter Katalog"); + return true; + }, + 15000, + "an edit to the newly discovered locale" + ); + await writeFile( path.join(fixture.directory, "src/locales/en.json"), await readFile(path.join(__dirname, "states/en.json")) @@ -121,7 +145,7 @@ test("CLI watch refreshes the catalogue, JSON and declarations from the same loc async () => { const {catalogue, keys, languages} = await inspect(directory); expect([...languages].sort()).toEqual(["de", "en", "fr"]); - expect(catalogue.de.app_title).toBe("Übersetzungskatalog"); + expect(catalogue.de.app_title).toBe("Aktualisierter Katalog"); expect(catalogue.fr.app_greeting).toBe("Welcome {{name}}"); expect(catalogue.fr.newKey).toBe("Added during watch"); expect(catalogue.fr).not.toHaveProperty("empty"); @@ -134,6 +158,47 @@ test("CLI watch refreshes the catalogue, JSON and declarations from the same loc 15000, "updated locale catalogue, languages and JSON" ); + + await rm(path.join(fixture.directory, "src/locales/de.json")); + await waitFor( + async () => { + expect((await inspect(directory)).languages).toEqual(["en", "fr"]); + return true; + }, + 15000, + "removal of a locale and its generated JSON" + ); + + const declarationFile = path.join(fixture.directory, ".adnbn/locale.d.ts"); + for (const invalid of ["invalid-plural.json", "invalid-json.txt"]) { + const previousDeclaration = await readFile(declarationFile, "utf8"); + const previousJson = await readFile(path.join(directory, "_locales/en/messages.json"), "utf8"); + const previousBundle = await readFile(path.join(directory, "js/background.js"), "utf8"); + const offset = output.length; + await copyFile( + path.join(__dirname, "states", invalid), + path.join(fixture.directory, "src/locales/de.json") + ); + await waitFor( + async () => output.slice(offset).includes("compiled with") || undefined, + 15000, + "invalid locale diagnostics" + ); + expect(watcher.exitCode).toBeNull(); + expect(await readFile(declarationFile, "utf8")).toBe(previousDeclaration); + expect(await readFile(path.join(directory, "_locales/en/messages.json"), "utf8")).toBe(previousJson); + expect(await readFile(path.join(directory, "js/background.js"), "utf8")).toBe(previousBundle); + + await copyFile(path.join(__dirname, "states/de.json"), path.join(fixture.directory, "src/locales/de.json")); + await waitFor( + async () => { + expect((await inspect(directory)).catalogue.de.app_title).toBe("Übersetzungskatalog"); + return true; + }, + 15000, + "recovery after fixing the invalid locale" + ); + } } catch (error) { throw new Error(`${String(error)}\n${output}`, {cause: error}); } finally { diff --git a/tests/integration/build/locale/states/de-updated.json b/tests/integration/build/locale/states/de-updated.json new file mode 100644 index 00000000..477625a3 --- /dev/null +++ b/tests/integration/build/locale/states/de-updated.json @@ -0,0 +1,9 @@ +{ + "app": { + "title": "Aktualisierter Katalog" + }, + "items": [ + "{{count}} Artikel", + "{{count}} Artikel" + ] +} diff --git a/tests/integration/build/locale/states/invalid-json.txt b/tests/integration/build/locale/states/invalid-json.txt new file mode 100644 index 00000000..8beda168 --- /dev/null +++ b/tests/integration/build/locale/states/invalid-json.txt @@ -0,0 +1 @@ +{"app": diff --git a/tests/integration/build/locale/states/invalid-plural.json b/tests/integration/build/locale/states/invalid-plural.json new file mode 100644 index 00000000..ab48c52d --- /dev/null +++ b/tests/integration/build/locale/states/invalid-plural.json @@ -0,0 +1,5 @@ +{ + "app": { + "title": "Incomplete translation" + } +} From f9d4436391b4928ed0f5e51f9e5310a3115baa4d Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:28:03 +0300 Subject: [PATCH 29/38] feat(locale)!: replace the React provider with locale hooks Share dynamic state through ObservableLocale and expose native and dynamic React adapters with typed JSX substitutions and explicit DOM attributes. BREAKING CHANGE: Remove React LocaleProvider, LocaleProviderProps and LocaleContract. Use useLocale or useNativeLocale, and apply DOM attributes explicitly with useLocaleAttributes. Rename provider languages()/languageNames() to langs()/langNames(). React langs now contains language codes; display names are in langNames. useLocale().change() now returns Promise. React adapters require React 18 or 19. React peers are now optional. --- package-lock.json | 35 +- package.json | 16 +- .../declaration/LocaleDeclaration.test.ts | 19 +- .../tests/fixtures/custom-structure.ts | 8 +- .../tests/fixtures/dynamic-react.tsx | 99 ++++++ .../tests/fixtures/empty-registry.ts | 10 +- .../tests/fixtures/generated-registry.ts | 17 +- .../tests/fixtures/language-names.ts | 38 ++- .../tests/fixtures/native-react.tsx | 79 +++++ src/locale/README.md | 131 +++++++- src/locale/adapters/react/LocaleProvider.tsx | 85 ----- src/locale/adapters/react/ReactLocale.test.ts | 55 +++ src/locale/adapters/react/ReactLocale.ts | 67 ++++ src/locale/adapters/react/context.ts | 46 --- .../hooks/tests/fixtures/dynamic-locale.ts | 11 + .../tests/fixtures/dynamic-messages.json | 14 + .../hooks/tests/fixtures/native-messages.json | 9 + .../react/hooks/use-locale-attributes.test.ts | 204 +++++++++++ .../react/hooks/use-locale-attributes.ts | 42 +++ .../adapters/react/hooks/use-locale.test.ts | 173 ++++++++++ src/locale/adapters/react/hooks/use-locale.ts | 21 ++ .../react/hooks/use-native-locale.test.ts | 176 ++++++++++ .../adapters/react/hooks/use-native-locale.ts | 10 + src/locale/adapters/react/index.ts | 6 +- .../adapters/react/localeProvider.test.ts | 126 ------- .../react/tests/fixtures/catalogue.json | 14 - src/locale/adapters/react/types.ts | 39 +++ src/locale/index.ts | 1 + src/locale/providers/AbstractLocale.ts | 6 +- src/locale/providers/CustomLocale.test.ts | 6 +- src/locale/providers/CustomLocale.ts | 2 +- src/locale/providers/DynamicLocale.test.ts | 80 +++-- src/locale/providers/DynamicLocale.ts | 39 ++- src/locale/providers/NativeLocale.test.ts | 6 +- src/locale/providers/NativeLocale.ts | 8 +- src/locale/providers/ObservableLocale.test.ts | 316 ++++++++++++++++++ src/locale/providers/ObservableLocale.ts | 246 ++++++++++++++ src/locale/providers/index.ts | 1 + .../providers/tests/fixtures/dynamic/index.ts | 15 + .../tests/fixtures/MemoryLocaleStorage.ts | 31 ++ src/locale/tests/fixtures/index.ts | 1 + src/locale/tests/fixtures/storage.ts | 22 -- src/types/locale.ts | 15 +- .../build/locale/dynamic-fixture/package.json | 5 +- .../locale/dynamic-fixture/src/contract.ts | 3 - .../build/locale/dynamic-fixture/src/panel.ts | 2 +- .../build/locale/dynamic.integration.test.ts | 35 +- .../build/locale/states/native-popup.ts | 22 ++ 48 files changed, 1993 insertions(+), 419 deletions(-) create mode 100644 src/cli/plugins/locale/declaration/tests/fixtures/dynamic-react.tsx create mode 100644 src/cli/plugins/locale/declaration/tests/fixtures/native-react.tsx delete mode 100644 src/locale/adapters/react/LocaleProvider.tsx create mode 100644 src/locale/adapters/react/ReactLocale.test.ts create mode 100644 src/locale/adapters/react/ReactLocale.ts delete mode 100644 src/locale/adapters/react/context.ts create mode 100644 src/locale/adapters/react/hooks/tests/fixtures/dynamic-locale.ts create mode 100644 src/locale/adapters/react/hooks/tests/fixtures/dynamic-messages.json create mode 100644 src/locale/adapters/react/hooks/tests/fixtures/native-messages.json create mode 100644 src/locale/adapters/react/hooks/use-locale-attributes.test.ts create mode 100644 src/locale/adapters/react/hooks/use-locale-attributes.ts create mode 100644 src/locale/adapters/react/hooks/use-locale.test.ts create mode 100644 src/locale/adapters/react/hooks/use-locale.ts create mode 100644 src/locale/adapters/react/hooks/use-native-locale.test.ts create mode 100644 src/locale/adapters/react/hooks/use-native-locale.ts delete mode 100644 src/locale/adapters/react/localeProvider.test.ts delete mode 100644 src/locale/adapters/react/tests/fixtures/catalogue.json create mode 100644 src/locale/adapters/react/types.ts create mode 100644 src/locale/providers/ObservableLocale.test.ts create mode 100644 src/locale/providers/ObservableLocale.ts create mode 100644 src/locale/providers/tests/fixtures/dynamic/index.ts create mode 100644 src/locale/tests/fixtures/MemoryLocaleStorage.ts create mode 100644 src/locale/tests/fixtures/index.ts delete mode 100644 src/locale/tests/fixtures/storage.ts create mode 100644 tests/integration/build/locale/states/native-popup.ts diff --git a/package-lock.json b/package-lock.json index 9736f37f..3382b065 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,8 @@ "jest-environment-jsdom": "^30.0.0", "jest-webextension-mock": "^4.0.0", "prettier": "^3.6.2", + "react": "^19.2.8", + "react-dom": "^19.2.8", "release-it": "^19.2.3", "ts-node": "10.9.2", "tsc-alias": "^1.8.17", @@ -98,10 +100,10 @@ }, "peerDependencies": { "@types/chrome": "*", - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -109,6 +111,12 @@ }, "@types/react-dom": { "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true } } }, @@ -16825,26 +16833,24 @@ } }, "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.5" + "react": "^19.2.8" } }, "node_modules/react-is": { @@ -17518,8 +17524,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/schema-utils": { "version": "3.3.0", diff --git a/package.json b/package.json index c1ad26ac..11339cea 100644 --- a/package.json +++ b/package.json @@ -208,6 +208,8 @@ "jest-environment-jsdom": "^30.0.0", "jest-webextension-mock": "^4.0.0", "prettier": "^3.6.2", + "react": "^19.2.8", + "react-dom": "^19.2.8", "release-it": "^19.2.3", "ts-node": "10.9.2", "tsc-alias": "^1.8.17", @@ -221,10 +223,10 @@ }, "peerDependencies": { "@types/chrome": "*", - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -232,6 +234,12 @@ }, "@types/react-dom": { "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true } }, "overrides": { diff --git a/src/cli/plugins/locale/declaration/LocaleDeclaration.test.ts b/src/cli/plugins/locale/declaration/LocaleDeclaration.test.ts index aa275de2..801c826a 100644 --- a/src/cli/plugins/locale/declaration/LocaleDeclaration.test.ts +++ b/src/cli/plugins/locale/declaration/LocaleDeclaration.test.ts @@ -20,7 +20,8 @@ const typecheck = (files: string[], declaration?: string, target: "source" | "pa moduleResolution: ts.ModuleResolutionKind.Bundler, target: ts.ScriptTarget.ESNext, strict: true, - skipLibCheck: true, + skipLibCheck: target === "source", + jsx: ts.JsxEmit.ReactJSX, noEmit: true, types: [], paths: @@ -73,8 +74,8 @@ describe("locale declarations", () => { 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("LocaleReactContract"); + expect(content).not.toContain("langNames"); expect(content).not.toContain("langs"); expect(content).not.toContain("adnbn/locale/react"); }); @@ -101,7 +102,17 @@ describe("locale declarations", () => { }); expect( - typecheck(["language-names.ts", "custom-structure.ts", "generated-registry.ts"], declaration, target) + typecheck( + [ + "language-names.ts", + "custom-structure.ts", + "generated-registry.ts", + "native-react.tsx", + "dynamic-react.tsx", + ], + declaration, + target + ) ).toEqual([]); }); }); diff --git a/src/cli/plugins/locale/declaration/tests/fixtures/custom-structure.ts b/src/cli/plugins/locale/declaration/tests/fixtures/custom-structure.ts index 79a9a582..184155d2 100644 --- a/src/cli/plugins/locale/declaration/tests/fixtures/custom-structure.ts +++ b/src/cli/plugins/locale/declaration/tests/fixtures/custom-structure.ts @@ -1,5 +1,5 @@ import {NativeLocale, DynamicLocale} from "adnbn/locale"; -import type {LocaleContract} from "adnbn/locale/react"; +import type {LocaleReactContract} from "adnbn/locale/react"; interface CustomStructure { custom: {plural: false; substitutions: ["value"]}; @@ -8,11 +8,11 @@ interface CustomStructure { new NativeLocale().trans("custom", {value: "Custom"}); new DynamicLocale(false).choice("total", 2); -declare const customContext: LocaleContract; -customContext.t("custom", {value: 1}); +declare const customLocale: LocaleReactContract; +customLocale.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"); +customLocale.t("total"); diff --git a/src/cli/plugins/locale/declaration/tests/fixtures/dynamic-react.tsx b/src/cli/plugins/locale/declaration/tests/fixtures/dynamic-react.tsx new file mode 100644 index 00000000..c0fe33b3 --- /dev/null +++ b/src/cli/plugins/locale/declaration/tests/fixtures/dynamic-react.tsx @@ -0,0 +1,99 @@ +import type {ReactNode} from "react"; +import { + Language, + ObservableLocale, + type LocaleDynamicProvider, + type LocaleRegistry, + type LocaleSnapshot, + type LocaleStorageDriver, +} from "adnbn/locale"; +import {useLocale, useLocaleAttributes, type LocaleReactDynamicContract} from "adnbn/locale/react"; + +declare const storage: LocaleStorageDriver; +const locale: LocaleReactDynamicContract = useLocale(storage); +useLocaleAttributes(locale); +useLocaleAttributes(locale, null); +useLocale(); +useLocale(false); +const saved: Promise = locale.change(Language.French); +const text: string = locale.t("app.greeting", {name: "Ada"}); +const node: ReactNode = locale.t("app.greeting", {name: Ada}); +const plural: string = locale.choice("cart.items", 2, {count: 2}); +const pluralNode: ReactNode = locale.choice("cart.items", 2, {count: 2}); +const title: string = locale.t("app.title"); +const emptyPlural: string = locale.choice("cart.empty", 0); + +const observable = ObservableLocale.getInstance(storage); +const snapshot: LocaleSnapshot = observable.snapshot(); +const selected: Language = snapshot.lang(); +const raw: string = snapshot.get("app.greeting"); +const codes: ReadonlySet = snapshot.langs(); +const names: ReadonlyMap = snapshot.langNames(); +const stop: () => void = observable.subscribe(() => {}); +const synced: Promise = observable.sync(); +const changed: Promise = observable.change(Language.French); +const provider: LocaleDynamicProvider = observable; +const current: Language = provider.lang(); +const keys: ReadonlySet = provider.keys(); +const langNames: ReadonlyMap = provider.langNames(); +const available: ReadonlySet = provider.langs(); +const greeting: string = provider.trans("app.greeting", {name: "Ada"}); +const items: string = provider.choice("cart.items", 2, {count: 2}); +const message: string = observable.get("app.greeting"); + +// @ts-expect-error direct observable instances also expose a read-only set +observable.langs().add(Language.French); +// @ts-expect-error direct observable instances also expose a read-only map +observable.langNames().set(Language.French, "Changed"); +// @ts-expect-error storage is a driver or false +useLocale("locale"); +// @ts-expect-error invalid keys remain rejected +locale.t("unknown"); +// @ts-expect-error substitutions remain required +locale.t("app.greeting"); +// @ts-expect-error extra JSX substitutions are rejected +locale.t("app.greeting", {name: Ada, extra: "bad"}); +// @ts-expect-error plural keys cannot be used as ordinary messages +locale.t("cart.items", {count: 2}); +// @ts-expect-error ordinary keys cannot be pluralized +locale.choice("app.title", 2); +// @ts-expect-error plural substitutions remain required +locale.choice("cart.items", 2); +// @ts-expect-error plural selection requires a number +locale.choice("cart.items", 2, {count: 2}); +// @ts-expect-error a node translation is not a string +const attribute: string = locale.t("app.greeting", {name: Ada}); +// @ts-expect-error change returns a Promise +const immediate: Language = locale.change(Language.French); +// @ts-expect-error snapshots are read-only +snapshot.lang = () => Language.French; +// @ts-expect-error snapshot language codes are read-only +snapshot.langs().add(Language.French); +// @ts-expect-error snapshot language names are read-only +snapshot.langNames().set(Language.French, "Changed"); +// @ts-expect-error snapshot keys follow the registry +snapshot.get("unknown"); +// @ts-expect-error delegated translations retain required substitutions +observable.trans("app.greeting"); +// @ts-expect-error delegated translations reject extra substitutions +observable.trans("app.greeting", {name: "Ada", extra: "bad"}); +// @ts-expect-error delegated translations reject JSX +observable.trans("app.greeting", {name: Ada}); +// @ts-expect-error delegated translations keep plural keys separate +observable.trans("cart.items", {count: 2}); +// @ts-expect-error delegated plural selection requires substitutions +observable.choice("cart.items", 2); +// @ts-expect-error delegated plural selection rejects ordinary keys +observable.choice("app.title", 2); +// @ts-expect-error delegated message lookup follows the registry +observable.get("unknown"); + +interface CustomStructure { + custom: {plural: false; substitutions: ["value"]}; +} +const custom = new ObservableLocale(false); +custom.snapshot().get("custom"); +const customProvider: LocaleDynamicProvider = custom; +const customText: string = customProvider.trans("custom", {value: "example"}); +// @ts-expect-error a custom observable retains its own structure +custom.snapshot().get("app.title"); diff --git a/src/cli/plugins/locale/declaration/tests/fixtures/empty-registry.ts b/src/cli/plugins/locale/declaration/tests/fixtures/empty-registry.ts index 84eb25a7..55395b2c 100644 --- a/src/cli/plugins/locale/declaration/tests/fixtures/empty-registry.ts +++ b/src/cli/plugins/locale/declaration/tests/fixtures/empty-registry.ts @@ -1,9 +1,11 @@ import {NativeLocale, DynamicLocale, t, choice, key, resolve, type LocaleRegistry} from "adnbn/locale"; -import {useLocale} from "adnbn/locale/react"; +import {useLocale, useNativeLocale} from "adnbn/locale/react"; const emptyKeys: ReadonlySet = new NativeLocale().keys(); const emptyDynamicKeys: ReadonlySet = new DynamicLocale(false).keys(); const plain: string = resolve("Plain title"); +// @ts-expect-error the dynamic React hook shares the empty registry +useLocale(false).t("app.title"); declare const runtimeMarker: string; resolve(runtimeMarker); // @ts-expect-error an empty registry has no keys @@ -20,5 +22,7 @@ new NativeLocale().trans("app.title"); 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"); +// @ts-expect-error the native React hook shares the empty registry +useNativeLocale().t("app.title"); +// @ts-expect-error an empty registry has no plural keys +useNativeLocale().choice("cart.items", 2, {count: 2}); diff --git a/src/cli/plugins/locale/declaration/tests/fixtures/generated-registry.ts b/src/cli/plugins/locale/declaration/tests/fixtures/generated-registry.ts index 41ad4ebb..247df87e 100644 --- a/src/cli/plugins/locale/declaration/tests/fixtures/generated-registry.ts +++ b/src/cli/plugins/locale/declaration/tests/fixtures/generated-registry.ts @@ -1,5 +1,5 @@ import {Language, NativeLocale, DynamicLocale, t, choice, key, resolve, type LocaleRegistry} from "adnbn/locale"; -import {useLocale} from "adnbn/locale/react"; +import {useNativeLocale} from "adnbn/locale/react"; t("app.title"); t("app.greeting", {name: "Ada"}); @@ -15,15 +15,18 @@ nativeLocale.choice("cart.items", 2, {count: 2}); NativeLocale.getInstance().trans("app.title"); NativeLocale.getInstance().choice("cart.empty", 0); const dynamicLocale = new DynamicLocale(false); +const messages: Readonly> = dynamicLocale.messages(); +const rawTitle: string = messages.app_title; +// @ts-expect-error the messages view is read-only +dynamicLocale.messages().app_title = "Changed"; 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); +useNativeLocale().t("app.greeting", {name: "Ada"}); +useNativeLocale().choice("cart.items", 2, {count: 2}); const allKeys: ReadonlySet<"app.title" | "app.greeting" | "cart.items" | "cart.empty"> = nativeLocale.keys(); const knownKey: keyof LocaleRegistry = "app.title"; // @ts-expect-error no index signature widens the registry @@ -61,8 +64,8 @@ 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"); +useNativeLocale().t("extra"); // @ts-expect-error React retains the substitution contract -useLocale().t("app.greeting"); +useNativeLocale().t("app.greeting"); // @ts-expect-error React retains the plural contract -useLocale().choice("app.title", 2); +useNativeLocale().choice("app.title", 2); diff --git a/src/cli/plugins/locale/declaration/tests/fixtures/language-names.ts b/src/cli/plugins/locale/declaration/tests/fixtures/language-names.ts index cac033c1..7e14e27f 100644 --- a/src/cli/plugins/locale/declaration/tests/fixtures/language-names.ts +++ b/src/cli/plugins/locale/declaration/tests/fixtures/language-names.ts @@ -1,16 +1,36 @@ import {Language, NativeLocale, DynamicLocale, type LocaleProvider} from "adnbn/locale"; -import {useLocale} from "adnbn/locale/react"; +import {useLocale, useNativeLocale} from "adnbn/locale/react"; -const native: Map = new NativeLocale().languageNames(); -const dynamic: Map = new DynamicLocale().languageNames(); +const native: ReadonlyMap = new NativeLocale().langNames(); +const dynamic: ReadonlyMap = new DynamicLocale().langNames(); declare const provider: LocaleProvider<{}>; -const providerNames: Map = provider.languageNames(); -const available: Set = new NativeLocale().languages(); -const names: ReadonlyMap = useLocale().langs; +const providerNames: ReadonlyMap = provider.langNames(); +const available: ReadonlySet = new NativeLocale().langs(); +const names: ReadonlyMap = useNativeLocale().langNames; +const codes: ReadonlySet = useNativeLocale().langs; +const dynamicNames: ReadonlyMap = useLocale().langNames; +const dynamicCodes: ReadonlySet = useLocale().langs; const title: string | undefined = names.get(Language.French); +const includes: boolean = codes.has(Language.French); +// @ts-expect-error the provider contract exposes a read-only set +provider.langs().delete(Language.French); +// @ts-expect-error the provider contract exposes a read-only map +provider.langNames().clear(); +// @ts-expect-error direct native instances also expose a read-only set +new NativeLocale().langs().add(Language.French); +// @ts-expect-error direct native instances also expose a read-only map +new NativeLocale().langNames().set(Language.French, "Changed"); +// @ts-expect-error direct dynamic instances also expose a read-only set +new DynamicLocale(false).langs().clear(); +// @ts-expect-error direct dynamic instances also expose a read-only map +new DynamicLocale(false).langNames().set(Language.French, "Changed"); // @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; +useNativeLocale().langNames.set(Language.French, "Changed"); +// @ts-expect-error React exposes a read-only set +useNativeLocale().langs.add(Language.French); +// @ts-expect-error the dynamic hook also exposes a read-only map +useLocale().langNames.set(Language.French, "Changed"); +// @ts-expect-error the dynamic hook also exposes a read-only set +useLocale().langs.add(Language.French); // @ts-expect-error keys are Language values, not arbitrary strings native.get("unsupported"); diff --git a/src/cli/plugins/locale/declaration/tests/fixtures/native-react.tsx b/src/cli/plugins/locale/declaration/tests/fixtures/native-react.tsx new file mode 100644 index 00000000..256ef431 --- /dev/null +++ b/src/cli/plugins/locale/declaration/tests/fixtures/native-react.tsx @@ -0,0 +1,79 @@ +import type {ReactNode} from "react"; +import {Language, LocaleDir, NativeLocale} from "adnbn/locale"; +import {useLocaleAttributes, useNativeLocale, type LocaleReactContract} from "adnbn/locale/react"; + +const locale: LocaleReactContract = useNativeLocale(); +const attributes: void = useLocaleAttributes(locale); +useLocaleAttributes(locale, undefined); +useLocaleAttributes(locale, "#extension-root"); +useLocaleAttributes(locale, document.documentElement); +useLocaleAttributes(locale, null); +useLocaleAttributes({lang: Language.English, dir: LocaleDir.LeftToRight}); + +// @ts-expect-error a locale is required +useLocaleAttributes(); +// @ts-expect-error only null disables the target +useLocaleAttributes(locale, false); +// @ts-expect-error language and direction are both required +useLocaleAttributes({lang: Language.English}); + +const language: Language = locale.lang; +const codes: ReadonlySet = locale.langs; +const names: ReadonlyMap = locale.langNames; +const direction: LocaleDir = locale.dir; +const rtl: boolean = locale.isRtl; + +const title: string = locale.t("app.title"); +const text: string = locale.t("app.greeting", {name: "Ada"}); +const numeric: string = locale.t("app.greeting", {name: 42}); +const greeting: ReactNode = locale.t("app.greeting", {name: Ada}); +const hidden: ReactNode = locale.t("app.greeting", {name: null}); +const optionalNode: ReactNode = locale.t("app.greeting", {name: undefined}); +const array: ReactNode = locale.t("app.greeting", {name: ["Ada", !]}); +const pluralText: string = locale.choice("cart.items", 2, {count: 2}); +const pluralNode: ReactNode = locale.choice("cart.items", 2, {count: many}); +const pluralEmpty: string = locale.choice("cart.empty", 0); + +// @ts-expect-error the native hook does not expose language changes +locale.change(Language.French); +// @ts-expect-error the native hook does not accept storage options +useNativeLocale(false); +// @ts-expect-error unknown keys are rejected +locale.t("app.unknown"); +// @ts-expect-error substitutions remain required +locale.t("app.greeting"); +// @ts-expect-error a required placeholder cannot be omitted +locale.t("app.greeting", {}); +// @ts-expect-error JSX does not allow extra placeholder names +locale.t("app.greeting", {name: Ada, extra: }); +// @ts-expect-error plain objects are not React nodes +locale.t("app.greeting", {name: {text: "Ada"}}); +// @ts-expect-error keys without placeholders reject substitutions +locale.t("app.title", {}); +// @ts-expect-error plural keys are excluded from t +locale.t("cart.items", {count: 2}); +// @ts-expect-error ordinary keys are excluded from choice +locale.choice("app.title", 2); +// @ts-expect-error plural substitutions remain required +locale.choice("cart.items", 2); +// @ts-expect-error unknown plural substitutions are rejected +locale.choice("cart.items", 2, {count: 2, extra: 1}); +// @ts-expect-error plural keys without placeholders reject substitutions +locale.choice("cart.empty", 2, {}); +// @ts-expect-error plural selection always needs a numeric count +locale.choice("cart.items", 2, {count: 2}); +// @ts-expect-error JSX translations cannot be assigned to string-only attributes +const stringGreeting: string = locale.t("app.greeting", {name: Ada}); +// @ts-expect-error JSX plural translations are not guaranteed to return strings +const stringPlural: string = locale.choice("cart.items", 2, {count: 2}); +// @ts-expect-error React substitutions do not extend the core provider contract +NativeLocale.getInstance().trans("app.greeting", {name: Ada}); + +interface CustomStructure { + custom: {plural: false; substitutions: ["value"]}; +} +declare const custom: LocaleReactContract; +useLocaleAttributes(custom); +custom.t("custom", {value: }); +// @ts-expect-error custom contracts retain their own keys +custom.t("app.title"); diff --git a/src/locale/README.md b/src/locale/README.md index c964167f..7c1a7f1a 100644 --- a/src/locale/README.md +++ b/src/locale/README.md @@ -8,17 +8,20 @@ description: Runtime translation providers, shared formatting, catalogue access, ## Directory responsibilities -| Area | Responsibility | -| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `providers/AbstractLocale.ts` | Common string rendering, missing-message diagnostics, and language names. | -| `providers/NativeLocale.ts` | Browser i18n message lookup and a singleton used by the public helpers. | -| `providers/DynamicLocale.ts` | Compiled translations, language state per instance, and optional storage synchronization. | -| `providers/CustomLocale.ts` | Internal provider for an explicitly supplied language and flat message dictionary; not re-exported by the public entrypoint. | -| `helpers.ts` | Native shortcuts: `t`, `choice`, `key` for browser message references, and `resolve` for strings prefixed with `@`. | -| `catalogue/` | Empty package fallback and typing for the generated data module. | -| `storage/` | Built-in `LocaleStorage` implementation of the public `LocaleStorageDriver` contract. | -| `adapters/react/` | `LocaleProvider`, its context, and `useLocale`; connects dynamic localization to React state and DOM `lang`/`dir` attributes. | -| `index.ts` | Public providers, helpers, and selected shared contracts. | +| Area | Responsibility | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `providers/AbstractLocale.ts` | Common string rendering, missing-message diagnostics, and language names. | +| `providers/NativeLocale.ts` | Browser i18n message lookup and a singleton used by the public helpers. | +| `providers/DynamicLocale.ts` | Compiled translations, language state per instance, and optional storage synchronization. | +| `providers/ObservableLocale.ts` | Observable dynamic state, immutable snapshots, shared instances, and subscription lifetimes. | +| `providers/CustomLocale.ts` | Internal provider for an explicitly supplied language and flat message dictionary; not re-exported by the public entrypoint. | +| `helpers.ts` | Native shortcuts: `t`, `choice`, `key` for browser message references, and `resolve` for strings prefixed with `@`. | +| `catalogue/` | Empty package fallback and typing for the generated data module. | +| `storage/` | Built-in `LocaleStorage` implementation of the public `LocaleStorageDriver` contract. | +| `adapters/react/` | React node rendering, native and dynamic locale hooks, and DOM language attributes through `useLocaleAttributes`. | +| `index.ts` | Public providers, helpers, and selected shared contracts. | + +React hooks and their tests live in `adapters/react/hooks/`. The adapter's `index.ts` re-exports the hooks through `adnbn/locale/react`. ## Ownership and data flow @@ -48,10 +51,114 @@ Core providers have no React dependency. Browser i18n access goes through `@addo Translations are synchronous strings. Providers share substitution and plural processing through `AbstractLocale`; `trans` handles non-plural keys and `choice` selects a form by count. Substitutions accept strings and numbers. Generated public keys use dot notation, while message lookup converts them to underscore keys. Missing messages warn and return the key; empty translations remain empty strings. +Language information uses the same names throughout providers, snapshots, and React hooks: + +| Meaning | Provider / snapshot | React hook | +| ----------------------------------------------- | ------------------- | ----------- | +| Selected language code | `lang()` | `lang` | +| Set of available language codes | `langs()` | `langs` | +| Map of available codes to native language names | `langNames()` | `langNames` | + +Providers, snapshots, and hooks expose `ReadonlySet` and `ReadonlyMap`. These TypeScript types allow reading and iteration without exposing mutation methods; the underlying collections remain ordinary sets and maps. In snapshots and hooks, both collections retain their identity across dynamic language changes; their contents and order match the provider. In a language selector, iterate `langs` and read each label with `langNames.get(code)`. + `DynamicLocale` reads bundled messages without fetching JSON or dynamically importing languages. `change()` updates its state immediately and returns a promise for saving the language code. Without a constructor argument it creates `LocaleStorage`, which uses extension local storage with the fixed namespace `PackageName` (`adnbn`) and default key `locale`. Pass another key to `new LocaleStorage("customLocale")`; the constructor stores it in the protected `key` property used for reading, writing, and watching. Passing `false` to `DynamicLocale` keeps state only in the instance; passing a `LocaleStorageDriver` uses that object directly. +`select(lang): Language` selects a language synchronously without saving it, while `change(lang)` also saves the selection when storage is enabled. An unavailable language makes `select` throw synchronously without changing state. If the selected language differs from the saved value, a later `sync()` or storage event received through `watch()` can replace the local selection. + +`DynamicLocale.messages()` returns a read-only view of the completed dictionary for its current language. It uses underscore keys, includes the `locale` marker and prepared fallbacks, and keeps placeholders and plural forms unrendered. Repeated reads reuse the dictionary; switching languages selects another dictionary without modifying previously returned data. This class-specific method does not extend the shared `LocaleProvider` contract. + In MAIN, use `new DynamicLocale(false)` or supply a custom driver that does not require extension APIs. Translations and language changes work from the bundle, starting with the configured default language. The built-in driver is intended for ISOLATED and extension pages. -Storage initialization is explicit: `sync()` reads the saved language, `watch()` observes subsequent storage events, and `unwatch()` disconnects. The constructor does neither. `watch()` is not a general subscription to in-memory state; `sync()` and `watch()` require enabled storage. The current React provider connects these methods on mount and exposes their results through context. +Storage initialization is explicit: `sync()` reads the saved language, `watch()` observes subsequent storage events, and `unwatch()` disconnects. The constructor does neither. `watch()` is not a general subscription to in-memory state; `sync()` and `watch()` require enabled storage. `LocaleStorageDriver` provides `get(): Promise`, `set(lang): Promise`, and `watch(handler): () => void`. Reading an absent value leaves the current language unchanged. Notifications contain only valid language codes, including own writes, with no initial notification or deletion event. `LocaleStorage` ignores absent values and diagnoses malformed codes; `DynamicLocale` additionally checks that a language exists in its catalogue. Read and write errors propagate to callers. Custom drivers own their persistence and notifications; pass the same driver to providers that should share its state. + +## Native translations in React + +`useNativeLocale()` from `adnbn/locale/react` uses the existing `NativeLocale` singleton without a Provider or storage. It returns `LocaleReactContract`: `t`, `choice`, `lang`, `langs`, `langNames`, `dir`, and `isRtl`. Its language is selected by browser i18n, independently of dynamic language changes. It has no `change` method and requires browser i18n, just like the native helpers. + +```tsx +import {useNativeLocale} from "adnbn/locale/react"; + +function Greeting() { + const {t, choice} = useNativeLocale(); + const title = t("greeting", {name: "Ada"}); + + return ( +
+ {t("greeting", {name: Ada})} + {choice("items", 2, {count: 2})} +
+ ); +} +``` + +For messages such as `"Hello {{name}}"` and `["{{count}} item", "{{count}} items"]`, string/number substitutions return strings; React substitutions return `ReactNode`. The generated registry checks keys, required placeholder names, and plural usage in both overloads. Numeric `count` selects the plural form before substitutions, independently of the displayed node. + +The internal `ReactLocale` class implements `LocaleReactContract` by wrapping an existing message provider. The hook memoizes its instance; `t` and `choice` are bound to that instance so they can be destructured. The class owns React rendering, while the underlying provider owns message lookup. + +The renderer inserts nodes directly and keeps component state, handlers, and refs. It supports repeated placeholders, trims names, and leaves empty or malformed placeholders as text. HTML in translations remains text. The React types and renderer belong to the adapter; the core provider contract continues to accept strings and numbers only. + +## Observable dynamic translations + +`ObservableLocale` implements `LocaleDynamicProvider` and delegates message lookup, string translation, plural forms, keys, and language information to a private `DynamicLocale(false)`. It owns storage, subscriptions, and language changes; the internal provider performs no storage operations. Its `lang()`, `langs()`, `langNames()`, `keys()`, `get()`, `trans()`, and `choice()` methods read the current language. + +`snapshot()` and `subscribe(listener)` expose the observable state. A snapshot fixes the language and its message access, so translations retained from an earlier render stay consistent. Its `lang()`, `langs()`, `langNames()`, and `get(key)` methods are read-only; `get` returns the message before substitutions. The snapshot retains its identity until the language changes and uses a separate `CustomLocale` reader over the dictionary returned by `DynamicLocale.messages()`, without copying translations. Snapshot creation belongs to `ObservableLocale`; access to the generated catalogue stays inside `DynamicLocale`. + +`new ObservableLocale(storage)` creates an independent instance. `ObservableLocale.getInstance()` shares the default storage instance; `getInstance(false)` shares a separate in-memory instance. Passing the same driver object returns the same instance. Different drivers remain separate, even when their underlying storage synchronizes them. Keep custom drivers stable across renders, for example by creating them outside the component. + +Construction does not read or watch storage. The first subscriber connects one storage listener and reads the saved language. The last unsubscribe disconnects it, and reconnecting reads storage again. Each `subscribe()` call returns its own cleanup function; subscriptions also work with `false`. Explicit `sync()` reads the saved language and requires enabled storage. + +`change()` publishes immediately and serializes persistence, including repeated selections of the current language. Old reads and storage echoes cannot overwrite a pending local selection. After successful writes, the provider reconciles with current storage. Save failures reject the returned promise without rolling back the selected language. Errors from automatic synchronization are logged; explicit `sync()` errors propagate to its caller. + +## Dynamic translations in React + +`useLocale(storage?)` returns `LocaleReactDynamicContract`, extending the native React translation contract with `change(lang): Promise`. It connects the shared `ObservableLocale` through React's `useSyncExternalStore`. Components using the same instance update together on local selections and storage changes. The hook renders immediately using the initial language, then restores a saved selection asynchronously. It does not modify DOM attributes. + +```tsx +import {Language} from "adnbn/locale"; +import {useLocale} from "adnbn/locale/react"; + +function Greeting() { + const {t, lang, dir, change} = useLocale(); + return ( +
+

{t("greeting", {name: Ada})}

+ +
+ ); +} +``` + +Pass `false` for memory or a `LocaleStorageDriver` for custom persistence. In MAIN world use one of these modes. Native translations from `useNativeLocale()` remain tied to browser i18n, independently of this dynamic selection. `useLocale` is intended for client-rendered extension interfaces; it does not supply a server snapshot for SSR. + +## DOM language attributes + +`useLocaleAttributes(locale, target?)` from `adnbn/locale/react` applies `lang` and `dir` in an effect. Pass the result of either `useLocale()` or `useNativeLocale()`; the hook only reads those two properties and creates no locale instance or storage subscription. + +An omitted target or `undefined` selects `html`. A selector string selects the first matching element, an `Element` is used directly, and `null` disables application. For a content script, target the extension's container to leave the host page's language unchanged. + +```tsx title="src/popup/App.tsx" +import {useLocale, useLocaleAttributes} from "adnbn/locale/react"; + +export function App() { + const locale = useLocale(); + useLocaleAttributes(locale); + + return
{locale.langNames.get(locale.lang)}
; +} +``` + +Use `useNativeLocale()` in the same pattern for browser-selected localization. To toggle application while keeping the hook mounted, pass `enabled ? "#extension-root" : null` as the target. + +Language, direction, or target changes rerun the effect. Before reapplying and on unmount, cleanup restores each previous attribute only if its current value still equals the value written by the hook. Previously absent attributes are removed. Use one attributes hook per target. Missing elements are skipped; no `MutationObserver` waits for them to appear, so the selector is checked again only when an effect dependency changes. + +## React installation + +React adapters require React and ReactDOM 18 or 19; `useLocale` uses the built-in `useSyncExternalStore`. React, ReactDOM, and their type packages are optional peers of `adnbn`. React projects should install them explicitly. No `use-sync-external-store` shim is required. The current external `@addon-core/storage` package still declares React as a required peer, so it may bring React into an otherwise non-React installation transitively. diff --git a/src/locale/adapters/react/LocaleProvider.tsx b/src/locale/adapters/react/LocaleProvider.tsx deleted file mode 100644 index c02603a2..00000000 --- a/src/locale/adapters/react/LocaleProvider.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React, {PropsWithChildren, useCallback, useEffect, useMemo, useState} from "react"; - -import {LocaleContext, LocaleContract} from "./context"; - -import {getLocaleDir, isLocaleRtl} from "@shared/locale"; - -import {DynamicLocale} from "@locale/providers"; - -import {Language, type LocaleStorageDriver} from "@typing/locale"; - -export interface LocaleProviderProps { - storage?: LocaleStorageDriver | false; - container?: string | Element | false; -} - -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()); - - const t: LocaleContract["t"] = useCallback((key, ...args): string => { - return locale.trans(key, ...args); - }, []); - - const choice: LocaleContract["choice"] = useCallback((key, count, ...args): string => { - return locale.choice(key, count, ...args); - }, []); - - const change: LocaleContract["change"] = useCallback((lang): void => { - locale - .change(lang) - .catch(err => console.error(`[LocaleProvider] Cannot find locale file for "${lang}" language`, err)); - - setLang(locale.lang()); - }, []); - - useEffect(() => { - if (container === false) { - return; - } - - const element = typeof container === "string" ? document.querySelector(container) : container; - - if (element) { - element.setAttribute("lang", lang); - element.setAttribute("dir", getLocaleDir(lang)); - - return () => { - element.removeAttribute("lang"); - element.removeAttribute("dir"); - }; - } - }, [lang, container]); - - useEffect(() => { - if (storage === false) return; - - locale.sync().then(lang => setLang(lang)); - }, []); - - useEffect(() => { - if (storage === false) return; - - return locale.watch(lang => setLang(lang)); - }, []); - - return ( - - {children} - - ); -}; - -export default LocaleProvider; diff --git a/src/locale/adapters/react/ReactLocale.test.ts b/src/locale/adapters/react/ReactLocale.test.ts new file mode 100644 index 00000000..d5e9c5c8 --- /dev/null +++ b/src/locale/adapters/react/ReactLocale.test.ts @@ -0,0 +1,55 @@ +import {createElement} from "react"; +import {cleanup, render} from "@testing-library/react"; +import CustomLocale from "@locale/providers/CustomLocale"; +import {Language, LocaleDir} from "@typing/locale"; +import ReactLocale from "./ReactLocale"; + +interface Structure { + greeting: {plural: false; substitutions: ["name"]}; + items: {plural: true; substitutions: ["count"]}; +} + +afterEach(cleanup); + +test.each([ + [Language.English, 2, "many"], + [Language.French, 0, "one"], + [Language.Japanese, 5, "one"], + [Language.Russian, 5, "other"], +] as const)("the shared React renderer preserves %s plural rules", (lang, count, expected) => { + const locale = new ReactLocale( + new CustomLocale(lang, { + items: "{{count}} one|{{count}} many|{{count}} other", + }) + ); + const {container} = render( + createElement( + "p", + null, + locale.choice("items", count, { + count: createElement("b", null, "display"), + }) + ) + ); + expect(container.innerHTML).toBe(`

display ${expected}

`); +}); + +test("the shared React contract derives RTL properties and preserves empty plural forms", () => { + const locale = new ReactLocale(new CustomLocale(Language.Arabic, {items: "{{count}} zero|"})); + expect(locale.dir).toBe(LocaleDir.RightToLeft); + expect(locale.isRtl).toBe(true); + expect(locale.choice("items", 1, {count: 1})).toBe(""); + expect(locale.choice("items", 3, {count: 3})).toBe("3 zero"); +}); + +test("translation methods remain bound when destructured", () => { + const {t, choice} = new ReactLocale( + new CustomLocale(Language.English, { + greeting: "Hello {{name}}", + items: "{{count}} item|{{count}} items", + }) + ); + + expect(t("greeting", {name: "Ada"})).toBe("Hello Ada"); + expect(choice("items", 2, {count: 2})).toBe("2 items"); +}); diff --git a/src/locale/adapters/react/ReactLocale.ts b/src/locale/adapters/react/ReactLocale.ts new file mode 100644 index 00000000..e35d53af --- /dev/null +++ b/src/locale/adapters/react/ReactLocale.ts @@ -0,0 +1,67 @@ +import {createElement, Fragment, type ReactNode} from "react"; + +import {selectPluralForm, applySubstitutions, getLocaleDir, isLocaleRtl} from "@shared/locale"; + +import type { + Language, + LocaleDir, + LocaleNonPluralKeys, + LocalePluralKeys, + LocaleRegistry, + LocaleSubstitutionArgs, + LocaleSnapshot, +} from "@typing/locale"; + +import type {LocaleReactContract, LocaleReactSubstitutionArgs} from "./types"; + +/** Adapts an existing message provider to React without owning storage or language changes. */ +export default class ReactLocale implements LocaleReactContract { + public readonly lang: Language; + public readonly langs: ReadonlySet; + public readonly langNames: ReadonlyMap; + public readonly dir: LocaleDir; + public readonly isRtl: boolean; + + constructor(private readonly locale: LocaleSnapshot) { + this.lang = locale.lang(); + this.langs = locale.langs(); + this.langNames = locale.langNames(); + this.dir = getLocaleDir(this.lang); + this.isRtl = isLocaleRtl(this.lang); + + this.t = this.t.bind(this); + this.choice = this.choice.bind(this); + } + + public t>(key: K, ...args: LocaleSubstitutionArgs): string; + public t>(key: K, ...args: LocaleReactSubstitutionArgs): ReactNode; + public t(key: LocaleNonPluralKeys, substitutions?: Record): ReactNode { + return this.render(key, substitutions); + } + + public choice>(key: K, count: number, ...args: LocaleSubstitutionArgs): string; + public choice>( + key: K, + count: number, + ...args: LocaleReactSubstitutionArgs + ): ReactNode; + public choice(key: LocalePluralKeys, count: number, substitutions?: Record): ReactNode { + return this.render(key, substitutions, count); + } + + private render(key: keyof S & string, substitutions?: Record, count?: number): ReactNode { + const message = this.locale.get(key); + + const template = count === undefined ? message : selectPluralForm(message, this.lang, count); + + const parts = applySubstitutions(template, substitutions, name => { + console.warn(`Locale substitution "${name}" not found for key "${key}" in "${this.lang}" language.`); + }); + + if (parts.every(part => typeof part === "string" || typeof part === "number")) { + return parts.join(""); + } + + return parts.map((part, index) => createElement(Fragment, {key: index}, part)); + } +} diff --git a/src/locale/adapters/react/context.ts b/src/locale/adapters/react/context.ts deleted file mode 100644 index 08ef4f18..00000000 --- a/src/locale/adapters/react/context.ts +++ /dev/null @@ -1,46 +0,0 @@ -import {createContext, useContext} from "react"; - -import { - LocaleDir, - Language, - type LocaleNonPluralKeys, - type LocaleSubstitutionArgs, - type LocalePluralKeys, - type LocaleRegistry, -} from "@typing/locale"; - -export interface LocaleContract { - lang: Language; - - langs: ReadonlyMap; - - dir: LocaleDir; - - isRtl: boolean; - - t>(key: K, ...args: LocaleSubstitutionArgs): string; - - choice>(key: K, count: number, ...args: LocaleSubstitutionArgs): string; - - change(lang: Language): void; -} - -export const DefaultLocale: LocaleContract = { - lang: Language.English, - langs: new Map(), - isRtl: false, - dir: LocaleDir.LeftToRight, - t(key, ..._args): string { - return key; - }, - choice(key, _count, ..._args): string { - return key; - }, - change(_lang: Language) {}, -}; - -export const LocaleContext = createContext(DefaultLocale); - -LocaleContext.displayName = "LocaleContext"; - -export const useLocale = () => useContext(LocaleContext); diff --git a/src/locale/adapters/react/hooks/tests/fixtures/dynamic-locale.ts b/src/locale/adapters/react/hooks/tests/fixtures/dynamic-locale.ts new file mode 100644 index 00000000..fed5e44b --- /dev/null +++ b/src/locale/adapters/react/hooks/tests/fixtures/dynamic-locale.ts @@ -0,0 +1,11 @@ +import catalogue from "./dynamic-messages.json"; + +export {catalogue as default}; +export const lang = "en"; +export const keys = ["greeting", "items"]; +export const languages = ["en", "fr", "ar"]; + +export interface Structure { + greeting: {plural: false; substitutions: ["name"]}; + items: {plural: true; substitutions: ["count"]}; +} diff --git a/src/locale/adapters/react/hooks/tests/fixtures/dynamic-messages.json b/src/locale/adapters/react/hooks/tests/fixtures/dynamic-messages.json new file mode 100644 index 00000000..4cecc911 --- /dev/null +++ b/src/locale/adapters/react/hooks/tests/fixtures/dynamic-messages.json @@ -0,0 +1,14 @@ +{ + "en": { + "greeting": "Hello {{ name }}", + "items": "{{count}} item|{{count}} items" + }, + "fr": { + "greeting": "Bonjour {{ name }}", + "items": "{{count}} article|{{count}} articles" + }, + "ar": { + "greeting": "مرحبًا {{ name }}", + "items": "{{count}} صفر|{{count}} واحد|{{count}} اثنان|{{count}} قليل|{{count}} كثير|{{count}} آخر" + } +} diff --git a/src/locale/adapters/react/hooks/tests/fixtures/native-messages.json b/src/locale/adapters/react/hooks/tests/fixtures/native-messages.json new file mode 100644 index 00000000..b6e8ce1c --- /dev/null +++ b/src/locale/adapters/react/hooks/tests/fixtures/native-messages.json @@ -0,0 +1,9 @@ +{ + "locale": "en", + "app_title": "Native title", + "app_greeting": "Hello {{ name }}! Again: {{name}}.", + "app_action": "Open {{action}}", + "app_items": "{{count}} item|{{count}} items", + "app_literal": "{{name}} {{}} {{ }} {{broken} {broken}}", + "app_empty": "" +} diff --git a/src/locale/adapters/react/hooks/use-locale-attributes.test.ts b/src/locale/adapters/react/hooks/use-locale-attributes.test.ts new file mode 100644 index 00000000..3f45d0ea --- /dev/null +++ b/src/locale/adapters/react/hooks/use-locale-attributes.test.ts @@ -0,0 +1,204 @@ +jest.mock("@addon-core/browser", () => ({getI18nMessage: jest.fn(() => "en")})); +jest.mock("#adnbn/locale", () => require("./tests/fixtures/dynamic-locale")); + +import {createElement, StrictMode} from "react"; +import {renderToString} from "react-dom/server"; +import {act, cleanup, renderHook} from "@testing-library/react"; +import {useLocale, useLocaleAttributes, useNativeLocale, type LocaleReactContract} from "../index"; +import {MemoryLocaleStorage} from "@locale/tests/fixtures"; +import {Language, LocaleDir} from "@typing/locale"; + +const english: Pick = { + lang: Language.English, + dir: LocaleDir.LeftToRight, +}; +const arabic: Pick = { + lang: Language.Arabic, + dir: LocaleDir.RightToLeft, +}; + +afterEach(() => { + cleanup(); + document.documentElement.removeAttribute("lang"); + document.documentElement.removeAttribute("dir"); + document.body.replaceChildren(); + jest.restoreAllMocks(); +}); + +test("defaults to html and restores existing and absent attributes in StrictMode", () => { + const element = document.documentElement; + element.setAttribute("lang", "de"); + const hook = renderHook(() => useLocaleAttributes(english), {wrapper: StrictMode}); + + expect(element.getAttribute("lang")).toBe("en"); + expect(element.getAttribute("dir")).toBe("ltr"); + + hook.unmount(); + expect(element.getAttribute("lang")).toBe("de"); + expect(element.hasAttribute("dir")).toBe(false); +}); + +test("updates language and direction while retaining the original attributes for cleanup", () => { + const element = document.createElement("section"); + element.setAttribute("lang", ""); + element.setAttribute("dir", "auto"); + const hook = renderHook(({locale}) => useLocaleAttributes(locale, element), { + initialProps: {locale: english}, + }); + + hook.rerender({locale: arabic}); + expect(element.getAttribute("lang")).toBe("ar"); + expect(element.getAttribute("dir")).toBe("rtl"); + + hook.rerender({locale: {...arabic, lang: Language.Hebrew}}); + expect(element.getAttribute("lang")).toBe("he"); + hook.rerender({locale: {...arabic, lang: Language.Hebrew, dir: LocaleDir.LeftToRight}}); + expect(element.getAttribute("dir")).toBe("ltr"); + + hook.unmount(); + expect(element.getAttribute("lang")).toBe(""); + expect(element.getAttribute("dir")).toBe("auto"); +}); + +test("selects only the first match and restores it when switching to a direct element", () => { + const first = document.createElement("section"); + const second = document.createElement("section"); + first.className = second.className = "localized"; + first.setAttribute("lang", "de"); + document.body.append(first, second); + const hook = renderHook(({target}) => useLocaleAttributes(english, target), { + initialProps: {target: ".localized"}, + }); + + expect(first.getAttribute("lang")).toBe("en"); + expect(first.getAttribute("dir")).toBe("ltr"); + expect(second.hasAttribute("lang")).toBe(false); + expect(second.hasAttribute("dir")).toBe(false); + + hook.rerender({target: second}); + expect(first.getAttribute("lang")).toBe("de"); + expect(first.hasAttribute("dir")).toBe(false); + expect(second.getAttribute("lang")).toBe("en"); + expect(second.getAttribute("dir")).toBe("ltr"); + + hook.unmount(); + expect(second.hasAttribute("lang")).toBe(false); + expect(second.hasAttribute("dir")).toBe(false); +}); + +test("null disables application and undefined enables the default target again", () => { + const element = document.documentElement; + element.setAttribute("lang", "de"); + const hook = renderHook( + ({target}) => useLocaleAttributes(english, target), + { + initialProps: {target: null}, + } + ); + + expect(element.getAttribute("lang")).toBe("de"); + expect(element.hasAttribute("dir")).toBe(false); + hook.rerender({target: undefined}); + expect(element.getAttribute("lang")).toBe("en"); + expect(element.getAttribute("dir")).toBe("ltr"); + + hook.rerender({target: null}); + expect(element.getAttribute("lang")).toBe("de"); + expect(element.hasAttribute("dir")).toBe(false); + hook.rerender({target: undefined}); + expect(element.getAttribute("lang")).toBe("en"); +}); + +test("skips an absent target without observing elements added later", () => { + const hook = renderHook(({locale}) => useLocaleAttributes(locale, "#late-target"), { + initialProps: {locale: english}, + }); + const element = document.createElement("section"); + element.id = "late-target"; + document.body.append(element); + + expect(element.hasAttribute("lang")).toBe(false); + hook.rerender({locale: arabic}); + expect(element.getAttribute("lang")).toBe("ar"); + expect(element.getAttribute("dir")).toBe("rtl"); +}); + +test.each(["lang", "dir"])("preserves an external change to %s and restores the other attribute", attribute => { + const element = document.createElement("section"); + element.setAttribute("lang", "de"); + element.setAttribute("dir", "auto"); + const hook = renderHook(() => useLocaleAttributes(english, element)); + + if (attribute === "lang") element.setAttribute("lang", "fr"); + else element.removeAttribute("dir"); + + hook.unmount(); + expect(element.getAttribute("lang")).toBe(attribute === "lang" ? "fr" : "de"); + expect(element.getAttribute("dir")).toBe(attribute === "dir" ? null : "auto"); +}); + +test("does not reapply attributes when only the locale object identity changes", () => { + const element = document.createElement("section"); + const hook = renderHook(({locale}) => useLocaleAttributes(locale, element), { + initialProps: {locale: english}, + }); + element.setAttribute("lang", "de"); + const write = jest.spyOn(element, "setAttribute"); + + hook.rerender({locale: {...english}}); + expect(write).not.toHaveBeenCalled(); + expect(element.getAttribute("lang")).toBe("de"); +}); + +test("does not modify the DOM during server rendering", () => { + const element = document.documentElement; + element.setAttribute("lang", "de"); + function App() { + useLocaleAttributes(english); + return null; + } + + expect(renderToString(createElement(App))).toBe(""); + expect(element.getAttribute("lang")).toBe("de"); + expect(element.hasAttribute("dir")).toBe(false); +}); + +test("accepts the native hook's locale", () => { + const hook = renderHook(() => { + const locale = useNativeLocale(); + useLocaleAttributes(locale); + return locale; + }); + + expect(document.documentElement.getAttribute("lang")).toBe(hook.result.current.lang); + expect(document.documentElement.getAttribute("dir")).toBe(hook.result.current.dir); +}); + +test("follows dynamic selections and external storage changes through the supplied hook", async () => { + const storage = new MemoryLocaleStorage(); + const element = document.createElement("section"); + const hook = renderHook(() => { + const locale = useLocale(storage); + useLocaleAttributes(locale, element); + return locale; + }); + + expect(element.getAttribute("lang")).toBe("en"); + expect(storage.listenerCount).toBe(1); + await act(async () => { + await hook.result.current.change(Language.Arabic); + }); + expect(element.getAttribute("lang")).toBe("ar"); + expect(element.getAttribute("dir")).toBe("rtl"); + + await act(async () => { + await storage.set(Language.French); + }); + expect(element.getAttribute("lang")).toBe("fr"); + expect(element.getAttribute("dir")).toBe("ltr"); + + hook.unmount(); + expect(element.hasAttribute("lang")).toBe(false); + expect(element.hasAttribute("dir")).toBe(false); + expect(storage.listenerCount).toBe(0); +}); diff --git a/src/locale/adapters/react/hooks/use-locale-attributes.ts b/src/locale/adapters/react/hooks/use-locale-attributes.ts new file mode 100644 index 00000000..78d9f123 --- /dev/null +++ b/src/locale/adapters/react/hooks/use-locale-attributes.ts @@ -0,0 +1,42 @@ +import {useEffect} from "react"; +import type {LocaleReactContract} from "../types"; + +/** + * Applies language attributes from either locale hook to an element after render. + * An omitted target or undefined selects html; null disables application. + * Cleanup restores each previous attribute only while its applied value is unchanged. + */ +export const useLocaleAttributes = ( + locale: Pick, + target: string | Element | null = "html" +): void => { + const {lang, dir} = locale; + + useEffect(() => { + const element = typeof target === "string" ? document.querySelector(target) : target; + + if (!element) return; + + const attributes = Object.entries({lang, dir}).map(([name, value]) => ({ + name, + value, + previous: element.getAttribute(name), + })); + + for (const {name, value} of attributes) { + element.setAttribute(name, value); + } + + return () => { + for (const {name, value, previous} of attributes) { + if (element.getAttribute(name) !== value) continue; + + if (previous === null) { + element.removeAttribute(name); + } else { + element.setAttribute(name, previous); + } + } + }; + }, [target, lang, dir]); +}; diff --git a/src/locale/adapters/react/hooks/use-locale.test.ts b/src/locale/adapters/react/hooks/use-locale.test.ts new file mode 100644 index 00000000..2f69210b --- /dev/null +++ b/src/locale/adapters/react/hooks/use-locale.test.ts @@ -0,0 +1,173 @@ +jest.mock("@addon-core/browser", () => ({ + getI18nMessage: jest.fn((key: string) => (key === "locale" ? "en" : "Native {{name}}")), +})); +jest.mock("#adnbn/locale", () => require("./tests/fixtures/dynamic-locale")); + +import {createElement, StrictMode, useState} from "react"; +import {renderToString} from "react-dom/server"; +import {act, cleanup, fireEvent, render, renderHook, waitFor} from "@testing-library/react"; +import {useLocale, useNativeLocale, type LocaleReactDynamicContract} from "../index"; +import {ObservableLocale} from "@locale/providers"; +import {MemoryLocaleStorage} from "@locale/tests/fixtures"; +import {Language, LocaleDir, type LocaleStorageDriver} from "@typing/locale"; + +import type {Structure} from "./tests/fixtures/dynamic-locale"; + +// Fixture keys differ from the generated registry; declaration fixtures verify inference separately. +const useFixtureLocale = (storage?: LocaleStorageDriver | false) => + useLocale(storage) as LocaleReactDynamicContract; + +afterEach(() => { + cleanup(); + jest.restoreAllMocks(); +}); + +test("two roots share an instance, update immediately and keep previous render functions consistent", async () => { + const storage = new MemoryLocaleStorage(); + const gate = Promise.withResolvers(); + const originalSet = storage.set.bind(storage); + jest.spyOn(storage, "set").mockImplementationOnce(async lang => { + await gate.promise; + await originalSet(lang); + }); + const first = renderHook(() => useFixtureLocale(storage)); + const second = renderHook(() => useFixtureLocale(storage), {wrapper: StrictMode}); + const old = first.result.current; + const provider = ObservableLocale.getInstance(storage); + expect([...old.langs]).toEqual([Language.English, Language.French, Language.Arabic]); + expect([...old.langNames]).toEqual([ + [Language.English, "English"], + [Language.French, "Français"], + [Language.Arabic, "العربية"], + ]); + expect([...old.langs]).toEqual([...provider.langs()]); + expect([...old.langNames]).toEqual([...provider.langNames()]); + expect(old.langs).toBe(provider.snapshot().langs()); + expect(old.langNames).toBe(provider.snapshot().langNames()); + let saved!: Promise; + act(() => { + saved = first.result.current.change(Language.French); + }); + expect(first.result.current.lang).toBe(Language.French); + expect(second.result.current.lang).toBe(Language.French); + expect(first.result.current.t("greeting", {name: "Ada"})).toBe("Bonjour Ada"); + expect(old.t("greeting", {name: "Ada"})).toBe("Hello Ada"); + expect(old.choice("items", 0, {count: 0})).toBe("0 items"); + expect(first.result.current.choice("items", 0, {count: 0})).toBe("0 article"); + expect(first.result.current.change).toBe(old.change); + expect(first.result.current.langs).toBe(old.langs); + expect(first.result.current.langNames).toBe(old.langNames); + expect(second.result.current.langs).toBe(old.langs); + expect(second.result.current.langNames).toBe(old.langNames); + expect(storage.listenerCount).toBe(1); + await act(async () => { + gate.resolve(); + await saved; + }); + const current = first.result.current; + first.rerender(); + expect(first.result.current).toBe(current); + first.unmount(); + expect(storage.listenerCount).toBe(1); + second.unmount(); + expect(storage.listenerCount).toBe(0); +}); + +test("reacts to external storage changes and rereads the choice after remounting", async () => { + const storage = new MemoryLocaleStorage(Language.French); + const write = jest.spyOn(storage, "set"); + const first = renderHook(() => useFixtureLocale(storage), {wrapper: StrictMode}); + await waitFor(() => expect(first.result.current.lang).toBe(Language.French)); + expect(write).not.toHaveBeenCalled(); + await act(async () => { + await storage.set(Language.Arabic); + }); + expect(first.result.current.dir).toBe(LocaleDir.RightToLeft); + expect(first.result.current.isRtl).toBe(true); + first.unmount(); + expect(storage.listenerCount).toBe(0); + await storage.set(Language.English); + const second = renderHook(() => useFixtureLocale(storage)); + await waitFor(() => expect(second.result.current.lang).toBe(Language.English)); +}); + +test("memory mode is shared and independent from other drivers and native locale", async () => { + await ObservableLocale.getInstance(false).change(Language.English); + const first = renderHook(() => useFixtureLocale(false)); + const second = renderHook(() => useFixtureLocale(false)); + const separateStorage = new MemoryLocaleStorage(); + const separate = renderHook(() => useFixtureLocale(separateStorage)); + const native = renderHook(useNativeLocale); + expect([...native.result.current.langs]).toEqual([...first.result.current.langs]); + expect([...native.result.current.langNames]).toEqual([...first.result.current.langNames]); + await act(async () => { + await first.result.current.change(Language.French); + }); + expect(second.result.current.lang).toBe(Language.French); + expect(separate.result.current.lang).toBe(Language.English); + expect(native.result.current.lang).toBe(Language.English); +}); + +test("changing the storage argument switches subscription ownership", async () => { + const first = new MemoryLocaleStorage(Language.English); + const second = new MemoryLocaleStorage(Language.French); + const hook = renderHook(({storage}) => useFixtureLocale(storage), {initialProps: {storage: first}}); + hook.rerender({storage: second}); + await waitFor(() => expect(hook.result.current.lang).toBe(Language.French)); + expect(first.listenerCount).toBe(0); + expect(second.listenerCount).toBe(1); + await act(async () => { + await first.set(Language.Arabic); + }); + expect(hook.result.current.lang).toBe(Language.French); +}); + +test("keeps JSX component state while translations and plural forms change", async () => { + const storage = new MemoryLocaleStorage(); + function Counter() { + const [count, setCount] = useState(0); + return createElement("button", {onClick: () => setCount(value => value + 1)}, count); + } + function Greeting() { + const {t, choice} = useFixtureLocale(storage); + return createElement( + "main", + null, + createElement("p", null, t("greeting", {name: createElement(Counter)})), + createElement("p", null, choice("items", 0, {count: createElement("b", null, "0")})) + ); + } + const view = render(createElement(Greeting)); + const button = view.getByRole("button"); + fireEvent.click(button); + expect(view.container.textContent).toBe("Hello 10 items"); + await act(async () => { + await ObservableLocale.getInstance(storage).change(Language.French); + }); + expect(view.getByRole("button")).toBe(button); + expect(button.textContent).toBe("1"); + expect(view.container.textContent).toBe("Bonjour 10 article"); +}); + +test("returns save errors to the caller while displaying the selected language", async () => { + const storage = new MemoryLocaleStorage(); + jest.spyOn(storage, "set").mockRejectedValue(new Error("read only")); + const {result} = renderHook(() => useFixtureLocale(storage)); + await act(async () => { + await expect(result.current.change(Language.French)).rejects.toThrow("read only"); + }); + expect(result.current.lang).toBe(Language.French); +}); + +test("render alone performs no storage reads or subscriptions", () => { + const storage = new MemoryLocaleStorage(); + const read = jest.spyOn(storage, "get"); + function Greeting() { + useFixtureLocale(storage); + return null; + } + // This browser-only hook intentionally has no server snapshot; subscriptions must still stay idle. + expect(() => renderToString(createElement(Greeting))).toThrow(/Missing getServerSnapshot/); + expect(read).not.toHaveBeenCalled(); + expect(storage.listenerCount).toBe(0); +}); diff --git a/src/locale/adapters/react/hooks/use-locale.ts b/src/locale/adapters/react/hooks/use-locale.ts new file mode 100644 index 00000000..39d0638a --- /dev/null +++ b/src/locale/adapters/react/hooks/use-locale.ts @@ -0,0 +1,21 @@ +import {useMemo, useSyncExternalStore} from "react"; + +import ReactLocale from "../ReactLocale"; + +import {ObservableLocale} from "@locale/providers"; + +import type {LocaleReactDynamicContract} from "../types"; +import type {LocaleRegistry, LocaleStorageDriver} from "@typing/locale"; + +export const useLocale = (storage?: LocaleStorageDriver | false): LocaleReactDynamicContract => { + const locale = useMemo(() => ObservableLocale.getInstance(storage), [storage]); + const snapshot = useSyncExternalStore(locale.subscribe, locale.snapshot); + + return useMemo( + () => + Object.assign(new ReactLocale(snapshot), { + change: locale.change, + }), + [locale, snapshot] + ); +}; diff --git a/src/locale/adapters/react/hooks/use-native-locale.test.ts b/src/locale/adapters/react/hooks/use-native-locale.test.ts new file mode 100644 index 00000000..9479732f --- /dev/null +++ b/src/locale/adapters/react/hooks/use-native-locale.test.ts @@ -0,0 +1,176 @@ +jest.mock("@addon-core/browser", () => ({getI18nMessage: jest.fn()})); +jest.mock("#adnbn/locale", () => ({ + keys: Object.keys(require("./tests/fixtures/native-messages.json")).map(key => key.replaceAll("_", ".")), + languages: ["en", "fr"], +})); + +import {createElement, createRef, StrictMode, useState} from "react"; +import {createPortal} from "react-dom"; +import {cleanup, fireEvent, render, renderHook} from "@testing-library/react"; +import {getI18nMessage} from "@addon-core/browser"; +import {useNativeLocale, type LocaleReactContract} from "../index"; +import {NativeLocale} from "@locale/providers"; +import {Language, LocaleDir} from "@typing/locale"; +import messages from "./tests/fixtures/native-messages.json"; + +interface Structure { + "app.title": {plural: false; substitutions: []}; + "app.greeting": {plural: false; substitutions: ["name"]}; + "app.action": {plural: false; substitutions: ["action"]}; + "app.items": {plural: true; substitutions: ["count"]}; + "app.literal": {plural: false; substitutions: ["name"]}; + "app.empty": {plural: false; substitutions: []}; + "app.missing": {plural: false; substitutions: []}; +} + +// Runtime fixtures have their own keys; generated-registry fixtures verify the inferred public hook type. +const useFixtureLocale = () => useNativeLocale() as LocaleReactContract; + +describe("useNativeLocale", () => { + beforeEach(() => { + jest.mocked(getI18nMessage).mockImplementation(key => messages[key as keyof typeof messages] ?? ""); + }); + + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + test("works without a Provider and shares the existing native singleton across roots", () => { + const native = NativeLocale.getInstance(); + const read = jest.spyOn(native, "get"); + const first = renderHook(useFixtureLocale, {wrapper: StrictMode}); + const second = renderHook(useFixtureLocale); + const initial = first.result.current; + + expect(initial.lang).toBe(Language.English); + expect([...initial.langs]).toEqual([Language.English, Language.French]); + expect(initial.langNames).toEqual( + new Map([ + [Language.English, "English"], + [Language.French, "Français"], + ]) + ); + expect([...initial.langs]).toEqual([...native.langs()]); + expect([...initial.langNames]).toEqual([...native.langNames()]); + expect(initial.dir).toBe(LocaleDir.LeftToRight); + expect(initial.isRtl).toBe(false); + expect(initial).not.toHaveProperty("change"); + expect(initial.t("app.title")).toBe("Native title"); + expect(second.result.current.t("app.title")).toBe("Native title"); + expect(read).toHaveBeenCalledTimes(2); + + first.rerender(); + expect(first.result.current).toBe(initial); + }); + + test("returns strings for text and numeric substitutions, selecting plurals before replacement", () => { + const {result} = renderHook(useFixtureLocale); + expect(result.current.t("app.greeting", {name: "Ada"})).toBe("Hello Ada! Again: Ada."); + expect(result.current.choice("app.items", 1, {count: 1})).toBe("1 item"); + expect(result.current.choice("app.items", 2, {count: 0})).toBe("0 items"); + expect(result.current.choice("app.items", 2, {count: "one|two"})).toBe("one|two items"); + }); + + test("renders repeated JSX placeholders without adding DOM wrappers", () => { + const warn = jest.spyOn(console, "error").mockImplementation(); + const Greeting = () => { + const {t} = useFixtureLocale(); + return createElement("p", null, t("app.greeting", {name: createElement("strong", null, "Ada")})); + }; + const {container} = render(createElement(Greeting)); + expect(container.innerHTML).toBe("

Hello Ada! Again: Ada.

"); + expect(warn).not.toHaveBeenCalled(); + }); + + test("preserves component state, event handlers and refs across parent renders", () => { + const button = createRef(); + const Counter = () => { + const [count, setCount] = useState(0); + return createElement("button", {ref: button, onClick: () => setCount(count + 1)}, String(count)); + }; + const Action = () => { + const {t} = useFixtureLocale(); + return createElement("p", null, t("app.action", {action: createElement(Counter)})); + }; + const {getByRole, rerender, unmount} = render(createElement(Action)); + const element = getByRole("button"); + expect(button.current).toBe(element); + fireEvent.click(element); + rerender(createElement(Action)); + expect(getByRole("button")).toBe(element); + expect(element.textContent).toBe("1"); + unmount(); + expect(button.current).toBeNull(); + }); + + test("selects the plural with the numeric count while displaying a React node", () => { + const Items = () => { + const {choice} = useFixtureLocale(); + return createElement("p", null, choice("app.items", 2, {count: createElement("span", null, "many|more")})); + }; + const {container} = render(createElement(Items)); + expect(container.innerHTML).toBe("

many|more items

"); + }); + + test("supports empty nodes, arrays and portals as substitutions", () => { + const portal = document.createElement("aside"); + const {result} = renderHook(useFixtureLocale); + const {container, rerender} = render(createElement("p", null, result.current.t("app.action", {action: null}))); + expect(container.textContent).toBe("Open "); + for (const action of [false, undefined]) { + rerender(createElement("p", null, result.current.t("app.action", {action}))); + expect(container.textContent).toBe("Open "); + } + rerender( + createElement( + "p", + null, + result.current.t("app.action", { + action: ["the ", createElement("b", {key: "label"}, "panel")], + }) + ) + ); + expect(container.innerHTML).toBe("

Open the panel

"); + rerender( + createElement( + "p", + null, + result.current.t("app.action", { + action: createPortal(createElement("b", null, "panel"), portal), + }) + ) + ); + expect(container.textContent).toBe("Open "); + expect(portal.innerHTML).toBe("panel"); + }); + + test("treats HTML and malformed placeholders as text", () => { + const {result} = renderHook(useFixtureLocale); + const {container} = render( + createElement( + "p", + null, + result.current.t("app.literal", { + name: createElement("span", null, "Ada"), + }) + ) + ); + expect(container.textContent).toBe("Ada {{}} {{ }} {{broken} {broken}}"); + expect(container.querySelector("b")).toBeNull(); + expect(container.querySelector("span")?.textContent).toBe("Ada"); + }); + + test("preserves empty messages and the existing missing-message and substitution diagnostics", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(); + const {result} = renderHook(useFixtureLocale); + expect(result.current.t("app.empty")).toBe(""); + expect(warn).not.toHaveBeenCalled(); + expect(result.current.t("app.missing")).toBe("app.missing"); + expect(warn).toHaveBeenCalledWith('Locale key "app.missing" not found in "en" language.'); + expect(result.current.t("app.greeting", {} as never)).toBe("Hello name! Again: name."); + expect(warn).toHaveBeenCalledWith( + 'Locale substitution "name" not found for key "app.greeting" in "en" language.' + ); + }); +}); diff --git a/src/locale/adapters/react/hooks/use-native-locale.ts b/src/locale/adapters/react/hooks/use-native-locale.ts new file mode 100644 index 00000000..bc5efbe3 --- /dev/null +++ b/src/locale/adapters/react/hooks/use-native-locale.ts @@ -0,0 +1,10 @@ +import {useMemo} from "react"; +import NativeLocale from "@locale/providers/NativeLocale"; +import ReactLocale from "../ReactLocale"; +import type {LocaleRegistry} from "@typing/locale"; +import type {LocaleReactContract} from "../types"; + +/** Uses browser-selected translations with React substitutions. */ +export const useNativeLocale = (): LocaleReactContract => { + return useMemo(() => new ReactLocale(NativeLocale.getInstance()), []); +}; diff --git a/src/locale/adapters/react/index.ts b/src/locale/adapters/react/index.ts index 19b0f332..17913616 100644 --- a/src/locale/adapters/react/index.ts +++ b/src/locale/adapters/react/index.ts @@ -1,2 +1,4 @@ -export {default as LocaleProvider, type LocaleProviderProps} from "./LocaleProvider"; -export {useLocale, type LocaleContract} from "./context"; +export {useNativeLocale} from "./hooks/use-native-locale"; +export {useLocale} from "./hooks/use-locale"; +export {useLocaleAttributes} from "./hooks/use-locale-attributes"; +export type {LocaleReactContract, LocaleReactDynamicContract} from "./types"; diff --git a/src/locale/adapters/react/localeProvider.test.ts b/src/locale/adapters/react/localeProvider.test.ts deleted file mode 100644 index 97c4008c..00000000 --- a/src/locale/adapters/react/localeProvider.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -jest.mock("@addon-core/browser", () => ({ - getI18nMessage: jest.fn(() => "en"), -})); -jest.mock("#adnbn/locale", () => ({ - __esModule: true, - default: require("./tests/fixtures/catalogue.json"), - keys: ["demo.empty"], - languages: ["en", "fr", "ru"], -})); - -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 {createLocaleStorage} from "../../tests/fixtures/storage"; -import {useLocale, type LocaleContract} from "./context"; -import {Language} from "@typing/locale"; - -describe("React locale provider", () => { - const fetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, "fetch"); - - beforeEach(() => { - jest.mocked(getI18nMessage).mockReset().mockReturnValue("en"); - Object.defineProperty(globalThis, "fetch", { - configurable: true, - value: jest.fn(() => { - throw new Error("Translations must come from the catalogue"); - }), - }); - }); - - afterEach(() => { - cleanup(); - jest.restoreAllMocks(); - - if (fetchDescriptor) { - Object.defineProperty(globalThis, "fetch", fetchDescriptor); - } else { - Reflect.deleteProperty(globalThis, "fetch"); - } - }); - - 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 catalogue translation on the first render without warnings or fetching", async () => { - jest.mocked(getI18nMessage).mockImplementation(key => (key === "locale" ? "ru" : "")); - const warn = jest.spyOn(console, "warn").mockImplementation(); - const wrapper = ({children}: PropsWithChildren) => createElement(LocaleProvider, {container: false}, 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(); - expect(getI18nMessage).toHaveBeenCalledTimes(1); - }); - - test("keeps the available language map stable while changing the selected language", async () => { - const wrapper = ({children}: PropsWithChildren) => createElement(LocaleProvider, {container: false}, 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"], - [Language.Russian, "Русский"], - ]); - - 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, Language.Russian]); - }); - - test("uses the supplied driver for saved and subsequent selections", async () => { - const storage = createLocaleStorage(Language.French); - const wrapper = ({children}: PropsWithChildren) => - createElement(LocaleProvider, {container: false, storage}, children); - const {result} = renderHook(() => useLocale(), {wrapper}); - await waitFor(() => expect(result.current.lang).toBe(Language.French)); - act(() => result.current.change(Language.Russian)); - await waitFor(() => expect(result.current.lang).toBe(Language.Russian)); - await expect(storage.get()).resolves.toBe(Language.Russian); - }); - - test("changes language without storage synchronization when storage is disabled", async () => { - const wrapper = ({children}: PropsWithChildren) => - createElement(LocaleProvider, {container: false, storage: false}, children); - const {result} = renderHook(() => useLocale(), {wrapper}); - await act(async () => {}); - expect(result.current.lang).toBe(Language.English); - act(() => result.current.change(Language.French)); - expect(result.current.lang).toBe(Language.French); - }); -}); diff --git a/src/locale/adapters/react/tests/fixtures/catalogue.json b/src/locale/adapters/react/tests/fixtures/catalogue.json deleted file mode 100644 index 059e298f..00000000 --- a/src/locale/adapters/react/tests/fixtures/catalogue.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "en": { - "locale": "en", - "demo_empty": "" - }, - "fr": { - "locale": "fr", - "demo_empty": "" - }, - "ru": { - "locale": "ru", - "demo_empty": "" - } -} diff --git a/src/locale/adapters/react/types.ts b/src/locale/adapters/react/types.ts new file mode 100644 index 00000000..09b1749d --- /dev/null +++ b/src/locale/adapters/react/types.ts @@ -0,0 +1,39 @@ +import type {ReactNode} from "react"; +import type { + Language, + LocaleDir, + LocaleNonPluralKeys, + LocalePluralKeys, + LocaleRegistry, + LocaleSubstitutionArgs, + LocaleSubstitutionKeys, +} from "@typing/locale"; + +export type LocaleReactSubstitutionArgs = string extends keyof S + ? [substitutions?: Record] + : [LocaleSubstitutionKeys] extends [never] + ? [] + : [substitutions: Record, ReactNode>]; + +/** Translation capabilities shared by React adapters, without language mutation or storage. */ +export interface LocaleReactContract { + /** Currently selected language code. */ + readonly lang: Language; + /** Available language codes. */ + readonly langs: ReadonlySet; + /** Native names keyed by the available language codes. */ + readonly langNames: ReadonlyMap; + readonly dir: LocaleDir; + readonly isRtl: boolean; + + t>(key: K, ...args: LocaleSubstitutionArgs): string; + t>(key: K, ...args: LocaleReactSubstitutionArgs): ReactNode; + + choice>(key: K, count: number, ...args: LocaleSubstitutionArgs): string; + choice>(key: K, count: number, ...args: LocaleReactSubstitutionArgs): ReactNode; +} + +/** React translations with an observable language selection. */ +export interface LocaleReactDynamicContract extends LocaleReactContract { + change(lang: Language): Promise; +} diff --git a/src/locale/index.ts b/src/locale/index.ts index 4dc14a4d..bf6bbc7c 100644 --- a/src/locale/index.ts +++ b/src/locale/index.ts @@ -10,6 +10,7 @@ export { type LocaleRegistry, type LocaleProvider, type LocaleDynamicProvider, + type LocaleSnapshot, type LocaleStorageDriver, type LocaleKeys, type LocaleSubstitutionArgs, diff --git a/src/locale/providers/AbstractLocale.ts b/src/locale/providers/AbstractLocale.ts index 1c78f23d..f34911e2 100644 --- a/src/locale/providers/AbstractLocale.ts +++ b/src/locale/providers/AbstractLocale.ts @@ -16,11 +16,11 @@ export default abstract class AbstractLocale public abstract keys(): Set; - public abstract languages(): Set; + public abstract langs(): ReadonlySet; /** Returns native names for this provider's languages, preserving their order. */ - public languageNames(): Map { - return new Map([...this.languages()].map(language => [language, LanguageNames[language]])); + public langNames(): ReadonlyMap { + return new Map([...this.langs()].map(language => [language, LanguageNames[language]])); } protected abstract value(key: keyof S & string): string | undefined; diff --git a/src/locale/providers/CustomLocale.test.ts b/src/locale/providers/CustomLocale.test.ts index 49a9ff4b..237731c2 100644 --- a/src/locale/providers/CustomLocale.test.ts +++ b/src/locale/providers/CustomLocale.test.ts @@ -41,12 +41,12 @@ describe("CustomLocale", () => { expect(locale.lang()).toBe(Language.English); }); - test("languageNames() - follows the provider's languages", () => { + test("langNames() - follows the provider's languages", () => { locale.setLang(Language.French); - expect(locale.languageNames()).toEqual(new Map([[Language.French, "Français"]])); + expect(locale.langNames()).toEqual(new Map([[Language.French, "Français"]])); locale.setLang(Language.Ukrainian); - expect(locale.languageNames()).toEqual(new Map([[Language.Ukrainian, "Українська"]])); + expect(locale.langNames()).toEqual(new Map([[Language.Ukrainian, "Українська"]])); }); describe("trans()", () => { diff --git a/src/locale/providers/CustomLocale.ts b/src/locale/providers/CustomLocale.ts index d7469f51..cbb4d036 100644 --- a/src/locale/providers/CustomLocale.ts +++ b/src/locale/providers/CustomLocale.ts @@ -34,7 +34,7 @@ export default class CustomLocale extends Ab return new Set(Object.keys(this.data)) as Set; } - public languages(): Set { + public langs(): ReadonlySet { return new Set([this.lang()]); } diff --git a/src/locale/providers/DynamicLocale.test.ts b/src/locale/providers/DynamicLocale.test.ts index bd324529..4a7af6ad 100644 --- a/src/locale/providers/DynamicLocale.test.ts +++ b/src/locale/providers/DynamicLocale.test.ts @@ -1,27 +1,14 @@ jest.mock("@addon-core/browser", () => ({getI18nMessage: jest.fn(() => "en")})); jest.mock("@addon-core/storage", () => ({Storage: {Local: jest.fn()}})); -jest.mock("#adnbn/locale", () => ({ - __esModule: true, - default: require("./tests/fixtures/dynamic/catalogue.json"), - lang: "fr", - keys: ["greeting", "items", "app.title", "fallback", "empty", "__proto__"], - languages: ["en", "fr", "en_GB"], -})); +jest.mock("#adnbn/locale", () => require("./tests/fixtures/dynamic")); import {getI18nMessage} from "@addon-core/browser"; import {Storage, type StorageProvider} from "@addon-core/storage"; import DynamicLocale from "./DynamicLocale"; -import {createLocaleStorage} from "../tests/fixtures/storage"; +import {MemoryLocaleStorage} from "../tests/fixtures"; import {Language} from "@typing/locale"; -interface Structure { - greeting: {plural: false; substitutions: ["name"]}; - items: {plural: true; substitutions: ["count"]}; - "app.title": {plural: false; substitutions: []}; - fallback: {plural: false; substitutions: []}; - empty: {plural: false; substitutions: []}; - __proto__: {plural: false; substitutions: []}; -} +import type {Structure} from "./tests/fixtures/dynamic"; type Watcher = Record void>; const keys = ["greeting", "items", "app.title", "fallback", "empty", "__proto__"]; @@ -68,6 +55,31 @@ test("reads the initial language from browser i18n and translates synchronously expect(driver.watch).not.toHaveBeenCalled(); }); +test("exposes completed messages while retaining previously selected dictionaries", async () => { + const locale = new DynamicLocale(false); + const english = locale.messages(); + expect(locale.messages()).toBe(english); + expect(english).toMatchObject({ + locale: "en", + greeting: "Hello {{ name }}", + app_title: "Catalogue", + items: "{{count}} item|{{count}} items", + fallback: "Shared default message", + empty: "", + }); + expect(Object.hasOwn(english, "app.title")).toBe(false); + + const changed = locale.change(Language.French); + const french = locale.messages(); + expect(french).not.toBe(english); + expect(french.app_title).toBe("Catalogue français"); + expect(french.fallback).toBe("Shared default message"); + expect(english.app_title).toBe("Catalogue"); + await changed; + await locale.change(Language.English); + expect(locale.messages()).toBe(english); +}); + test("normalizes the native marker before selecting catalogue data", () => { jest.mocked(getI18nMessage).mockReturnValue("en-GB"); const locale = new DynamicLocale(false); @@ -102,22 +114,38 @@ test("rejects a recognized native language that is absent from the catalogue", ( expect(() => new DynamicLocale(false)).toThrow('Language "de" is not available'); }); -test("keeps public dot keys separate from catalogue keys and returns independent language collections", () => { +test("keeps public dot keys separate from catalogue keys and exposes available languages", () => { const locale = new DynamicLocale(false); expect([...locale.keys()]).toEqual(keys); - expect([...locale.languageNames()]).toEqual([ + expect([...locale.langs()]).toEqual([Language.English, Language.French, Language.EnglishGreatBritain]); + expect([...locale.langNames()]).toEqual([ [Language.English, "English"], [Language.French, "Français"], [Language.EnglishGreatBritain, "English (United Kingdom)"], ]); - locale.languages().clear(); - locale.languageNames().clear(); locale.keys().clear(); - expect(locale.languages().size).toBe(3); - expect(locale.languageNames().size).toBe(3); expect(locale.keys().size).toBe(keys.length); }); +test("selects without saving and throws synchronously for an unavailable language without changing state", () => { + stored = Language.English; + const locale = new DynamicLocale(); + + expect(locale.select(Language.French)).toBe(Language.French); + expect(locale.lang()).toBe(Language.French); + expect(locale.trans("greeting", {name: "Ada"})).toBe("Bonjour Ada"); + expect(driver.set).not.toHaveBeenCalled(); + expect(stored).toBe(Language.English); + + const messages = locale.messages(); + + expect(() => locale.select(Language.German)).toThrow('Language "de" is not available'); + expect(locale.lang()).toBe(Language.French); + expect(locale.messages()).toBe(messages); + expect(driver.set).not.toHaveBeenCalled(); + expect(stored).toBe(Language.English); +}); + test("switches messages immediately while waiting for storage persistence", async () => { const write = Promise.withResolvers(); driver.set.mockImplementationOnce(() => write.promise); @@ -252,7 +280,7 @@ test("uses a custom driver without constructing extension storage, including wit jest.mocked(getI18nMessage).mockImplementationOnce(() => { throw new TypeError("Browser i18n is unavailable"); }); - const storage = createLocaleStorage(Language.English); + const storage = new MemoryLocaleStorage(Language.English); const write = jest.spyOn(storage, "set"); const locale = new DynamicLocale(storage); expect(locale.lang()).toBe(Language.French); @@ -273,10 +301,10 @@ test("uses a custom driver without constructing extension storage, including wit }); test("shares a supplied driver while keeping different drivers independent", async () => { - const storage = createLocaleStorage(); + const storage = new MemoryLocaleStorage(); const first = new DynamicLocale(storage); const second = new DynamicLocale(storage); - const independent = new DynamicLocale(createLocaleStorage()); + const independent = new DynamicLocale(new MemoryLocaleStorage()); const stopFirst = first.watch(); const stopSecond = second.watch(); const stopIndependent = independent.watch(); @@ -291,7 +319,7 @@ test("shares a supplied driver while keeping different drivers independent", asy }); test("propagates custom driver read and write errors without rolling back the selected language", async () => { - const storage = createLocaleStorage(); + const storage = new MemoryLocaleStorage(); const error = new Error("Custom storage failed"); jest.spyOn(storage, "get").mockRejectedValueOnce(error); jest.spyOn(storage, "set").mockRejectedValueOnce(error); diff --git a/src/locale/providers/DynamicLocale.ts b/src/locale/providers/DynamicLocale.ts index 31fce895..d23d5022 100644 --- a/src/locale/providers/DynamicLocale.ts +++ b/src/locale/providers/DynamicLocale.ts @@ -19,7 +19,7 @@ export default class DynamicLocale implements LocaleDynamicProvider { private language!: Language; - private messages!: Record; + private data!: Readonly>; protected storage?: LocaleStorageDriver; protected unsubscribe?: () => void; @@ -40,6 +40,23 @@ export default class DynamicLocale this.storage = storage === false ? undefined : (storage ?? new LocaleStorage()); } + /** + * Selects a language synchronously without saving it. + * With storage enabled, a later sync() or watched storage update can replace this local selection. + * @throws If the language is absent from the catalogue. + */ + public select(lang: Language): Language { + if (!Object.hasOwn(catalogue, lang)) { + throw new Error(`[DynamicLocale] Language "${lang}" is not available in the catalogue.`); + } + + this.language = lang; + this.data = catalogue[lang]!; + + return lang; + } + + /** Selects a language immediately and saves it when storage is enabled. */ public async change(lang: Language): Promise { this.select(lang); @@ -100,28 +117,22 @@ export default class DynamicLocale return this.language; } + /** Completed messages for the current language, with underscore keys and no substitutions applied. */ + public messages(): Readonly> { + return this.data; + } + public keys(): Set { return new Set(keys) as Set; } - public languages(): Set { + public langs(): ReadonlySet { return new Set(languages); } protected value(key: Extract): string | undefined { const name = convertLocaleKey(key); - return Object.hasOwn(this.messages, name) ? this.messages[name] : undefined; - } - - private select(lang: Language): Language { - if (!Object.hasOwn(catalogue, lang)) { - throw new Error(`[DynamicLocale] Language "${lang}" is not available in the catalogue.`); - } - - this.language = lang; - this.messages = catalogue[lang]!; - - return lang; + return Object.hasOwn(this.data, name) ? this.data[name] : undefined; } } diff --git a/src/locale/providers/NativeLocale.test.ts b/src/locale/providers/NativeLocale.test.ts index a057b025..ec2ca089 100644 --- a/src/locale/providers/NativeLocale.test.ts +++ b/src/locale/providers/NativeLocale.test.ts @@ -89,14 +89,12 @@ describe("NativeLocale", () => { test("reads available languages from the module independently of the selected native language", () => { expect(locale.lang()).toBe("ru"); - expect([...locale.languages()]).toEqual(["en", "fr", "ru"]); - expect([...locale.languageNames()]).toEqual([ + expect([...locale.langs()]).toEqual(["en", "fr", "ru"]); + expect([...locale.langNames()]).toEqual([ ["en", "English"], ["fr", "Français"], ["ru", "Русский"], ]); - locale.languages().clear(); - expect([...locale.languages()]).toEqual(["en", "fr", "ru"]); expect(error).not.toHaveBeenCalled(); }); }); diff --git a/src/locale/providers/NativeLocale.ts b/src/locale/providers/NativeLocale.ts index 2521a1ff..85174912 100644 --- a/src/locale/providers/NativeLocale.ts +++ b/src/locale/providers/NativeLocale.ts @@ -5,12 +5,12 @@ import AbstractLocale from "./AbstractLocale"; import {convertLocaleKey, resolveLanguage} from "@shared/locale"; -import {Language, LocaleCustomKeyForLanguage, type LocaleProvider, type LocaleRegistry} from "@typing/locale"; +import {Language, LocaleCustomKeyForLanguage, type LocaleRegistry} from "@typing/locale"; export default class NativeLocale extends AbstractLocale { - private static instance?: LocaleProvider; + private static instance?: NativeLocale; - public static getInstance(): LocaleProvider { + public static getInstance(): NativeLocale { return (NativeLocale.instance ??= new NativeLocale()); } @@ -53,7 +53,7 @@ export default class NativeLocale extends Abs return new Set(keys) as Set; } - public languages(): Set { + public langs(): ReadonlySet { return new Set(languages); } diff --git a/src/locale/providers/ObservableLocale.test.ts b/src/locale/providers/ObservableLocale.test.ts new file mode 100644 index 00000000..6861617b --- /dev/null +++ b/src/locale/providers/ObservableLocale.test.ts @@ -0,0 +1,316 @@ +jest.mock("@addon-core/browser", () => ({getI18nMessage: jest.fn(() => "en")})); +jest.mock("#adnbn/locale", () => require("./tests/fixtures/dynamic")); + +import {getI18nMessage} from "@addon-core/browser"; +import {ObservableLocale} from "@locale/providers"; +import {MemoryLocaleStorage} from "../tests/fixtures"; +import {Language, type LocaleDynamicProvider, type LocaleStorageDriver} from "@typing/locale"; + +import type {Structure} from "./tests/fixtures/dynamic"; + +afterEach(() => jest.restoreAllMocks()); + +test("provides current translations while snapshots retain their original language", async () => { + const storage = new MemoryLocaleStorage(); + const write = jest.spyOn(storage, "set"); + const locale = new ObservableLocale(storage); + const provider: LocaleDynamicProvider = locale; + const initial = locale.snapshot(); + expect(provider.lang()).toBe(Language.English); + expect(provider.langs()).toEqual(new Set([Language.English, Language.French, Language.EnglishGreatBritain])); + expect(provider.langNames().get(Language.French)).toBe("Français"); + expect([...initial.langs()]).toEqual([...provider.langs()]); + expect([...initial.langNames()]).toEqual([...provider.langNames()]); + expect([...initial.langNames().keys()]).toEqual([...initial.langs()]); + expect(provider.keys()).toContain("app.title"); + expect(provider.trans("greeting", {name: "Ada"})).toBe("Hello Ada"); + expect(locale.get("greeting")).toBe("Hello {{ name }}"); + expect(locale.get("greeting", {name: "Ada"})).toBe("Hello Ada"); + expect(provider.choice("items", 0, {count: 0})).toBe("0 items"); + + const changed = provider.change(Language.French); + expect(provider.lang()).toBe(Language.French); + expect(provider.trans("greeting", {name: "Ada"})).toBe("Bonjour Ada"); + expect(provider.choice("items", 0, {count: 0})).toBe("0 article"); + expect(provider.trans("app.title")).toBe("Catalogue français"); + expect(provider.trans("empty")).toBe(""); + expect(locale.snapshot().get("greeting")).toBe("Bonjour {{ name }}"); + expect(initial.get("greeting")).toBe("Hello {{ name }}"); + await changed; + expect(write).toHaveBeenCalledTimes(1); +}); + +test("caches default, memory, and driver instances separately without connecting storage", () => { + const first = new MemoryLocaleStorage(); + const second = new MemoryLocaleStorage(); + const read = jest.spyOn(first, "get"); + expect(ObservableLocale.getInstance()).toBe(ObservableLocale.getInstance()); + expect(ObservableLocale.getInstance(false)).toBe(ObservableLocale.getInstance(false)); + expect(ObservableLocale.getInstance(first)).toBe(ObservableLocale.getInstance(first)); + expect(ObservableLocale.getInstance(first)).not.toBe(ObservableLocale.getInstance(second)); + expect(ObservableLocale.getInstance(false)).not.toBe(ObservableLocale.getInstance()); + expect(first.listenerCount).toBe(0); + expect(read).not.toHaveBeenCalled(); +}); + +test("publishes synchronously in memory and preserves previous messages and snapshot identity", async () => { + const locale = new ObservableLocale(false); + const initial = locale.snapshot(); + const listener = jest.fn(); + const stop = locale.subscribe(listener); + expect(locale.snapshot()).toBe(initial); + expect(initial.get("greeting")).toBe("Hello {{ name }}"); + const changed = locale.change(Language.French); + expect(locale.lang()).toBe(Language.French); + expect(locale.snapshot().get("greeting")).toBe("Bonjour {{ name }}"); + expect(locale.snapshot().get("empty")).toBe(""); + expect(initial.get("greeting")).toBe("Hello {{ name }}"); + expect(initial.lang()).toBe(Language.English); + expect(locale.snapshot().langs()).toBe(initial.langs()); + expect(locale.snapshot().langNames()).toBe(initial.langNames()); + expect(listener).toHaveBeenCalledTimes(1); + await expect(changed).resolves.toBe(Language.French); + const french = locale.snapshot(); + await locale.change(Language.French); + expect(locale.snapshot()).toBe(french); + expect(listener).toHaveBeenCalledTimes(1); + stop(); + await locale.change(Language.English); + expect(listener).toHaveBeenCalledTimes(1); +}); + +test("connects once, restores storage and reads again after the last subscriber reconnects", async () => { + const storage = new MemoryLocaleStorage(Language.French); + const locale = new ObservableLocale(storage); + const read = jest.spyOn(storage, "get"); + const write = jest.spyOn(storage, "set"); + const stopFirst = locale.subscribe(jest.fn()); + const stopSecond = locale.subscribe(jest.fn()); + expect(storage.listenerCount).toBe(1); + await locale.sync(); + expect(locale.lang()).toBe(Language.French); + expect(write).not.toHaveBeenCalled(); + stopFirst(); + expect(storage.listenerCount).toBe(1); + stopSecond(); + expect(storage.listenerCount).toBe(0); + await storage.set(Language.EnglishGreatBritain); + const stopThird = locale.subscribe(jest.fn()); + await locale.sync(); + expect(locale.lang()).toBe(Language.EnglishGreatBritain); + expect(read).toHaveBeenCalledTimes(4); + stopThird(); +}); + +test("serializes writes, saves equal selections and ignores old storage echoes", async () => { + const storage = new MemoryLocaleStorage(Language.English); + const locale = new ObservableLocale(storage); + const stop = locale.subscribe(jest.fn()); + await locale.sync(); + const blocked = Promise.withResolvers(); + const originalSet = storage.set.bind(storage); + const write = jest.spyOn(storage, "set").mockImplementationOnce(async lang => { + await blocked.promise; + await originalSet(lang); + }); + const first = locale.change(Language.French); + const second = locale.change(Language.EnglishGreatBritain); + await Promise.resolve(); + expect(write).toHaveBeenCalledTimes(1); + expect(locale.lang()).toBe(Language.EnglishGreatBritain); + blocked.resolve(); + await Promise.all([first, second]); + expect(write.mock.calls.map(([lang]) => lang)).toEqual([Language.French, Language.EnglishGreatBritain]); + storage.emit(Language.French); + await locale.sync(); + expect(locale.lang()).toBe(Language.EnglishGreatBritain); + const snapshot = locale.snapshot(); + await locale.change(Language.EnglishGreatBritain); + expect(locale.snapshot()).toBe(snapshot); + expect(write).toHaveBeenCalledTimes(3); + stop(); +}); + +test("ignores an initial read that finishes after a local selection", async () => { + const storage = new MemoryLocaleStorage(Language.English); + const oldRead = Promise.withResolvers(); + jest.spyOn(storage, "get").mockImplementationOnce(() => oldRead.promise); + const locale = new ObservableLocale(storage); + const stop = locale.subscribe(jest.fn()); + await locale.change(Language.French); + oldRead.resolve(Language.English); + await oldRead.promise; + expect(locale.lang()).toBe(Language.French); + stop(); +}); + +test("does not apply a read from a disconnected subscription", async () => { + const storage = new MemoryLocaleStorage(); + const oldRead = Promise.withResolvers(); + jest.spyOn(storage, "get").mockImplementationOnce(() => oldRead.promise); + const locale = new ObservableLocale(storage); + const stop = locale.subscribe(jest.fn()); + stop(); + oldRead.resolve(Language.French); + await oldRead.promise; + expect(locale.lang()).toBe(Language.English); +}); + +test("rejects a failed write without rolling back a newer choice and continues the queue", async () => { + const storage = new MemoryLocaleStorage(Language.English); + const blocked = Promise.withResolvers(); + jest.spyOn(storage, "set").mockImplementationOnce(() => blocked.promise); + const locale = new ObservableLocale(storage); + const first = locale.change(Language.French); + const failure = expect(first).rejects.toThrow("write failed"); + const second = locale.change(Language.EnglishGreatBritain); + blocked.reject(new Error("write failed")); + await failure; + await second; + expect(locale.lang()).toBe(Language.EnglishGreatBritain); + expect(storage.value).toBe(Language.EnglishGreatBritain); + jest.spyOn(storage, "set").mockRejectedValueOnce(new Error("last write failed")); + await expect(locale.change(Language.French)).rejects.toThrow("last write failed"); + expect(locale.lang()).toBe(Language.French); +}); + +test("the same callback can have independent subscriptions", async () => { + const storage = new MemoryLocaleStorage(); + const locale = new ObservableLocale(storage); + const listener = jest.fn(); + const first = locale.subscribe(listener); + const second = locale.subscribe(listener); + expect(storage.listenerCount).toBe(1); + await locale.change(Language.French); + expect(listener).toHaveBeenCalledTimes(2); + listener.mockClear(); + first(); + first(); + expect(storage.listenerCount).toBe(1); + await storage.set(Language.English); + await locale.sync(); + expect(locale.lang()).toBe(Language.English); + expect(listener).toHaveBeenCalledTimes(1); + second(); + expect(storage.listenerCount).toBe(0); +}); + +test("a failed subscription does not leave a listener behind", async () => { + const storage = new MemoryLocaleStorage(Language.French); + jest.spyOn(storage, "watch").mockImplementationOnce(() => { + throw new Error("cannot subscribe"); + }); + const locale = new ObservableLocale(storage); + const failed = jest.fn(); + expect(() => locale.subscribe(failed)).toThrow("cannot subscribe"); + const ready = Promise.withResolvers(); + const stop = locale.subscribe(() => ready.resolve()); + await ready.promise; + expect(locale.lang()).toBe(Language.French); + expect(failed).not.toHaveBeenCalled(); + stop(); + expect(storage.listenerCount).toBe(0); +}); + +test("a subscriber can select another language without reordering persistence", async () => { + const storage = new MemoryLocaleStorage(); + const write = jest.spyOn(storage, "set"); + const locale = new ObservableLocale(storage); + let next: Promise | undefined; + const stop = locale.subscribe(() => { + if (locale.lang() === Language.French) next = locale.change(Language.EnglishGreatBritain); + }); + const first = locale.change(Language.French); + expect(locale.lang()).toBe(Language.EnglishGreatBritain); + expect(locale.snapshot().lang()).toBe(Language.EnglishGreatBritain); + await first; + await next; + expect(write.mock.calls).toEqual([[Language.French], [Language.EnglishGreatBritain]]); + expect(storage.value).toBe(Language.EnglishGreatBritain); + stop(); +}); + +test("distinct drivers can synchronize through the same physical store without echo writes", async () => { + const storage = new MemoryLocaleStorage(); + const other: LocaleStorageDriver = { + get: () => storage.get(), + set: lang => storage.set(lang), + watch: handler => storage.watch(handler), + }; + const first = ObservableLocale.getInstance(storage); + const second = ObservableLocale.getInstance(other); + const write = jest.spyOn(storage, "set"); + const stops = [first.subscribe(jest.fn()), second.subscribe(jest.fn())]; + await first.change(Language.French); + await second.sync(); + expect(first).not.toBe(second); + expect(second.lang()).toBe(Language.French); + expect(write).toHaveBeenCalledTimes(1); + stops.forEach(stop => stop()); +}); + +test("validates selections and saved languages without publishing an invalid snapshot", async () => { + const storage = new MemoryLocaleStorage(Language.German); + const locale = new ObservableLocale(storage); + const snapshot = locale.snapshot(); + const warn = jest.spyOn(console, "warn").mockImplementation(); + await locale.sync(); + expect(warn).toHaveBeenCalledWith('Incorrect language code in storage - "de"'); + await expect(locale.change(Language.German)).rejects.toThrow('Language "de" is not available'); + expect(locale.snapshot()).toBe(snapshot); + expect(storage.value).toBe(Language.German); +}); + +test("an invalid choice neither writes nor invalidates a pending storage read", async () => { + const storage = new MemoryLocaleStorage(); + const read = Promise.withResolvers(); + jest.spyOn(storage, "get").mockImplementationOnce(() => read.promise); + const write = jest.spyOn(storage, "set"); + const locale = new ObservableLocale(storage); + const initial = locale.snapshot(); + const synced = locale.sync(); + await expect(locale.change(Language.German)).rejects.toThrow('Language "de" is not available'); + expect(locale.snapshot()).toBe(initial); + expect(write).not.toHaveBeenCalled(); + read.resolve(Language.French); + await synced; + expect(locale.lang()).toBe(Language.French); + expect(locale.snapshot().lang()).toBe(Language.French); +}); + +test("snapshots treat missing prototype keys as missing messages", () => { + const locale = new ObservableLocale>(false); + const warn = jest.spyOn(console, "warn").mockImplementation(); + expect(locale.snapshot().get("toString")).toBe("toString"); + expect(warn).toHaveBeenCalledWith('Locale key "toString" not found in "en" language.'); + expect(locale.snapshot().get("__proto__")).toBe("Ordinary message"); +}); + +test("reports background synchronization errors and still releases the listener", async () => { + const storage = new MemoryLocaleStorage(); + const failure = new Error("read failed"); + jest.spyOn(storage, "get").mockRejectedValue(failure); + const report = jest.spyOn(console, "error").mockImplementation(); + const locale = new ObservableLocale(storage); + const stop = locale.subscribe(jest.fn()); + await Promise.resolve(); + await Promise.resolve(); + expect(report).toHaveBeenCalledWith("[ObservableLocale] Cannot synchronize language:", failure); + stop(); + expect(storage.listenerCount).toBe(0); +}); + +test("custom storage and memory mode work when browser i18n is unavailable", async () => { + jest.mocked(getI18nMessage).mockImplementation(() => { + throw new Error("No i18n"); + }); + const locale = new ObservableLocale(new MemoryLocaleStorage(Language.English)); + expect(locale.lang()).toBe(Language.French); + await locale.sync(); + expect(locale.lang()).toBe(Language.English); + const memory = new ObservableLocale(false); + await memory.change(Language.English); + expect(memory.lang()).toBe(Language.English); + await expect(memory.sync()).rejects.toThrow("Language is not saving in storage"); + jest.mocked(getI18nMessage).mockReturnValue("en"); +}); diff --git a/src/locale/providers/ObservableLocale.ts b/src/locale/providers/ObservableLocale.ts new file mode 100644 index 00000000..dbf87939 --- /dev/null +++ b/src/locale/providers/ObservableLocale.ts @@ -0,0 +1,246 @@ +import DynamicLocale from "./DynamicLocale"; +import CustomLocale from "./CustomLocale"; + +import {LocaleStorage} from "../storage"; + +import type { + Language, + LocaleDynamicProvider, + LocaleNonPluralKeys, + LocalePluralKeys, + LocaleRegistry, + LocaleSnapshot, + LocaleStorageDriver, + LocaleSubstitutionArgs, + LocaleSubstitutionValue, +} from "@typing/locale"; + +/** Dynamic translations with shared instances and subscriptions independent of any UI framework. */ +export default class ObservableLocale implements LocaleDynamicProvider { + private static defaultInstance?: ObservableLocale; + private static memoryInstance?: ObservableLocale; + + private static readonly drivers = new WeakMap(); + + public static getInstance(storage?: LocaleStorageDriver | false): ObservableLocale { + if (typeof storage === "object") { + let instance = this.drivers.get(storage); + + if (!instance) { + instance = new ObservableLocale(storage); + this.drivers.set(storage, instance); + } + + return instance; + } + + if (storage === false) { + return (this.memoryInstance ??= new ObservableLocale(false)); + } + + return (this.defaultInstance ??= new ObservableLocale()); + } + + private readonly locale = new DynamicLocale(false); + private readonly storage?: LocaleStorageDriver; + private readonly listeners = new Set<() => void>(); + private readonly languages: ReadonlySet; + private readonly languageNames: ReadonlyMap; + private currentSnapshot: LocaleSnapshot; + private revision = 0; + private writes: Promise = Promise.resolve(); + private pendingWrites = 0; + private unsubscribe?: () => void; + + constructor(storage?: LocaleStorageDriver | false) { + this.storage = storage === false ? undefined : (storage ?? new LocaleStorage()); + this.languages = this.langs(); + this.languageNames = this.langNames(); + this.currentSnapshot = this.createSnapshot(); + } + + public lang(): Language { + return this.locale.lang(); + } + + public langs(): ReadonlySet { + return this.locale.langs(); + } + + public langNames(): ReadonlyMap { + return this.locale.langNames(); + } + + public keys(): Set { + return this.locale.keys(); + } + + public get(key: K, substitutions?: Record): string { + return this.locale.get(key, substitutions); + } + + public trans>(key: K, ...args: LocaleSubstitutionArgs): string { + return this.locale.trans(key, ...args); + } + + public choice>(key: K, count: number, ...args: LocaleSubstitutionArgs): string { + return this.locale.choice(key, count, ...args); + } + + public readonly snapshot = (): LocaleSnapshot => this.currentSnapshot; + + public readonly subscribe = (listener: () => void): (() => void) => { + // Each subscription owns its cleanup, even when the same callback is subscribed twice. + const notify = () => listener(); + + this.listeners.add(notify); + + try { + this.connect(); + } catch (error) { + this.listeners.delete(notify); + throw error; + } + + if (this.listeners.size === 1) { + void this.refresh(); + } + + return () => { + this.listeners.delete(notify); + this.disconnect(); + }; + }; + + public readonly change = async (lang: Language): Promise => { + this.locale.select(lang); + + const storage = this.storage; + + if (!storage) { + this.publish(); + return lang; + } + + this.revision++; + + // Register persistence before notifying: a subscriber may select another language immediately. + this.pendingWrites++; + + const saved = this.writes.then(() => storage.set(lang)); + + this.writes = saved.catch(() => undefined); + + this.publish(); + + let succeeded = false; + + try { + await saved; + succeeded = true; + + return lang; + } finally { + if (--this.pendingWrites === 0 && succeeded) { + void this.refresh(); + } + } + }; + + public async sync(): Promise { + if (!this.storage) { + throw new Error("Language is not saving in storage"); + } + + if (this.pendingWrites) { + return this.lang(); + } + + const revision = ++this.revision; + const lang = await this.storage.get(); + + if (revision !== this.revision || this.pendingWrites) { + return this.lang(); + } + + if (lang === undefined) { + return this.lang(); + } + + if (!this.langs().has(lang)) { + console.warn(`Incorrect language code in storage - "${lang}"`); + + return this.lang(); + } + + this.locale.select(lang); + + this.publish(); + + return this.lang(); + } + + private connect(): void { + if (!this.storage || this.unsubscribe) { + return; + } + + this.unsubscribe = this.storage.watch(() => { + void this.refresh(); + }); + } + + private disconnect(): void { + if (this.listeners.size) { + return; + } + + this.revision++; + + this.unsubscribe?.(); + this.unsubscribe = undefined; + } + + private async refresh(): Promise { + if (!this.storage || this.pendingWrites) { + return; + } + + try { + await this.sync(); + } catch (error) { + console.error("[ObservableLocale] Cannot synchronize language:", error); + } + } + + private createSnapshot(): LocaleSnapshot { + const lang = this.lang(); + const langs = this.languages; + const langNames = this.languageNames; + + const reader = new CustomLocale(lang, this.locale.messages()); + + return Object.freeze({ + lang: () => lang, + langs: () => langs, + langNames: () => langNames, + get: reader.get.bind(reader), + }); + } + + private publish(): void { + if (this.currentSnapshot.lang() === this.lang()) { + return; + } + + this.currentSnapshot = this.createSnapshot(); + + for (const listener of [...this.listeners]) { + try { + listener(); + } catch (error) { + console.error("[ObservableLocale] Language subscriber failed:", error); + } + } + } +} diff --git a/src/locale/providers/index.ts b/src/locale/providers/index.ts index 481d401d..f9db42bb 100644 --- a/src/locale/providers/index.ts +++ b/src/locale/providers/index.ts @@ -1,3 +1,4 @@ export {default as AbstractLocale} from "./AbstractLocale"; export {default as DynamicLocale} from "./DynamicLocale"; export {default as NativeLocale} from "./NativeLocale"; +export {default as ObservableLocale} from "./ObservableLocale"; diff --git a/src/locale/providers/tests/fixtures/dynamic/index.ts b/src/locale/providers/tests/fixtures/dynamic/index.ts new file mode 100644 index 00000000..6a1cbee7 --- /dev/null +++ b/src/locale/providers/tests/fixtures/dynamic/index.ts @@ -0,0 +1,15 @@ +import catalogue from "./catalogue.json"; + +export {catalogue as default}; +export const lang = "fr"; +export const keys = ["greeting", "items", "app.title", "fallback", "empty", "__proto__"]; +export const languages = ["en", "fr", "en_GB"]; + +export interface Structure { + greeting: {plural: false; substitutions: ["name"]}; + items: {plural: true; substitutions: ["count"]}; + "app.title": {plural: false; substitutions: []}; + fallback: {plural: false; substitutions: []}; + empty: {plural: false; substitutions: []}; + __proto__: {plural: false; substitutions: []}; +} diff --git a/src/locale/tests/fixtures/MemoryLocaleStorage.ts b/src/locale/tests/fixtures/MemoryLocaleStorage.ts new file mode 100644 index 00000000..d118803e --- /dev/null +++ b/src/locale/tests/fixtures/MemoryLocaleStorage.ts @@ -0,0 +1,31 @@ +import type {Language, LocaleStorageDriver} from "@typing/locale"; + +export default class MemoryLocaleStorage implements LocaleStorageDriver { + private readonly listeners = new Set<(lang: Language) => void>(); + + constructor(public value?: Language) {} + + public get listenerCount(): number { + return this.listeners.size; + } + + public async get(): Promise { + return this.value; + } + + public async set(lang: Language): Promise { + this.value = lang; + this.emit(lang); + } + + public emit(lang: Language): void { + for (const listener of this.listeners) listener(lang); + } + + public watch(handler: (lang: Language) => void): () => void { + this.listeners.add(handler); + return () => { + this.listeners.delete(handler); + }; + } +} diff --git a/src/locale/tests/fixtures/index.ts b/src/locale/tests/fixtures/index.ts new file mode 100644 index 00000000..f3b710e6 --- /dev/null +++ b/src/locale/tests/fixtures/index.ts @@ -0,0 +1 @@ +export {default as MemoryLocaleStorage} from "./MemoryLocaleStorage"; diff --git a/src/locale/tests/fixtures/storage.ts b/src/locale/tests/fixtures/storage.ts deleted file mode 100644 index 8876d0ca..00000000 --- a/src/locale/tests/fixtures/storage.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type {Language, LocaleStorageDriver} from "@typing/locale"; - -export const createLocaleStorage = (initial?: Language): LocaleStorageDriver => { - let value = initial; - const listeners = new Set<(lang: Language) => void>(); - - return { - async get() { - return value; - }, - async set(lang) { - value = lang; - for (const handler of listeners) handler(lang); - }, - watch(handler) { - listeners.add(handler); - return () => { - listeners.delete(handler); - }; - }, - }; -}; diff --git a/src/types/locale.ts b/src/types/locale.ts index bd6d48a2..060f8e1d 100644 --- a/src/types/locale.ts +++ b/src/types/locale.ts @@ -226,11 +226,14 @@ export type LocaleSubstitutionArgs = string extends keyof : [substitutions: LocaleSubstitutionsFor]; export interface LocaleProvider { + /** Currently selected language code. */ lang(): Language; - languages(): Set; + /** Available language codes. */ + langs(): ReadonlySet; - languageNames(): Map; + /** Native names keyed by the available language codes. */ + langNames(): ReadonlyMap; keys(): ReadonlySet; @@ -245,6 +248,14 @@ export interface LocaleDynamicProvider extends LocaleProvider { change(lang: Language): Promise; } +/** Read-only message access for one selected language. */ +export interface LocaleSnapshot { + readonly lang: () => Language; + readonly langs: () => ReadonlySet; + readonly langNames: () => ReadonlyMap; + readonly get: (key: K) => string; +} + /** Persists one language selection independently of the application's translation catalogue. */ export interface LocaleStorageDriver { /** No saved selection leaves the provider's current language unchanged. */ diff --git a/tests/integration/build/locale/dynamic-fixture/package.json b/tests/integration/build/locale/dynamic-fixture/package.json index 7d731f2d..05d223a6 100644 --- a/tests/integration/build/locale/dynamic-fixture/package.json +++ b/tests/integration/build/locale/dynamic-fixture/package.json @@ -4,6 +4,9 @@ "type": "module", "version": "1.0.0", "dependencies": { - "adnbn": "*" + "adnbn": "*", + "react": "*", + "react-dom": "*", + "scheduler": "*" } } diff --git a/tests/integration/build/locale/dynamic-fixture/src/contract.ts b/tests/integration/build/locale/dynamic-fixture/src/contract.ts index 25865364..affbaccd 100644 --- a/tests/integration/build/locale/dynamic-fixture/src/contract.ts +++ b/tests/integration/build/locale/dynamic-fixture/src/contract.ts @@ -1,5 +1,4 @@ import {DynamicLocale, Language, LocaleStorage, type LocaleRegistry, type LocaleStorageDriver} from "adnbn/locale"; -import type {LocaleProviderProps} from "adnbn/locale/react"; export const checkStorageContract = (storage: LocaleStorageDriver): void => { const builtin: LocaleStorageDriver = new LocaleStorage(); @@ -12,8 +11,6 @@ export const checkStorageContract = (storage: LocaleStorageDriver): void => { const stop: () => void = storage.watch(lang => { const selected: Language = lang; }); - const customProps: LocaleProviderProps = {storage}; - const memoryProps: LocaleProviderProps = {storage: false}; // @ts-expect-error persisting an absent language is not part of the driver contract storage.set(undefined); }; diff --git a/tests/integration/build/locale/dynamic-fixture/src/panel.ts b/tests/integration/build/locale/dynamic-fixture/src/panel.ts index 475a4ec5..e4a57be8 100644 --- a/tests/integration/build/locale/dynamic-fixture/src/panel.ts +++ b/tests/integration/build/locale/dynamic-fixture/src/panel.ts @@ -5,7 +5,7 @@ export const createPanel = (context: string, storage?: LocaleStorageDriver | fal const panel = document.createElement("div"); panel.dataset.localeContext = context; const select = document.createElement("select"); - for (const [language, name] of locale.languageNames()) select.add(new Option(name, language)); + for (const [language, name] of locale.langNames()) select.add(new Option(name, language)); const message = document.createElement("p"); const update = () => { select.value = locale.lang(); diff --git a/tests/integration/build/locale/dynamic.integration.test.ts b/tests/integration/build/locale/dynamic.integration.test.ts index 5d776041..92f6e5ee 100644 --- a/tests/integration/build/locale/dynamic.integration.test.ts +++ b/tests/integration/build/locale/dynamic.integration.test.ts @@ -76,8 +76,8 @@ test("native-only consumers do not include the catalogue despite importing the p nativeLocale: undefined as | { keys(): Set; - languages(): Set; - languageNames(): Map; + langs(): ReadonlySet; + langNames(): ReadonlyMap; trans(key: string): string; } | undefined, @@ -93,8 +93,8 @@ test("native-only consumers do not include the catalogue despite importing the p "welcome", ]); expect(sandbox.nativeLocale!.trans("empty")).toBe(""); - expect([...sandbox.nativeLocale!.languages()]).toEqual(["en", "fr"]); - expect([...sandbox.nativeLocale!.languageNames()]).toEqual([ + expect([...sandbox.nativeLocale!.langs()]).toEqual(["en", "fr"]); + expect([...sandbox.nativeLocale!.langNames()]).toEqual([ ["en", "English"], ["fr", "Français"], ]); @@ -102,3 +102,30 @@ test("native-only consumers do not include the catalogue despite importing the p await fixture.dispose(); } }); + +test("the native React hook builds through the public adapter without bundling the catalogue", async () => { + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, fixtureDirectory); + try { + for (const file of ["background.ts", "options.ts", "locale.content.ts", "main.content.ts"]) + await rm(path.join(fixture.directory, "src", file)); + await copyFile(path.join(__dirname, "states/native-popup.ts"), path.join(fixture.directory, "src/popup.ts")); + + const directory = await fixture.build(); + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + expect(manifest.action.default_popup).toBe("popup.html"); + const files = (await readdir(path.join(directory, "js"))).filter(file => file.endsWith(".js")); + expect(files).toContain("popup.js"); + for (const file of files) { + const source = await readFile(path.join(directory, "js", file), "utf8"); + expect(source).not.toContain("Hello from DynamicLocale!"); + expect(source).not.toContain("Bonjour depuis DynamicLocale"); + } + await run( + process.execPath, + [path.join(ADNBN_TEST_ROOT, "node_modules/typescript/bin/tsc"), "--noEmit", "-p", "tsconfig.json"], + fixture.directory + ); + } finally { + await fixture.dispose(); + } +}); diff --git a/tests/integration/build/locale/states/native-popup.ts b/tests/integration/build/locale/states/native-popup.ts new file mode 100644 index 00000000..be5ce746 --- /dev/null +++ b/tests/integration/build/locale/states/native-popup.ts @@ -0,0 +1,22 @@ +import {createElement} from "react"; +import {createRoot} from "react-dom/client"; +import {definePopup} from "adnbn"; +import {useNativeLocale} from "adnbn/locale/react"; + +function Popup() { + const {t, choice} = useNativeLocale(); + return createElement( + "main", + null, + t("welcome", {name: createElement("strong", null, "Ada")}), + choice("items", 2, {count: createElement("span", null, "2")}) + ); +} + +export default definePopup({ + render: () => { + const element = document.createElement("div"); + createRoot(element).render(createElement(Popup)); + return element; + }, +}); From d613c0f1e427c32702a28bbe1cf8d96ab5aa3436 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:30:35 +0300 Subject: [PATCH 30/38] chore(test): remove duplicate type checking --- package.json | 3 +-- src/relay/README.md | 2 +- tsconfig.tests.json | 19 ------------------- 3 files changed, 2 insertions(+), 22 deletions(-) delete mode 100644 tsconfig.tests.json diff --git a/package.json b/package.json index 11339cea..b331ce40 100644 --- a/package.json +++ b/package.json @@ -114,8 +114,7 @@ "prepublishOnly": "npm run build", "build": "node ./scripts/clean-dist.js && 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 && npm run typecheck:tests", - "typecheck:tests": "tsc -p tsconfig.tests.json --noEmit", + "typecheck": "tsc -p tsconfig.json --noEmit", "prepare:integration": "npm run build && tsx tests/integration/prepare.ts", "typecheck:integration": "npm run prepare:integration && tsx tests/integration/typecheck.ts", "test:integration": "npm run test -- --testPathPatterns=tests/integration --runInBand", diff --git a/src/relay/README.md b/src/relay/README.md index ee369c04..06453de6 100644 --- a/src/relay/README.md +++ b/src/relay/README.md @@ -290,7 +290,7 @@ 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: +`typecheck` checks both source and test files; 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 diff --git a/tsconfig.tests.json b/tsconfig.tests.json deleted file mode 100644 index dc1d5059..00000000 --- a/tsconfig.tests.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": true - }, - "include": [ - "src/**/*.test.ts", - "src/**/*.test.tsx", - "src/**/*.d.ts", - "tests/**/*.test.ts", - "tests/integration/*.ts", - "tests/jest*.ts" - ], - "exclude": [ - "src/**/tests/fixtures/**", - "dist", - "node_modules" - ] -} From 478e3cd7c25dcf825d704eb7232c86f191839dc5 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:56:03 +0300 Subject: [PATCH 31/38] test(locale): fix Windows CI fixtures and output paths --- AGENTS.md | 8 ++++++ .../generate-json/GenerateJsonPlugin.test.ts | 26 +++++++++-------- .../build/locale/locale.integration.test.ts | 28 ++++++++----------- 3 files changed, 33 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5024246c..7c838aa4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,14 @@ - `CHANGELOG.md` is generated by the release pipeline during publication. Do not edit it manually; document behavior and migration guidance in the relevant documentation instead. +## Cross-platform tests + +- The full CI matrix in `.github/workflows/ci.yml` runs on Linux and Windows with Node.js 22 and 24. New and changed non-browser tests must work on both operating systems. Chrome and Firefox browser integrations currently run on Linux; consult the workflow for the current matrix. +- Build filesystem paths with `path.resolve` and `path.join`, including paths used with in-memory filesystems. Do not hardcode Unix roots such as `/dist` or Windows drive letters. Configure the compiler and read, write, or assert its output using the same resolved directory. Keep filesystem paths distinct from URLs and manifest asset paths, which use forward slashes. +- In watch tests, simulate edits with `writeFile(destination, await readFile(source))`. Do not use `copyFile` to update watched files: on Windows it can preserve an old `mtime`, preventing the watcher from detecting the change. Copying initial fixtures before starting watch is fine. +- Wait for the expected compilation or observable output with a descriptive timeout. Do not rely on fixed sleeps or assume that emitted files mean the watcher has finished reconnecting. Preserve exact filename casing and account for platform line endings when they are not part of the tested contract. +- A local pass on one operating system does not establish cross-platform compatibility. For platform-specific failures, reproduce the relevant filesystem or process behavior when possible and distinguish that simulation from validation on the actual CI runner. Do not hide the failure by skipping the platform or only increasing timeouts. + ## Responsibility boundaries ### Layer ownership diff --git a/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.test.ts b/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.test.ts index ecad344a..d8d5c5f6 100644 --- a/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.test.ts +++ b/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.test.ts @@ -1,8 +1,10 @@ +import path from "path"; import {rspack, Compiler, type Stats} from "@rspack/core"; import {createFsFromVolume, Volume} from "memfs"; import GenerateJsonPlugin from "./GenerateJsonPlugin"; describe("GenerateJsonPlugin watch updates", () => { + const output = path.resolve(__dirname, "dist"); let compiler: Compiler; const run = () => @@ -21,7 +23,7 @@ describe("GenerateJsonPlugin watch updates", () => { compiler = rspack({ mode: "none", entry: {}, - output: {path: "/dist"}, + output: {path: output}, optimization: {emitOnErrors: false}, plugins: [new GenerateJsonPlugin({}).watch(update)], }); @@ -32,11 +34,11 @@ describe("GenerateJsonPlugin watch updates", () => { const failed = await run(); expect(failed.hasErrors()).toBe(true); expect(failed.toString({all: false, errors: true})).toContain(error.message); - expect(filesystem.existsSync("/dist/messages.json")).toBe(false); + expect(filesystem.existsSync(path.join(output, "messages.json"))).toBe(false); await compiler.hooks.watchRun.promise(compiler); expect((await run()).hasErrors()).toBe(false); - expect(JSON.parse(filesystem.readFileSync("/dist/messages.json", "utf8") as string)).toEqual({ + expect(JSON.parse(filesystem.readFileSync(path.join(output, "messages.json"), "utf8") as string)).toEqual({ title: "Updated", }); expect(update).toHaveBeenCalledTimes(2); @@ -51,7 +53,7 @@ describe("GenerateJsonPlugin watch updates", () => { compiler = rspack({ mode: "none", entry: {}, - output: {path: "/dist", clean: false}, + output: {path: output, clean: false}, optimization: {emitOnErrors: false}, plugins: [ new GenerateJsonPlugin(data).watch(async () => { @@ -62,23 +64,23 @@ describe("GenerateJsonPlugin watch updates", () => { }); const filesystem = createFsFromVolume(new Volume()); compiler.outputFileSystem = filesystem as Compiler["outputFileSystem"]; - await run(); - filesystem.writeFileSync("/dist/unrelated.txt", "Keep this file"); + expect((await run()).hasErrors()).toBe(false); + filesystem.writeFileSync(path.join(output, "unrelated.txt"), "Keep this file"); data = {"_locales/en/messages.json": {message: "Updated"}}; fail = true; await compiler.hooks.watchRun.promise(compiler); expect((await run()).hasErrors()).toBe(true); - expect(filesystem.existsSync("/dist/_locales/fr/messages.json")).toBe(true); + expect(filesystem.existsSync(path.join(output, "_locales/fr/messages.json"))).toBe(true); fail = false; await compiler.hooks.watchRun.promise(compiler); expect((await run()).hasErrors()).toBe(false); - expect(filesystem.existsSync("/dist/_locales/fr")).toBe(false); - expect(filesystem.readFileSync("/dist/unrelated.txt", "utf8")).toBe("Keep this file"); - expect(JSON.parse(filesystem.readFileSync("/dist/_locales/en/messages.json", "utf8") as string)).toEqual({ - message: "Updated", - }); + expect(filesystem.existsSync(path.join(output, "_locales/fr"))).toBe(false); + expect(filesystem.readFileSync(path.join(output, "unrelated.txt"), "utf8")).toBe("Keep this file"); + expect( + JSON.parse(filesystem.readFileSync(path.join(output, "_locales/en/messages.json"), "utf8") as string) + ).toEqual({message: "Updated"}); }); test("allows watch builds without an update callback", async () => { diff --git a/tests/integration/build/locale/locale.integration.test.ts b/tests/integration/build/locale/locale.integration.test.ts index 0243d545..b1bc10a7 100644 --- a/tests/integration/build/locale/locale.integration.test.ts +++ b/tests/integration/build/locale/locale.integration.test.ts @@ -95,6 +95,12 @@ test("exports the configured default language even when it is not the first cata test("CLI watch discovers, edits and removes locales and recovers from invalid translations", async () => { const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, path.join(__dirname, "fixture")); + // Save like an editor: Windows copyFile can preserve the fixture's old mtime and hide an edit from watch. + const updateLocale = async (language: string, state: string) => + writeFile( + path.join(fixture.directory, "src/locales", `${language}.json`), + await readFile(path.join(__dirname, "states", state)) + ); let watcher: ChildProcess | undefined; let output = ""; try { @@ -109,10 +115,7 @@ test("CLI watch discovers, edits and removes locales and recovers from invalid t // Assets exist before Rspack finishes the compilation and reconnects its watcher. await waitFor(async () => output.includes("compiled") || undefined, 15000, "initial locale watch compilation"); await waitFor(() => inspect(directory), 15000, "initial locale watch build"); - await writeFile( - path.join(fixture.directory, "src/locales/de.json"), - await readFile(path.join(__dirname, "states/de.json")) - ); + await updateLocale("de", "de.json"); await waitFor( async () => { const {catalogue, languages} = await inspect(directory); @@ -124,10 +127,7 @@ test("CLI watch discovers, edits and removes locales and recovers from invalid t "a new locale without editing an existing file" ); - await copyFile( - path.join(__dirname, "states/de-updated.json"), - path.join(fixture.directory, "src/locales/de.json") - ); + await updateLocale("de", "de-updated.json"); await waitFor( async () => { expect((await inspect(directory)).catalogue.de.app_title).toBe("Aktualisierter Katalog"); @@ -137,10 +137,7 @@ test("CLI watch discovers, edits and removes locales and recovers from invalid t "an edit to the newly discovered locale" ); - await writeFile( - path.join(fixture.directory, "src/locales/en.json"), - await readFile(path.join(__dirname, "states/en.json")) - ); + await updateLocale("en", "en.json"); await waitFor( async () => { const {catalogue, keys, languages} = await inspect(directory); @@ -175,10 +172,7 @@ test("CLI watch discovers, edits and removes locales and recovers from invalid t const previousJson = await readFile(path.join(directory, "_locales/en/messages.json"), "utf8"); const previousBundle = await readFile(path.join(directory, "js/background.js"), "utf8"); const offset = output.length; - await copyFile( - path.join(__dirname, "states", invalid), - path.join(fixture.directory, "src/locales/de.json") - ); + await updateLocale("de", invalid); await waitFor( async () => output.slice(offset).includes("compiled with") || undefined, 15000, @@ -189,7 +183,7 @@ test("CLI watch discovers, edits and removes locales and recovers from invalid t expect(await readFile(path.join(directory, "_locales/en/messages.json"), "utf8")).toBe(previousJson); expect(await readFile(path.join(directory, "js/background.js"), "utf8")).toBe(previousBundle); - await copyFile(path.join(__dirname, "states/de.json"), path.join(fixture.directory, "src/locales/de.json")); + await updateLocale("de", "de.json"); await waitFor( async () => { expect((await inspect(directory)).catalogue.de.app_title).toBe("Übersetzungskatalog"); From 36032ff82915ff4382e1a544146888d4f71ad15d Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:15:14 +0300 Subject: [PATCH 32/38] refactor(locale)!: make runtime translation explicit Remove implicit locale resolution from view and command entrypoint wrappers and from changePopup/changeSidebar. Keep resolve available through the public locale entrypoint for explicit use. BREAKING CHANGE: Runtime view titles, command descriptions, and titles applied by changePopup/changeSidebar are now passed through unchanged. Call resolve explicitly when translating locale markers is required. Build-time localization of manifest fields is unchanged. --- src/cli/virtual/command.ts | 4 +--- src/cli/virtual/view.ts | 5 +---- src/cli/virtual/virtual.test.ts | 4 ++-- src/locale/helpers.test.ts | 2 +- src/main/popup.ts | 4 +--- src/main/sidebar.ts | 4 +--- 6 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/cli/virtual/command.ts b/src/cli/virtual/command.ts index e22c820e..c8dfd6a0 100644 --- a/src/cli/virtual/command.ts +++ b/src/cli/virtual/command.ts @@ -1,5 +1,4 @@ import type {CommandUnresolvedDefinition} from "adnbn"; -import {resolve} from "adnbn/locale"; import command, {isValidCommandDefinition, isValidCommandExecuteFunction} from "adnbn/entry/command"; import * as module from "virtual:command-entrypoint"; @@ -17,12 +16,11 @@ try { definition = {...definition, execute: defaultDefinition}; } - const {execute, name = commandName, description, ...options} = definition; + const {execute, name = commandName, ...options} = definition; command({ name, execute, - description: description ? resolve(description) : undefined, ...options, }); } catch (e) { diff --git a/src/cli/virtual/view.ts b/src/cli/virtual/view.ts index 09229f69..e7d680e4 100644 --- a/src/cli/virtual/view.ts +++ b/src/cli/virtual/view.ts @@ -1,5 +1,4 @@ import type {ViewDefinition, ViewOptions} from "adnbn"; -import {resolve} from "adnbn/locale"; import {isViewDefinition, isValidViewDefinitionRenderValue} from "adnbn/entry/view"; import view from "virtual:view-framework"; @@ -17,9 +16,7 @@ try { definition = {...definition, render: defaultDefinition}; } - const {title, ...options} = definition; - - view({title: title ? resolve(title) : undefined, ...options}); + view(definition); } catch (e) { console.error("The view crashed on startup:", e); } diff --git a/src/cli/virtual/virtual.test.ts b/src/cli/virtual/virtual.test.ts index 9907dba1..422f5c82 100644 --- a/src/cli/virtual/virtual.test.ts +++ b/src/cli/virtual/virtual.test.ts @@ -15,7 +15,7 @@ describe("Built virtual modules", () => { }, { generator: "virtualCommandModule", - imports: ["adnbn", "adnbn/locale", "adnbn/entry/command", "{entry}"], + imports: ["adnbn", "adnbn/entry/command", "{entry}"], }, { generator: "virtualContentScriptModule", @@ -64,7 +64,7 @@ describe("Built virtual modules", () => { }, { generator: "virtualViewModule", - imports: ["adnbn", "adnbn/locale", "adnbn/entry/view", "adnbn/entry/view/{framework}", "{entry}"], + imports: ["adnbn", "adnbn/entry/view", "adnbn/entry/view/{framework}", "{entry}"], }, ]; let generated: Record<"ts" | "tsx", Record>; diff --git a/src/locale/helpers.test.ts b/src/locale/helpers.test.ts index 8b1ab0df..01ce02c7 100644 --- a/src/locale/helpers.test.ts +++ b/src/locale/helpers.test.ts @@ -11,7 +11,7 @@ jest.mock("@addon-core/browser", () => ({ })); import {getI18nMessage} from "@addon-core/browser"; -import {resolve} from "./helpers"; +import {resolve} from "./index"; jest.mock("#adnbn/locale", () => ({keys: ["locale", "app.title", "app.greeting"], languages: ["en"]})); diff --git a/src/main/popup.ts b/src/main/popup.ts index 541a5533..4ed729a9 100644 --- a/src/main/popup.ts +++ b/src/main/popup.ts @@ -1,7 +1,5 @@ import {setActionPopup, setActionTitle} from "@addon-core/browser"; -import {resolve} from "@locale/helpers"; - import {changeActionIcon} from "./icon"; import {PopupDefinition} from "@typing/popup"; @@ -52,7 +50,7 @@ export const changePopup = async (alias: PopupAlias, tab?: number | Tab): Promis await setActionPopup(path, tab); if (title) { - await setActionTitle(resolve(title), tab); + await setActionTitle(title, tab); } if (icon) { diff --git a/src/main/sidebar.ts b/src/main/sidebar.ts index ec6ef02b..36e6c0e6 100644 --- a/src/main/sidebar.ts +++ b/src/main/sidebar.ts @@ -1,7 +1,5 @@ import {setSidebarPath, setSidebarTitle} from "@addon-core/browser"; -import {resolve} from "@locale/helpers"; - import type {SidebarDefinition} from "@typing/sidebar"; import type {ManifestSidebar} from "@typing/manifest"; @@ -50,6 +48,6 @@ export const changeSidebar = async (alias: SidebarAlias, tab?: number | Tab): Pr await setSidebarPath(path, tab); if (title) { - await setSidebarTitle(resolve(title), tab); + await setSidebarTitle(title, tab); } }; From 36eb13914e0eb61ed9f8e87ac1a4b33c6b671f21 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:15:55 +0300 Subject: [PATCH 33/38] refactor: deliver page and relay data through virtual modules --- jest.config.ts | 2 +- package.json | 2 +- .../bundler/plugins/runtime-data/README.md | 8 ++-- src/cli/plugins/content/index.ts | 24 +++++------ src/cli/plugins/content/relay-module.ts | 7 +++ src/cli/plugins/locale/index.ts | 2 +- .../locale/{module.ts => locale-module.ts} | 0 src/cli/plugins/page/index.ts | 26 +++++------ src/cli/plugins/page/page-module.ts | 5 +++ src/locale/README.md | 4 +- src/locale/catalogue/index.ts | 2 - src/locale/catalogue/module.d.ts | 7 --- src/main/page.ts | 7 ++- src/main/relay.test.ts | 43 +++++-------------- src/main/relay.ts | 9 +--- src/relay/README.md | 2 +- src/types/page.ts | 3 -- src/types/relay.ts | 3 -- .../catalogue.ts => virtual/locale.ts} | 0 src/virtual/modules.d.ts | 15 +++++++ src/virtual/page.ts | 2 + src/virtual/relay.ts | 4 ++ .../isolation-watch.integration.test.ts | 19 ++++++++ .../build/isolation/states/background.ts | 5 +++ .../build/locale/chunks.integration.test.ts | 2 +- .../build/locale/states/package-import.mjs | 3 ++ .../relay-runtime.integration.test.ts | 43 +++++++++++++++++++ .../build/relay-runtime/states/background.ts | 7 ++- .../relay-runtime/states/page-background.ts | 5 +++ .../build/relay-runtime/states/page.ts | 3 ++ .../relay-runtime/states/unused-background.ts | 9 ++++ 31 files changed, 173 insertions(+), 100 deletions(-) create mode 100644 src/cli/plugins/content/relay-module.ts rename src/cli/plugins/locale/{module.ts => locale-module.ts} (100%) create mode 100644 src/cli/plugins/page/page-module.ts delete mode 100644 src/locale/catalogue/index.ts delete mode 100644 src/locale/catalogue/module.d.ts rename src/{locale/catalogue/catalogue.ts => virtual/locale.ts} (100%) create mode 100644 src/virtual/modules.d.ts create mode 100644 src/virtual/page.ts create mode 100644 src/virtual/relay.ts create mode 100644 tests/integration/build/isolation/states/background.ts create mode 100644 tests/integration/build/relay-runtime/states/page-background.ts create mode 100644 tests/integration/build/relay-runtime/states/page.ts create mode 100644 tests/integration/build/relay-runtime/states/unused-background.ts diff --git a/jest.config.ts b/jest.config.ts index 9e19c53b..0aff96b1 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -8,7 +8,7 @@ const config: Config = { modulePathIgnorePatterns: ["/.cache/"], resolver: "/tests/raw-module-resolver.cjs", moduleNameMapper: { - "^#adnbn/locale$": "/src/locale/catalogue/index.ts", + "^#adnbn/(.*)$": "/src/virtual/$1.ts", "^@cli/(.*)$": "/src/cli/$1", "^@entry/(.*)$": "/src/entry/$1", "^@frame/(.*)$": "/src/frame/$1", diff --git a/package.json b/package.json index b331ce40..955aa5d1 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ ], "sideEffects": false, "imports": { - "#adnbn/locale": "./dist/locale/catalogue/index.js" + "#adnbn/*": "./dist/virtual/*.js" }, "engines": { "node": ">=22.0.0" diff --git a/src/cli/bundler/plugins/runtime-data/README.md b/src/cli/bundler/plugins/runtime-data/README.md index 447001f0..84e62dad 100644 --- a/src/cli/bundler/plugins/runtime-data/README.md +++ b/src/cli/bundler/plugins/runtime-data/README.md @@ -4,10 +4,10 @@ a carrier file, fetch metadata or connect another entrypoint's JavaScript. `update(data)` changes the next compilation's snapshot, including watch rebuilds. -The optional `test(entry)` selects entrypoints by name. Without it, every runtime receives the data, -preserving the page and Relay getters' current availability. With a shared runtime chunk, matching -any consuming entrypoint selects that runtime. The selector does not inspect API usage or tree-shake -individual map entries. Excluding a runtime that calls the corresponding getter is the caller's error. +The optional `test(entry)` selects entrypoints by name. Without it, every runtime receives the data. +With a shared runtime chunk, matching any consuming entrypoint selects that runtime. The selector +does not inspect API usage or tree-shake individual map entries. Excluding a runtime that reads the +property is the caller's error. Insertion is guarded once per runtime and plugin instance within each compilation. Distinct properties can coexist. Guard and snapshot state are refreshed per compilation, not retained across rebuilds. diff --git a/src/cli/plugins/content/index.ts b/src/cli/plugins/content/index.ts index 34adfe25..d7ca7c85 100644 --- a/src/cli/plugins/content/index.ts +++ b/src/cli/plugins/content/index.ts @@ -6,6 +6,7 @@ import Relay from "./Relay"; import RelayDeclaration from "./RelayDeclaration"; import {hasIsolatedTarget} from "./utils"; import {createPageAccessRequirements, getContentChunkName, validateContentStyles} from "./bundler"; +import {createRelayModule, RelayModuleName} from "./relay-module"; import {definePlugin} from "@main/plugin"; import {PageFinder} from "@cli/entrypoint"; @@ -18,13 +19,11 @@ import { onlyViaTopLevelEntry, IsolatedStylesPlugin, ResourceAccessPlugin, - RuntimeDataPlugin, - type RuntimeDataPluginData, + GenerateModulePlugin, } from "@cli/bundler"; import {Command} from "@typing/app"; import {ContentScriptStylesRuntimeProperty, ContentScriptWorld} from "@typing/content"; -import {RelayOptionsRuntimeProperty} from "@typing/relay"; export default definePlugin(() => { let contentProvider: Content; @@ -52,18 +51,17 @@ export default definePlugin(() => { let entryOptionsByName = await contentManager.entryOptions(); - const getRelayData = async (): Promise => { - // Preserve the previous JSON payload: absent optional values must not become undefined data. - return JSON.parse(JSON.stringify(await relayProvider.getOptionsMap())); - }; - - const relayDataPlugin = new RuntimeDataPlugin({ - property: RelayOptionsRuntimeProperty, - data: await getRelayData(), + const getModules = async () => ({ + [RelayModuleName]: createRelayModule(await relayProvider.getOptionsMap()), }); + const modulePlugin = new GenerateModulePlugin(await getModules()); + + if (config.command === Command.Watch) { + modulePlugin.watch(getModules); + } const basePlugins: Plugins = [ - relayDataPlugin, + modulePlugin, new ResourceAccessPlugin({ requirements: async () => { if ( @@ -126,8 +124,6 @@ export default definePlugin(() => { const entries = await contentManager.entries(); entryOptionsByName = await contentManager.entryOptions(); - relayDataPlugin.update(await getRelayData()); - return entries; }); } diff --git a/src/cli/plugins/content/relay-module.ts b/src/cli/plugins/content/relay-module.ts new file mode 100644 index 00000000..ab0435bb --- /dev/null +++ b/src/cli/plugins/content/relay-module.ts @@ -0,0 +1,7 @@ +import type {RelayOptions} from "@typing/relay"; + +export const RelayModuleName = "#adnbn/relay"; + +export const createRelayModule = (options: Record): string => + // Preserve the JSON payload, including omitted optional values, without retaining unused data. + `export const options = /*#__PURE__*/ JSON.parse(${JSON.stringify(JSON.stringify(options))});\n`; diff --git a/src/cli/plugins/locale/index.ts b/src/cli/plugins/locale/index.ts index 25adb747..33609529 100644 --- a/src/cli/plugins/locale/index.ts +++ b/src/cli/plugins/locale/index.ts @@ -8,7 +8,7 @@ import {extractLocaleKey, modifyLocaleMessageKey} from "@shared/locale"; import Locale from "./Locale"; import {LocaleDeclaration} from "./declaration"; -import {createLocaleModule, LocaleModuleLayer, LocaleModuleName} from "./module"; +import {createLocaleModule, LocaleModuleLayer, LocaleModuleName} from "./locale-module"; import {Command} from "@typing/app"; import {Browser} from "@typing/browser"; diff --git a/src/cli/plugins/locale/module.ts b/src/cli/plugins/locale/locale-module.ts similarity index 100% rename from src/cli/plugins/locale/module.ts rename to src/cli/plugins/locale/locale-module.ts diff --git a/src/cli/plugins/page/index.ts b/src/cli/plugins/page/index.ts index 01f889cc..efed19b5 100644 --- a/src/cli/plugins/page/index.ts +++ b/src/cli/plugins/page/index.ts @@ -4,13 +4,13 @@ import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; import Page from "./Page"; import {PageDeclaration} from "./declaration"; +import {createPageModule, PageModuleName} from "./page-module"; import {definePlugin} from "@main/plugin"; import {virtualViewModule} from "@cli/virtual"; -import {EntrypointPlugin, RuntimeDataPlugin} from "@cli/bundler"; +import {EntrypointPlugin, GenerateModulePlugin} from "@cli/bundler"; import {Command} from "@typing/app"; -import {PageAliasesRuntimeProperty} from "@typing/page"; export default definePlugin(() => { let page: Page; @@ -26,11 +26,16 @@ export default definePlugin(() => { bundler: async ({config}) => { declaration.setAlias(await page.getAlias()).build(); - const aliases = Object.fromEntries( - Array.from((await page.views()).values(), item => [item.alias, item.filename]) - ); + const getModules = async () => ({ + [PageModuleName]: createPageModule( + Object.fromEntries(Array.from((await page.views()).values(), item => [item.alias, item.filename])) + ), + }); + const modulePlugin = new GenerateModulePlugin(await getModules()); - const aliasPlugin = new RuntimeDataPlugin({property: PageAliasesRuntimeProperty, data: aliases}); + if (config.command === Command.Watch) { + modulePlugin.watch(getModules); + } const plugins: Plugins = []; @@ -47,12 +52,6 @@ export default definePlugin(() => { plugin.watch(async () => { declaration.setAlias(await page.clear().getAlias()).build(); - aliasPlugin.update( - Object.fromEntries( - Array.from((await page.views()).values(), item => [item.alias, item.filename]) - ) - ); - return page.view().entries(); }); } @@ -64,7 +63,8 @@ export default definePlugin(() => { } return { - plugins: [aliasPlugin, ...plugins], + // Entrypoint watch refreshes the finder and declarations before generating the module. + plugins: [...plugins, modulePlugin], } satisfies RspackConfig; }, manifest: async ({manifest}) => { diff --git a/src/cli/plugins/page/page-module.ts b/src/cli/plugins/page/page-module.ts new file mode 100644 index 00000000..8eb2cbc9 --- /dev/null +++ b/src/cli/plugins/page/page-module.ts @@ -0,0 +1,5 @@ +export const PageModuleName = "#adnbn/page"; + +export const createPageModule = (aliases: Record): string => + // Preserve own __proto__ keys and allow unused data to be removed from optimized bundles. + `export const aliases = /*#__PURE__*/ JSON.parse(${JSON.stringify(JSON.stringify(aliases))});\n`; diff --git a/src/locale/README.md b/src/locale/README.md index 7c1a7f1a..9b979e5b 100644 --- a/src/locale/README.md +++ b/src/locale/README.md @@ -25,7 +25,7 @@ React hooks and their tests live in `adapters/react/hooks/`. The adapter's `inde ## Ownership and data flow -Shared languages and contracts belong to `src/types/locale.ts`. It also declares the empty `LocaleRegistry`, re-exported through `adnbn/locale`. Generated `.adnbn/locale.d.ts` augments that registry, defining the application's keys and required substitutions from its default language for providers, helpers, and adapters. The private module name and bundler layer belong to `src/cli/plugins/locale/module.ts`. +Shared languages and contracts belong to `src/types/locale.ts`. It also declares the empty `LocaleRegistry`, re-exported through `adnbn/locale`. Generated `.adnbn/locale.d.ts` augments that registry, defining the application's keys and required substitutions from its default language for providers, helpers, and adapters. The private module name and bundler layer belong to `src/cli/plugins/locale/locale-module.ts`. Locale discovery, merging, validation, fallback preparation, native `_locales/*/messages.json`, and declaration generation belong to the CLI locale pipeline. The locale feature plugin supplies the generated data; Rspack integration and chunk delivery belong to the bundler. @@ -41,7 +41,7 @@ Common algorithms live in `src/shared/locale`. Its `index.ts` re-exports the hel These modules depend only on shared contracts and have no browser, DOM, React, storage, or CLI dependencies. Consumers supply missing-substitution diagnostics; React node rendering stays inside the React adapter. -During extension builds, the private `#adnbn/locale` import resolves directly to a generated module: a default catalogue export plus named `keys`, `languages`, and `lang` (the configured default language). `DynamicLocale` reads the catalogue; `NativeLocale` imports only the named lists, allowing unused translations to be removed from optimized bundles. Outside these builds, `catalogue/` provides a resolvable empty module with `lang: "en"`. +During extension builds, the private `#adnbn/locale` import resolves directly to a generated module: a default catalogue export plus named `keys`, `languages`, and `lang` (the configured default language). `DynamicLocale` reads the catalogue; `NativeLocale` imports only the named lists, allowing unused translations to be removed from optimized bundles. Outside these builds, `src/virtual/locale.ts` provides a resolvable empty module with `lang: "en"`. Private module declarations in `src/virtual/modules.d.ts` derive their export types from these fallback modules. Views and ISOLATED content scripts can share `locale.js`. MAIN retains its own content layer: its catalogue stays in the entrypoint or joins `common-main.content.js` under the regular content chunk rules. Background keeps translations in its single bundle. This separates file delivery across content execution worlds; the same translations may occur in their respective bundles. diff --git a/src/locale/catalogue/index.ts b/src/locale/catalogue/index.ts deleted file mode 100644 index f6e827b2..00000000 --- a/src/locale/catalogue/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {default} from "./catalogue"; -export * from "./catalogue"; diff --git a/src/locale/catalogue/module.d.ts b/src/locale/catalogue/module.d.ts deleted file mode 100644 index 3841aad8..00000000 --- a/src/locale/catalogue/module.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -declare module "#adnbn/locale" { - const catalogue: import("@typing/locale").LocaleCatalogue; - export const lang: import("@typing/locale").Language; - export const keys: readonly string[]; - export const languages: readonly import("@typing/locale").Language[]; - export default catalogue; -} diff --git a/src/main/page.ts b/src/main/page.ts index b4896915..f70fbd91 100644 --- a/src/main/page.ts +++ b/src/main/page.ts @@ -1,6 +1,7 @@ import {getUrl} from "@addon-core/browser"; +import {aliases} from "#adnbn/page"; -import {PageAliasesRuntimeProperty, type PageAlias, type PageDefinition, type PageMap} from "@typing/page"; +import type {PageAlias, PageDefinition, PageMap} from "@typing/page"; export type {PageDefinition, PageProps, PageConfig, PageAliasRegistry, PageAlias, PageMap} from "@typing/page"; @@ -8,13 +9,11 @@ export const definePage = (options: PageDefinition): PageDefinition => { return options; }; -declare const __webpack_require__: {[PageAliasesRuntimeProperty]?: Record}; - export const getPages = (): PageMap => { const pages: PageMap = new Map(); try { - Object.entries(__webpack_require__[PageAliasesRuntimeProperty] ?? {}).forEach(([key, value]) => { + Object.entries(aliases).forEach(([key, value]) => { pages.set(key, value); }); } catch (e) { diff --git a/src/main/relay.test.ts b/src/main/relay.test.ts index ec47e0d4..48f7c11c 100644 --- a/src/main/relay.test.ts +++ b/src/main/relay.test.ts @@ -1,51 +1,30 @@ +jest.mock("#adnbn/relay", () => ({options: {}})); + import {getRelay} from "./relay"; -import {RelayMethod, RelayOptionsRuntimeProperty, type RelayOptions} from "@typing/relay"; +import {options} from "#adnbn/relay"; +import {RelayMethod} from "@typing/relay"; import {RelayPermissionGlobalKey} from "@relay/RelayPermission"; // Generated RelayRegistry typing is covered by RelayDeclaration tests; exercise arbitrary runtime maps here. const getFixtureRelay = getRelay as (name: string, tabId: number) => unknown; -describe("getRelay runtime options", () => { - const runtimeGlobal = globalThis as typeof globalThis & { - __webpack_require__?: {[RelayOptionsRuntimeProperty]?: Record}; - }; - const runtimeDescriptor = Object.getOwnPropertyDescriptor(globalThis, "__webpack_require__"); +describe("getRelay generated options", () => { const permissionDescriptor = Object.getOwnPropertyDescriptor(globalThis, RelayPermissionGlobalKey); afterEach(() => { - for (const [property, descriptor] of [ - ["__webpack_require__", runtimeDescriptor], - [RelayPermissionGlobalKey, permissionDescriptor], - ] as const) { - if (descriptor) Object.defineProperty(globalThis, property, descriptor); - else Reflect.deleteProperty(globalThis, property); - } + for (const name of Object.keys(options)) delete options[name]; + if (permissionDescriptor) Object.defineProperty(globalThis, RelayPermissionGlobalKey, permissionDescriptor); + else Reflect.deleteProperty(globalThis, RelayPermissionGlobalKey); }); - test("creates a proxy from the options in the current entry runtime", () => { - runtimeGlobal.__webpack_require__ = { - [RelayOptionsRuntimeProperty]: {collector: {name: "collector", method: RelayMethod.Messaging}}, - }; + test("creates a proxy from the generated options", () => { + options.collector = {name: "collector", method: RelayMethod.Messaging}; expect(getFixtureRelay("collector", 1)).toHaveProperty("__proxy", true); expect(() => getFixtureRelay("missing", 1)).toThrow('Failed to get relay "missing"'); }); - test("does not keep the options map of another runtime", () => { - runtimeGlobal.__webpack_require__ = { - [RelayOptionsRuntimeProperty]: {first: {name: "first", method: RelayMethod.Messaging}}, - }; - expect(getFixtureRelay("first", 1)).toHaveProperty("__proxy", true); - - runtimeGlobal.__webpack_require__ = { - [RelayOptionsRuntimeProperty]: {second: {name: "second", method: RelayMethod.Messaging}}, - }; - expect(getFixtureRelay("second", 1)).toHaveProperty("__proxy", true); - expect(() => getFixtureRelay("first", 1)).toThrow('Failed to get relay "first"'); - }); - - test("preserves the unknown Relay error for a runtime without Relay data", () => { - runtimeGlobal.__webpack_require__ = {}; + test("preserves the unknown Relay error without configured Relays", () => { expect(() => getFixtureRelay("missing", 1)).toThrow('Failed to get relay "missing"'); }); }); diff --git a/src/main/relay.ts b/src/main/relay.ts index 5e14e853..bd1c8a2d 100644 --- a/src/main/relay.ts +++ b/src/main/relay.ts @@ -1,5 +1,6 @@ import RelayPermission from "@relay/RelayPermission"; import {ProxyRelay, type ProxyRelayParams} from "@relay/providers"; +import {options as relayOptions} from "#adnbn/relay"; import type {TransportType} from "@typing/transport"; import { @@ -7,9 +8,7 @@ import { RelayAllFrames, RelayFrameErrorKind, RelayMethod, - RelayOptions, RelayOptionsMap, - RelayOptionsRuntimeProperty, type RelayBatchOptions, type RelayBatchProxyTarget, type RelayName, @@ -47,15 +46,11 @@ export const defineRelay = (options: RelayDefinition return options; }; -declare const __webpack_require__: {[RelayOptionsRuntimeProperty]?: Record}; - const getRelayOptionsMap = (): RelayOptionsMap => { const relays: RelayOptionsMap = new Map(); try { - Object.entries(__webpack_require__[RelayOptionsRuntimeProperty] ?? {}).forEach(([name, options]) => - relays.set(name, options) - ); + Object.entries(relayOptions).forEach(([name, options]) => relays.set(name, options)); } catch (e) { console.error("Failed getting relays: ", e); } diff --git a/src/relay/README.md b/src/relay/README.md index 06453de6..8ef5fe9c 100644 --- a/src/relay/README.md +++ b/src/relay/README.md @@ -180,7 +180,7 @@ RelayParser / RelayFinder `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 content plugin supplies the options map to `RuntimeDataPlugin`, which embeds it in each entry runtime under the shared `RelayOptionsRuntimeProperty` key. `getRelay()` reads that runtime data; no separate file or background dependency is added. Watch rebuilds refresh the map together with Relay declarations, including changed names and removed optional values. +The content plugin supplies the options map as the `options` export of the private generated `#adnbn/relay` module. `getRelay()` imports these options through the regular module graph. Optimized builds omit the data when its consumers are unused. Watch rebuilds refresh the module together with Relay declarations, including changed names and removed optional values. Outside extension builds, the package provides an empty options map. 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. diff --git a/src/types/page.ts b/src/types/page.ts index fdb195d7..6bf3d8de 100644 --- a/src/types/page.ts +++ b/src/types/page.ts @@ -1,9 +1,6 @@ import {ViewDefinition, ViewOptions} from "@typing/view"; import {CspOptions} from "@typing/csp"; -/** Shared property used to write and read page aliases on the bundler runtime. */ -export const PageAliasesRuntimeProperty = "__adnbnPageAliases"; - /** Augmented by the generated page declarations of the consuming application. */ export interface PageAliasRegistry {} diff --git a/src/types/relay.ts b/src/types/relay.ts index 66dba6d8..d2b50e81 100644 --- a/src/types/relay.ts +++ b/src/types/relay.ts @@ -17,9 +17,6 @@ import type {MessageError} from "@typing/message"; export const RelayGlobalKey = "adnbnRelay"; -/** Shared build/runtime key for the serialized Relay options map. */ -export const RelayOptionsRuntimeProperty = "__adnbnRelayOptions"; - /** Augmented through adnbn/relay by the generated Relay declarations of the consuming application. */ export interface RelayRegistry {} diff --git a/src/locale/catalogue/catalogue.ts b/src/virtual/locale.ts similarity index 100% rename from src/locale/catalogue/catalogue.ts rename to src/virtual/locale.ts diff --git a/src/virtual/modules.d.ts b/src/virtual/modules.d.ts new file mode 100644 index 00000000..0ed716f7 --- /dev/null +++ b/src/virtual/modules.d.ts @@ -0,0 +1,15 @@ +declare module "#adnbn/locale" { + const catalogue: typeof import("./locale").default; + export const lang: typeof import("./locale").lang; + export const keys: typeof import("./locale").keys; + export const languages: typeof import("./locale").languages; + export default catalogue; +} + +declare module "#adnbn/page" { + export const aliases: typeof import("./page").aliases; +} + +declare module "#adnbn/relay" { + export const options: typeof import("./relay").options; +} diff --git a/src/virtual/page.ts b/src/virtual/page.ts new file mode 100644 index 00000000..45c07144 --- /dev/null +++ b/src/virtual/page.ts @@ -0,0 +1,2 @@ +/** Package fallback; extension builds supply #adnbn/page as a generated module. */ +export const aliases: Record = {}; diff --git a/src/virtual/relay.ts b/src/virtual/relay.ts new file mode 100644 index 00000000..905641a6 --- /dev/null +++ b/src/virtual/relay.ts @@ -0,0 +1,4 @@ +import type {RelayOptions} from "@typing/relay"; + +/** Package fallback; extension builds supply #adnbn/relay as a generated module. */ +export const options: Record = {}; diff --git a/tests/integration/build/isolation/isolation-watch.integration.test.ts b/tests/integration/build/isolation/isolation-watch.integration.test.ts index cdb312b4..fd00f20f 100644 --- a/tests/integration/build/isolation/isolation-watch.integration.test.ts +++ b/tests/integration/build/isolation/isolation-watch.integration.test.ts @@ -2,6 +2,7 @@ import {spawn, type ChildProcess} from "child_process"; import {copyFile, readFile, rm, writeFile} from "fs/promises"; import path from "path"; +import vm from "vm"; import {createIntegrationFixture} from "../../utils/fixture"; import {waitFor, stop} from "../../browser/utils/browser"; @@ -16,6 +17,7 @@ test("real CLI watch changes isolation and page aliases and removes outdated WAR let watcher: ChildProcess | undefined; let output = ""; try { + await copyFile(path.join(__dirname, "states/background.ts"), path.join(fixture.directory, "src/background.ts")); await rm(path.join(fixture.directory, "src/source.content.ts")); await copyFile( path.join(root, "tests/integration/browser/content/isolation-shadow/src/probe.content/probe.woff2"), @@ -33,6 +35,14 @@ test("real CLI watch changes isolation and page aliases and removes outdated WAR ); await setState("none"); const directory = await fixture.build({browser: "chrome"}); + const readPages = async () => { + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const source = await readFile(path.join(directory, manifest.background.service_worker), "utf8"); + const sandbox = {readPages: undefined as undefined | (() => unknown)}; + vm.runInNewContext(source, sandbox); + return sandbox.readPages!(); + }; + expect(await readPages()).toEqual({panel: "panel.html"}); await rm(path.join(directory, "manifest.json")); watcher = spawn(process.execPath, [path.join(root, "bin/adnbn.js"), "watch", ".", "-b", "chrome"], { cwd: fixture.directory, @@ -105,6 +115,15 @@ test("real CLI watch changes isolation and page aliases and removes outdated WAR await setState("renamed"); const renamed = await observe("renamed"); expect(renamed.source).toContain("renamed-panel"); + await waitFor( + async () => { + const pages = await readPages(); + expect(pages).toEqual({"renamed-panel": "panel.html"}); + return pages; + }, + 15000, + "CLI watch to refresh page data without editing its background consumer" + ); const compiled = output; await writeFile(page, pageSource.replace('name: "panel"', 'name: "renamed-panel"')); await waitFor( diff --git a/tests/integration/build/isolation/states/background.ts b/tests/integration/build/isolation/states/background.ts new file mode 100644 index 00000000..4f60ae25 --- /dev/null +++ b/tests/integration/build/isolation/states/background.ts @@ -0,0 +1,5 @@ +import {getPages} from "adnbn"; + +(globalThis as typeof globalThis & {readPages: () => unknown}).readPages = () => Object.fromEntries(getPages()); + +export default () => {}; diff --git a/tests/integration/build/locale/chunks.integration.test.ts b/tests/integration/build/locale/chunks.integration.test.ts index 72873bae..9807a0cb 100644 --- a/tests/integration/build/locale/chunks.integration.test.ts +++ b/tests/integration/build/locale/chunks.integration.test.ts @@ -4,7 +4,7 @@ import {readFile, readdir, rm, writeFile} from "fs/promises"; import path from "path"; import vm from "vm"; -import {createLocaleModule} from "@cli/plugins/locale/module"; +import {createLocaleModule} from "@cli/plugins/locale/locale-module"; import {Language} from "@typing/locale"; import {createIntegrationFixture} from "../../utils/fixture"; diff --git a/tests/integration/build/locale/states/package-import.mjs b/tests/integration/build/locale/states/package-import.mjs index 1f7916af..810e7ac1 100644 --- a/tests/integration/build/locale/states/package-import.mjs +++ b/tests/integration/build/locale/states/package-import.mjs @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; +import {getPages, getRelay} from "adnbn"; import {AbstractLocale, DynamicLocale, NativeLocale} from "adnbn/locale"; assert.equal(typeof globalThis.chrome, "undefined"); assert.equal(Object.getPrototypeOf(DynamicLocale.prototype), AbstractLocale.prototype); assert.equal(typeof NativeLocale, "function"); +assert.equal(getPages().size, 0); +assert.throws(() => getRelay("missing", 1), {message: 'Failed to get relay "missing"'}); diff --git a/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts b/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts index ed0f7c8e..0e94f8d9 100644 --- a/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts +++ b/tests/integration/build/relay-runtime/relay-runtime.integration.test.ts @@ -9,6 +9,49 @@ import {stop, waitFor} from "../../browser/utils/browser"; jest.setTimeout(90_000); +test("generated page and Relay data follow their consumers through the public barrel", async () => { + const fixture = await createIntegrationFixture( + ADNBN_TEST_ROOT, + path.join(ADNBN_TEST_ROOT, "tests/integration/browser/content/relay-styles") + ); + + try { + for (const file of ["control.relay.ts", "iframe.relay.ts"]) await rm(path.join(fixture.directory, "src", file)); + await copyFile( + path.join(__dirname, "states/messaging.ts"), + path.join(fixture.directory, "src/shadow.relay.ts") + ); + await copyFile( + path.join(__dirname, "states/page.ts"), + path.join(fixture.directory, "src/control.page/index.ts") + ); + + for (const state of ["unused-background", "page-background", "background"]) { + await copyFile( + path.join(__dirname, "states", `${state}.ts`), + path.join(fixture.directory, "src/background.ts") + ); + const directory = await fixture.build(); + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const source = await readFile(path.join(directory, manifest.background.service_worker), "utf8"); + expect(source.includes("data-page-alias")).toBe(state === "page-background"); + expect(source.includes("observer")).toBe(state === "background"); + + const sandbox = { + definitions: undefined as unknown, + readPages: undefined as undefined | (() => unknown), + readRelayOptions: undefined as undefined | (() => Record), + }; + vm.runInNewContext(source, sandbox); + if (state === "page-background") expect(sandbox.readPages!()).toEqual({"data-page-alias": "control.html"}); + if (state === "background") expect(Object.keys(sandbox.readRelayOptions!())).toEqual(["observer"]); + if (state === "unused-background") expect(sandbox.definitions).toHaveLength(2); + } + } finally { + await fixture.dispose(); + } +}); + test("content plugin delivers Relay options and refreshes names, methods and optional values in CLI watch", async () => { const root = path.resolve(__dirname, "../../../.."); // Reuse the browser application: the same production configuration also exercises real Relay RPC. diff --git a/tests/integration/build/relay-runtime/states/background.ts b/tests/integration/build/relay-runtime/states/background.ts index cbe9b4c7..80d07e57 100644 --- a/tests/integration/build/relay-runtime/states/background.ts +++ b/tests/integration/build/relay-runtime/states/background.ts @@ -1,7 +1,6 @@ -// Expose only the compiled runtime payload; no browser APIs are needed to inspect it in the build test. -declare const __webpack_require__: {__adnbnRelayOptions?: Record}; +import {options} from "#adnbn/relay"; -(globalThis as typeof globalThis & {readRelayOptions: () => unknown}).readRelayOptions = () => - __webpack_require__.__adnbnRelayOptions; +// Inspect the generated payload independently of browser APIs; Relay RPC has browser coverage. +(globalThis as typeof globalThis & {readRelayOptions: () => unknown}).readRelayOptions = () => options; export default () => {}; diff --git a/tests/integration/build/relay-runtime/states/page-background.ts b/tests/integration/build/relay-runtime/states/page-background.ts new file mode 100644 index 00000000..4f60ae25 --- /dev/null +++ b/tests/integration/build/relay-runtime/states/page-background.ts @@ -0,0 +1,5 @@ +import {getPages} from "adnbn"; + +(globalThis as typeof globalThis & {readPages: () => unknown}).readPages = () => Object.fromEntries(getPages()); + +export default () => {}; diff --git a/tests/integration/build/relay-runtime/states/page.ts b/tests/integration/build/relay-runtime/states/page.ts new file mode 100644 index 00000000..d136fbed --- /dev/null +++ b/tests/integration/build/relay-runtime/states/page.ts @@ -0,0 +1,3 @@ +import {definePage} from "adnbn"; + +export default definePage({name: "data-page-alias"}); diff --git a/tests/integration/build/relay-runtime/states/unused-background.ts b/tests/integration/build/relay-runtime/states/unused-background.ts new file mode 100644 index 00000000..5d5085c6 --- /dev/null +++ b/tests/integration/build/relay-runtime/states/unused-background.ts @@ -0,0 +1,9 @@ +import {definePage, defineRelay} from "adnbn"; + +// Keep the definition helpers from the same public modules, but no getters. +(globalThis as typeof globalThis & {definitions: unknown}).definitions = [ + definePage({name: "local-page-definition"}), + defineRelay({name: "local-relay-definition", init: () => ({})}), +]; + +export default () => {}; From 12719cf6eecf92f935aa410bd1f456fd4f99a0e9 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:27:03 +0300 Subject: [PATCH 34/38] refactor(bundler): remove unused runtime data plugin --- AGENTS.md | 2 +- src/cli/bundler/plugins/index.ts | 2 - .../bundler/plugins/runtime-data/README.md | 13 -- .../runtime-data/RuntimeDataPlugin.test.ts | 217 ------------------ .../plugins/runtime-data/RuntimeDataPlugin.ts | 79 ------- src/cli/bundler/plugins/runtime-data/index.ts | 1 - .../bundler/plugins/runtime-data/templates.ts | 17 -- .../templates/runtime.template.js | 6 - .../runtime-data/tests/fixtures/entry.js | 1 - src/cli/bundler/plugins/runtime-data/utils.ts | 39 ---- tsup.config.ts | 1 - 11 files changed, 1 insertion(+), 377 deletions(-) delete mode 100644 src/cli/bundler/plugins/runtime-data/README.md delete mode 100644 src/cli/bundler/plugins/runtime-data/RuntimeDataPlugin.test.ts delete mode 100644 src/cli/bundler/plugins/runtime-data/RuntimeDataPlugin.ts delete mode 100644 src/cli/bundler/plugins/runtime-data/index.ts delete mode 100644 src/cli/bundler/plugins/runtime-data/templates.ts delete mode 100644 src/cli/bundler/plugins/runtime-data/templates/runtime.template.js delete mode 100644 src/cli/bundler/plugins/runtime-data/tests/fixtures/entry.js delete mode 100644 src/cli/bundler/plugins/runtime-data/utils.ts diff --git a/AGENTS.md b/AGENTS.md index 7c838aa4..db2b074e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ - Separate a shared protocol from its implementation. A runtime property name used by both CLI and extension runtime belongs in a side-effect-free shared contract; each side imports it independently. The generic Rspack plugin receives the property through its options, using the shared `property` contract. - Do not move build-only details into public domain types merely because a feature uses them. For example, `IsolatedStylesLayer` is a bundler classification detail; a property used to communicate with the extension's styles runtime is a shared contract. - Keep each Rspack plugin encapsulated in its own directory with its class, tests, helpers, templates, and an `index.ts` exporting the necessary surface. Runtime JavaScript templates belong beside their owning plugin and use the existing raw-template pipeline; do not mix unrelated plugin runtimes in a common directory or embed large JavaScript strings in plugin classes. -- `RuntimeDataPlugin` embeds data known before filename hashing. It is not a universal replacement for `DefinePlugin`, which remains appropriate for compile-time constants and substitutions. Data depending on final asset names needs the bundler's filename and hashing lifecycle, not an early snapshot or an ad hoc post-build rewrite. +- `DefinePlugin` is appropriate for compile-time constants and substitutions. Data depending on final asset names needs the bundler's filename and hashing lifecycle, not an early snapshot or an ad hoc post-build rewrite. - Refactoring must not introduce background dependencies into other entrypoints, hidden global registries, message-based metadata delivery, or extra carrier files. Such behavior requires a separate, explicit design decision. ### Asset inventory, delivery, and access diff --git a/src/cli/bundler/plugins/index.ts b/src/cli/bundler/plugins/index.ts index b791773c..fbf9c77c 100644 --- a/src/cli/bundler/plugins/index.ts +++ b/src/cli/bundler/plugins/index.ts @@ -45,6 +45,4 @@ export {default as ReplacePlugin} from "./replace"; export {default as WatchPlugin} from "./watch"; -export {default as RuntimeDataPlugin, type RuntimeDataPluginData, type RuntimeDataPluginOptions} from "./runtime-data"; - export type {RuntimePropertyOptions} from "./types"; diff --git a/src/cli/bundler/plugins/runtime-data/README.md b/src/cli/bundler/plugins/runtime-data/README.md deleted file mode 100644 index 84e62dad..00000000 --- a/src/cli/bundler/plugins/runtime-data/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Runtime data - -`RuntimeDataPlugin` embeds a serializable snapshot known before filename hashing. It does not emit -a carrier file, fetch metadata or connect another entrypoint's JavaScript. `update(data)` changes -the next compilation's snapshot, including watch rebuilds. - -The optional `test(entry)` selects entrypoints by name. Without it, every runtime receives the data. -With a shared runtime chunk, matching any consuming entrypoint selects that runtime. The selector -does not inspect API usage or tree-shake individual map entries. Excluding a runtime that reads the -property is the caller's error. - -Insertion is guarded once per runtime and plugin instance within each compilation. Distinct properties -can coexist. Guard and snapshot state are refreshed per compilation, not retained across rebuilds. diff --git a/src/cli/bundler/plugins/runtime-data/RuntimeDataPlugin.test.ts b/src/cli/bundler/plugins/runtime-data/RuntimeDataPlugin.test.ts deleted file mode 100644 index 4cea1cbb..00000000 --- a/src/cli/bundler/plugins/runtime-data/RuntimeDataPlugin.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** @jest-environment node */ -import fs from "fs"; -import os from "os"; -import path from "path"; -import vm from "vm"; -import { - rspack, - type Compiler, - type Configuration, - type Filename, - type RspackPluginInstance, - type Stats, -} from "@rspack/core"; - -import RuntimeDataPlugin, {type RuntimeDataPluginData} from "./RuntimeDataPlugin"; - -const run = (compiler: Compiler): Promise => - new Promise((resolve, reject) => - compiler.run((error, stats) => { - if (error || !stats || stats.hasErrors()) - reject(error ?? new Error(stats?.toString({all: false, errors: true}))); - else resolve(stats); - }) - ); - -const filenames = (stats: Stats): string[] => - stats.compilation - .getAssets() - .map(asset => asset.name) - .sort(); - -const readData = (stats: Stats, file: string, property: string): unknown => { - const sandbox: {readRuntimeData?: (property: string) => unknown} = {}; - vm.runInNewContext(stats.compilation.getAsset(file)!.source.source().toString(), sandbox); - return sandbox.readRuntimeData!(property); -}; - -describe("RuntimeDataPlugin", () => { - let directory: string; - let compiler: Compiler; - - const build = ( - plugins: RspackPluginInstance[], - filename: Filename = "[name].js", - optimization?: Configuration["optimization"] - ): Compiler => - rspack({ - context: path.join(__dirname, "tests/fixtures"), - mode: "production", - devtool: false, - entry: {first: "./entry.js", second: "./entry.js"}, - output: {path: directory, filename, clean: true}, - optimization, - plugins, - }); - - beforeEach(() => { - directory = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-runtime-data-")); - }); - - afterEach(async () => { - await new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); - fs.rmSync(directory, {recursive: true, force: true}); - }); - - test.each([ - "[name].js", - "[name].[contenthash:8].js", - "[name].[chunkhash:8].js", - "[name].[fullhash:8].js", - data => `custom/${data.chunk?.name}.[contenthash:8].js`, - ])("updates every runtime without extra files using filename %s", async filename => { - const property = "fixtureData"; - const plugin = new RuntimeDataPlugin({property, data: {panel: "panel.html"}}); - compiler = build([plugin], filename); - - const first = await run(compiler); - const firstFiles = filenames(first); - expect(firstFiles).toHaveLength(2); - for (const file of firstFiles) expect(readData(first, file, property)).toEqual({panel: "panel.html"}); - - plugin.update({renamed: "panel.html"}); - const second = await run(compiler); - const secondFiles = filenames(second); - expect(secondFiles).toHaveLength(2); - for (const file of secondFiles) expect(readData(second, file, property)).toEqual({renamed: "panel.html"}); - if (filename !== "[name].js") expect(secondFiles).not.toEqual(firstFiles); - - const repeated = await run(compiler); - // Rspack's fullhash changes on repeated compilations even without a data plugin. - if (filename !== "[name].[fullhash:8].js") expect(filenames(repeated)).toEqual(secondFiles); - for (const file of filenames(repeated)) - expect(readData(repeated, file, property)).toEqual({renamed: "panel.html"}); - }); - - test("selects entries and drops stale data when selection changes between compilations", async () => { - let selected = "first"; - const plugin = new RuntimeDataPlugin({ - property: "fixture", - data: {value: 1}, - test: entry => entry === selected, - }); - compiler = build([plugin]); - for (const entry of ["first", "second", "first"]) { - selected = entry; - plugin.update({value: entry}); - const stats = await run(compiler); - expect(readData(stats, `${entry}.js`, "fixture")).toEqual({value: entry}); - const excluded = entry === "first" ? "second" : "first"; - expect(readData(stats, `${excluded}.js`, "fixture")).toBeUndefined(); - } - }); - - test("selects a shared runtime by its consuming entries and adds each property only once", async () => { - const selected: string[] = []; - compiler = build( - [ - new RuntimeDataPlugin({ - property: "fixture", - data: {shared: true}, - test: entry => { - selected.push(entry); - return entry === "second"; - }, - }), - ], - "[name].js", - {runtimeChunk: {name: "shared-runtime"}} - ); - const attached: string[] = []; - compiler.hooks.thisCompilation.tap("CountRuntimeDataFixture", compilation => { - compilation.hooks.runtimeModule.tap("CountRuntimeDataFixture", (module, chunk) => { - if (module.identifier().endsWith('runtime data "fixture"')) attached.push(chunk.name!); - }); - }); - const stats = await run(compiler); - const runtime = stats.compilation.entrypoints.get("first")!.getRuntimeChunk()!; - expect(runtime).toBe(stats.compilation.entrypoints.get("second")!.getRuntimeChunk()); - expect(selected).toEqual(["first", "second"]); - expect(attached).toEqual(["shared-runtime"]); - - // Repeating the hook must not add a second runtime module for the same property. - stats.compilation.hooks.additionalTreeRuntimeRequirements.call(runtime, new Set()); - expect(attached).toEqual(["shared-runtime"]); - expect(filenames(stats)).toEqual(["first.js", "second.js", "shared-runtime.js"]); - }); - - test("preserves literal strings and object keys without treating them as code or template markers", async () => { - const property = 'slot"]["__ADNBN_DATA__$&\\\n'; - const data = { - text: "$& $` $' __ADNBN_REQUIRE__ __ADNBN_DATA__ \" \\ \n \u2028", - nested: [null, true, false, 12.5, {value: "text"}], - object: JSON.parse('{"__proto__":{"value":"own property"}}'), - }; - compiler = build([ - new RuntimeDataPlugin({property, data}), - new RuntimeDataPlugin({property: "__proto__", data: "second slot"}), - ]); - const stats = await run(compiler); - for (const file of filenames(stats)) { - expect(readData(stats, file, property)).toEqual(data); - expect(readData(stats, file, "__proto__")).toBe("second slot"); - } - }); - - test("freezes data for one compilation even if update runs before its runtime modules are generated", async () => { - const data = {value: "first"}; - const plugin = new RuntimeDataPlugin({property: "snapshot", data}); - compiler = build([ - plugin, - { - apply(compiler) { - compiler.hooks.thisCompilation.tap("UpdateDataFixture", () => { - data.value = "mutated"; - plugin.update({value: "next"}); - }); - }, - }, - ]); - const first = await run(compiler); - for (const file of filenames(first)) expect(readData(first, file, "snapshot")).toEqual({value: "first"}); - const next = await run(compiler); - for (const file of filenames(next)) expect(readData(next, file, "snapshot")).toEqual({value: "next"}); - }); - - const cyclic: Record = {}; - cyclic.self = cyclic; - test.each([ - ["undefined", {nested: undefined}], - ["function", {nested: () => 1}], - ["symbol", Symbol("data")], - ["symbol key", {[Symbol("key")]: 1}], - ["bigint", BigInt(1)], - ["NaN", NaN], - ["infinity", Infinity], - ["class instance", new Date(0)], - ["cycle", cyclic], - ["sparse array", Array(2)], - ["named array property", Object.assign([1], {extra: true})], - [ - "accessor", - { - get value() { - throw new Error("must not evaluate the getter"); - }, - }, - ], - ["toJSON", {toJSON: () => "changed"}], - ])("rejects %s and recovers on the next compilation", async (_label, data) => { - const plugin = new RuntimeDataPlugin({property: "fixture", data: data as RuntimeDataPluginData}); - compiler = build([plugin]); - await expect(run(compiler)).rejects.toThrow('RuntimeDataPlugin property "fixture" has invalid data'); - plugin.update(null); - const recovered = await run(compiler); - for (const file of filenames(recovered)) expect(readData(recovered, file, "fixture")).toBeNull(); - }); -}); diff --git a/src/cli/bundler/plugins/runtime-data/RuntimeDataPlugin.ts b/src/cli/bundler/plugins/runtime-data/RuntimeDataPlugin.ts deleted file mode 100644 index 3d65f913..00000000 --- a/src/cli/bundler/plugins/runtime-data/RuntimeDataPlugin.ts +++ /dev/null @@ -1,79 +0,0 @@ -import {RuntimeGlobals, RuntimeModule, type Chunk, type Compiler} from "@rspack/core"; - -import type {RuntimePropertyOptions} from "../types"; -import {renderRuntimeData} from "./templates"; -import {serializeRuntimeData} from "./utils"; - -export type RuntimeDataPluginData = - | string - | number - | boolean - | null - | readonly RuntimeDataPluginData[] - | {readonly [key: string]: RuntimeDataPluginData}; - -export interface RuntimeDataPluginOptions extends RuntimePropertyOptions { - data: RuntimeDataPluginData; - /** Select entrypoints by name. A shared runtime receives data if any of its entries matches. Defaults to all runtimes. */ - test?: (entry: string) => boolean; -} - -class RuntimeDataRuntimeModule extends RuntimeModule { - public constructor( - private readonly property: string, - private readonly data: string - ) { - super(`runtime data ${JSON.stringify(property)}`, RuntimeModule.STAGE_BASIC); - } - - public generate(): string { - return renderRuntimeData({property: this.property, data: this.data, require: RuntimeGlobals.require}); - } -} - -/** Embeds data known before chunk hashing; it does not resolve output filenames or emit a separate file. */ -export default class RuntimeDataPlugin { - private readonly property: string; - private data: RuntimeDataPluginData; - private readonly test?: RuntimeDataPluginOptions["test"]; - - public constructor(options: RuntimeDataPluginOptions) { - this.property = options.property; - this.data = options.data; - this.test = options.test; - } - - public apply(compiler: Compiler): void { - compiler.hooks.thisCompilation.tap("RuntimeDataPlugin", compilation => { - // Selected runtimes share an immutable snapshot for this compilation only. - const injected = new Set(); - let data: string; - try { - data = serializeRuntimeData(this.data, this.property); - } catch (error) { - compilation.errors.push(error instanceof Error ? error : new Error(String(error))); - return; - } - - compilation.hooks.additionalTreeRuntimeRequirements.tap("RuntimeDataPlugin", (chunk, requirements) => { - if (injected.has(chunk)) return; - if ( - this.test && - !Array.from(compilation.entrypoints).some( - ([name, entrypoint]) => entrypoint.getRuntimeChunk() === chunk && this.test!(name) - ) - ) - return; - - injected.add(chunk); - requirements.add(RuntimeGlobals.require); - compilation.addRuntimeModule(chunk, new RuntimeDataRuntimeModule(this.property, data)); - }); - }); - } - - /** Updates the data used by the next compilation, including watch rebuilds. */ - public update(data: RuntimeDataPluginData): void { - this.data = data; - } -} diff --git a/src/cli/bundler/plugins/runtime-data/index.ts b/src/cli/bundler/plugins/runtime-data/index.ts deleted file mode 100644 index 034b7fc2..00000000 --- a/src/cli/bundler/plugins/runtime-data/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {default, type RuntimeDataPluginData, type RuntimeDataPluginOptions} from "./RuntimeDataPlugin"; diff --git a/src/cli/bundler/plugins/runtime-data/templates.ts b/src/cli/bundler/plugins/runtime-data/templates.ts deleted file mode 100644 index e8ff6b6e..00000000 --- a/src/cli/bundler/plugins/runtime-data/templates.ts +++ /dev/null @@ -1,17 +0,0 @@ -import runtimeTemplate from "./templates/runtime.template.js?raw"; - -import type {RuntimePropertyOptions} from "../types"; -import {renderRuntimeTemplate} from "../utils/runtime-template"; - -interface RuntimeDataTemplateOptions extends RuntimePropertyOptions { - readonly require: string; - /** Validated and serialized JSON, parsed in the generated runtime to preserve keys such as __proto__. */ - readonly data: string; -} - -export const renderRuntimeData = (options: RuntimeDataTemplateOptions): string => - renderRuntimeTemplate(runtimeTemplate, { - __ADNBN_REQUIRE__: options.require, - __ADNBN_PROPERTY__: JSON.stringify(options.property), - __ADNBN_DATA__: JSON.stringify(options.data), - }); diff --git a/src/cli/bundler/plugins/runtime-data/templates/runtime.template.js b/src/cli/bundler/plugins/runtime-data/templates/runtime.template.js deleted file mode 100644 index 8908eb60..00000000 --- a/src/cli/bundler/plugins/runtime-data/templates/runtime.template.js +++ /dev/null @@ -1,6 +0,0 @@ -Object.defineProperty(__ADNBN_REQUIRE__, __ADNBN_PROPERTY__, { - value: JSON.parse(__ADNBN_DATA__), - configurable: true, - enumerable: true, - writable: true, -}); diff --git a/src/cli/bundler/plugins/runtime-data/tests/fixtures/entry.js b/src/cli/bundler/plugins/runtime-data/tests/fixtures/entry.js deleted file mode 100644 index 5289a755..00000000 --- a/src/cli/bundler/plugins/runtime-data/tests/fixtures/entry.js +++ /dev/null @@ -1 +0,0 @@ -globalThis.readRuntimeData = property => __webpack_require__[property]; diff --git a/src/cli/bundler/plugins/runtime-data/utils.ts b/src/cli/bundler/plugins/runtime-data/utils.ts deleted file mode 100644 index d8bd6308..00000000 --- a/src/cli/bundler/plugins/runtime-data/utils.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** Reject values JSON.stringify would silently omit, coerce, or evaluate through accessors/toJSON. */ -export const serializeRuntimeData = (data: unknown, property: string): string => { - const ancestors = new Set(); - const invalid = (location: string, reason: string): never => { - throw new Error( - `RuntimeDataPlugin property ${JSON.stringify(property)} has invalid data at ${location}: ${reason}` - ); - }; - - const validate = (value: unknown, location: string): void => { - if (value === null || typeof value === "string" || typeof value === "boolean") return; - if (typeof value === "number" && Number.isFinite(value)) return; - if (typeof value !== "object" || value === null) return invalid(location, "expected a JSON-compatible value"); - if (ancestors.has(value)) invalid(location, "circular reference"); - - const array = Array.isArray(value); - const prototype = Object.getPrototypeOf(value); - if (array ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) { - invalid(location, "expected a plain object or array"); - } - - ancestors.add(value); - for (const key of Reflect.ownKeys(value)) { - if (array && key === "length") continue; - if (typeof key !== "string") return invalid(location, "symbol keys are not supported"); - if (array && !/^(0|[1-9]\d*)$/.test(key)) invalid(location, "named array properties are not supported"); - const descriptor = Object.getOwnPropertyDescriptor(value, key)!; - if (!descriptor.enumerable || !("value" in descriptor)) { - invalid(location, "only enumerable data properties are supported"); - } - validate(descriptor.value, `${location}[${JSON.stringify(key)}]`); - } - if (array && Object.keys(value).length !== value.length) invalid(location, "sparse arrays are not supported"); - ancestors.delete(value); - }; - - validate(data, "data"); - return JSON.stringify(data); -}; diff --git a/tsup.config.ts b/tsup.config.ts index 2dc45cd6..df87dc20 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -68,7 +68,6 @@ const virtualEntrypointImportPlugin = (): Plugin => ({ }); const runtimeTemplateEntries = [ - "src/cli/bundler/plugins/runtime-data/templates.ts", "src/cli/bundler/plugins/chunk-loader/templates.ts", "src/cli/bundler/plugins/isolated-styles/templates.ts", ]; From af251c5fbc1ac6c3c32f2741d10a16d2734dadbd Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:28:35 +0300 Subject: [PATCH 35/38] refactor: unify entrypoint virtual data and shared contracts Deliver popup, sidebar, offscreen, sandbox and icon data through generated virtual modules with package fallbacks. Move augmentable registries and related contracts into src/types. Preserve public augmentation and generate popup/sidebar/icon registries. Order shared declarations and document empty contracts. Add build coverage for data delivery and tree shaking, plus source and package contract checks in tests/integration/types. --- .../icon/declaration/IconDeclaration.ts | 11 +- src/cli/plugins/icon/declaration/icon.d.ts | 8 +- src/cli/plugins/icon/icon-module.ts | 5 + src/cli/plugins/icon/index.ts | 8 +- src/cli/plugins/offscreen/Offscreen.ts | 8 +- src/cli/plugins/offscreen/index.ts | 14 ++- src/cli/plugins/offscreen/offscreen-module.ts | 7 ++ src/cli/plugins/popup/Popup.ts | 4 +- .../popup/declaration/PopupDeclaration.ts | 11 +- src/cli/plugins/popup/declaration/popup.d.ts | 6 +- src/cli/plugins/popup/index.ts | 11 +- src/cli/plugins/popup/popup-module.ts | 7 ++ src/cli/plugins/sandbox/Sandbox.ts | 4 +- src/cli/plugins/sandbox/index.ts | 12 +- src/cli/plugins/sandbox/sandbox-module.ts | 7 ++ src/cli/plugins/sidebar/Sidebar.ts | 4 +- .../sidebar/declaration/SidebarDeclaration.ts | 11 +- .../plugins/sidebar/declaration/sidebar.d.ts | 6 +- src/cli/plugins/sidebar/index.ts | 11 +- src/cli/plugins/sidebar/sidebar-module.ts | 7 ++ src/main/icon.ts | 16 ++- src/main/offscreen.ts | 22 ++-- src/main/popup.ts | 16 ++- src/main/sandbox.ts | 11 +- src/main/service.ts | 3 +- src/main/sidebar.ts | 13 +- src/message/index.ts | 4 +- src/offscreen/index.ts | 10 +- src/sandbox/index.ts | 8 +- src/service/index.ts | 10 +- src/types/background.ts | 5 +- src/types/command.ts | 4 +- src/types/content.ts | 112 +++++++++-------- src/types/icon.ts | 10 ++ src/types/language.ts | 57 +++++++++ src/types/locale.ts | 73 ++--------- src/types/manifest.ts | 27 +++-- src/types/message.ts | 7 ++ src/types/offscreen.ts | 30 ++++- src/types/page.ts | 5 +- src/types/popup.ts | 11 ++ src/types/relay.ts | 13 +- src/types/sandbox.ts | 22 ++++ src/types/service.ts | 20 ++- src/types/sidebar.ts | 13 ++ src/types/transport.ts | 4 + src/virtual/icon.ts | 2 + src/virtual/modules.d.ts | 20 +++ src/virtual/offscreen.ts | 4 + src/virtual/popup.ts | 4 + src/virtual/sandbox.ts | 4 + src/virtual/sidebar.ts | 4 + tests/integration/README.md | 8 +- .../build/locale/states/package-import.mjs | 26 +++- .../virtual-modules/fixture/adnbn.config.ts | 9 ++ .../virtual-modules/fixture/package.json | 9 ++ .../virtual-modules/fixture/src/background.ts | 11 ++ .../fixture/src/icons/dataicons16.png | Bin 0 -> 82 bytes .../virtual-modules/fixture/src/offscreen.ts | 8 ++ .../virtual-modules/fixture/src/popup.ts | 3 + .../virtual-modules/fixture/src/sandbox.ts | 9 ++ .../virtual-modules/fixture/src/sidebar.ts | 3 + .../virtual-modules/fixture/tsconfig.json | 3 + .../states/unused-background.ts | 11 ++ .../virtual-modules.integration.test.ts | 55 +++++++++ .../types/fixtures/registries/assert.ts | 2 + .../types/fixtures/registries/fallback.ts | 24 ++++ .../types/fixtures/registries/generated.ts | 81 +++++++++++++ .../types/fixtures/registries/internal.ts | 28 +++++ .../types/fixtures/registries/message.ts | 26 ++++ .../types/registries.integration.test.ts | 114 ++++++++++++++++++ 71 files changed, 880 insertions(+), 276 deletions(-) create mode 100644 src/cli/plugins/icon/icon-module.ts create mode 100644 src/cli/plugins/offscreen/offscreen-module.ts create mode 100644 src/cli/plugins/popup/popup-module.ts create mode 100644 src/cli/plugins/sandbox/sandbox-module.ts create mode 100644 src/cli/plugins/sidebar/sidebar-module.ts create mode 100644 src/types/language.ts create mode 100644 src/virtual/icon.ts create mode 100644 src/virtual/offscreen.ts create mode 100644 src/virtual/popup.ts create mode 100644 src/virtual/sandbox.ts create mode 100644 src/virtual/sidebar.ts create mode 100644 tests/integration/build/virtual-modules/fixture/adnbn.config.ts create mode 100644 tests/integration/build/virtual-modules/fixture/package.json create mode 100644 tests/integration/build/virtual-modules/fixture/src/background.ts create mode 100644 tests/integration/build/virtual-modules/fixture/src/icons/dataicons16.png create mode 100644 tests/integration/build/virtual-modules/fixture/src/offscreen.ts create mode 100644 tests/integration/build/virtual-modules/fixture/src/popup.ts create mode 100644 tests/integration/build/virtual-modules/fixture/src/sandbox.ts create mode 100644 tests/integration/build/virtual-modules/fixture/src/sidebar.ts create mode 100644 tests/integration/build/virtual-modules/fixture/tsconfig.json create mode 100644 tests/integration/build/virtual-modules/states/unused-background.ts create mode 100644 tests/integration/build/virtual-modules/virtual-modules.integration.test.ts create mode 100644 tests/integration/types/fixtures/registries/assert.ts create mode 100644 tests/integration/types/fixtures/registries/fallback.ts create mode 100644 tests/integration/types/fixtures/registries/generated.ts create mode 100644 tests/integration/types/fixtures/registries/internal.ts create mode 100644 tests/integration/types/fixtures/registries/message.ts create mode 100644 tests/integration/types/registries.integration.test.ts diff --git a/src/cli/plugins/icon/declaration/IconDeclaration.ts b/src/cli/plugins/icon/declaration/IconDeclaration.ts index 2d03ae0e..ba9ea52c 100644 --- a/src/cli/plugins/icon/declaration/IconDeclaration.ts +++ b/src/cli/plugins/icon/declaration/IconDeclaration.ts @@ -2,7 +2,7 @@ import {FileBuilder} from "@cli/plugins/typescript"; import {ReadonlyConfig} from "@typing/config"; -export default class extends FileBuilder { +export default class IconDeclaration extends FileBuilder { protected names = new Set(); constructor(config: ReadonlyConfig) { @@ -20,11 +20,10 @@ export default class extends FileBuilder { protected template(): string { let content = this.readFile(); - if (this.names.size > 0) { - const type = '"' + Array.from(this.names).join('" | "') + '"'; - - content = content.replace("type IconName = string", `type IconName = ${type}`); - } + content = content.replace( + "// :icon-names", + Array.from(this.names, name => `${JSON.stringify(name)}: true;`).join("\n ") + ); return content; } diff --git a/src/cli/plugins/icon/declaration/icon.d.ts b/src/cli/plugins/icon/declaration/icon.d.ts index a4a79b1f..8dfe05f7 100644 --- a/src/cli/plugins/icon/declaration/icon.d.ts +++ b/src/cli/plugins/icon/declaration/icon.d.ts @@ -1,9 +1,7 @@ import ":package"; declare module ":package" { - export type IconName = string; - - export function changeActionIcon(icon?: IconName, tab?: number | chrome.tabs.Tab): Promise; - - export function changeSidebarIcon(icon?: IconName, tab?: number | chrome.tabs.Tab): Promise; + export interface IconNameRegistry { + // :icon-names + } } diff --git a/src/cli/plugins/icon/icon-module.ts b/src/cli/plugins/icon/icon-module.ts new file mode 100644 index 00000000..1cb8fbc3 --- /dev/null +++ b/src/cli/plugins/icon/icon-module.ts @@ -0,0 +1,5 @@ +export const IconModuleName = "#adnbn/icon"; + +export const createIconModule = (groups: Record>): string => + // Preserve own __proto__ keys and allow unused data to be removed from optimized bundles. + `export const groups = /*#__PURE__*/ JSON.parse(${JSON.stringify(JSON.stringify(groups))});\n`; diff --git a/src/cli/plugins/icon/index.ts b/src/cli/plugins/icon/index.ts index af28c000..1eb7ee17 100644 --- a/src/cli/plugins/icon/index.ts +++ b/src/cli/plugins/icon/index.ts @@ -1,8 +1,10 @@ -import {Configuration as RspackConfig, CopyRspackPlugin, DefinePlugin} from "@rspack/core"; +import {Configuration as RspackConfig, CopyRspackPlugin} from "@rspack/core"; import {definePlugin} from "@main/plugin"; +import {GenerateModulePlugin} from "@cli/bundler"; import Icon, {CopyPatterns, IconDefinition} from "./Icon"; +import {createIconModule, IconModuleName} from "./icon-module"; import {IconDeclaration} from "./declaration"; @@ -25,8 +27,8 @@ export default definePlugin(() => { new CopyRspackPlugin({ patterns: await icon.copy(), }), - new DefinePlugin({ - __ADNBN_ICONS__: JSON.stringify(await icon.define()), + new GenerateModulePlugin({ + [IconModuleName]: createIconModule(await icon.define()), }), ], } satisfies RspackConfig; diff --git a/src/cli/plugins/offscreen/Offscreen.ts b/src/cli/plugins/offscreen/Offscreen.ts index d85148bd..d35f09cc 100644 --- a/src/cli/plugins/offscreen/Offscreen.ts +++ b/src/cli/plugins/offscreen/Offscreen.ts @@ -7,11 +7,9 @@ import {OffscreenFinder, OffscreenViewFinder} from "@cli/entrypoint"; import {virtualOffscreenModule} from "@cli/virtual"; import {ReadonlyConfig} from "@typing/config"; -import {OffscreenEntrypointOptions} from "@typing/offscreen"; +import {OffscreenEntrypointOptions, OffscreenParametersMap} from "@typing/offscreen"; import {EntrypointFile} from "@typing/entrypoint"; -export type OffscreenParameters = Record; - export default class extends OffscreenFinder { protected _view?: View; @@ -53,8 +51,8 @@ export default class extends OffscreenFinder { return virtualOffscreenModule(file, options.name); } - public async parameters(): Promise { - const offscreens: OffscreenParameters = {}; + public async parameters(): Promise { + const offscreens: OffscreenParametersMap = {}; const files = await this.transport(); const filenames = await this.views().getFilenames(); diff --git a/src/cli/plugins/offscreen/index.ts b/src/cli/plugins/offscreen/index.ts index d729a2b9..db6354f9 100644 --- a/src/cli/plugins/offscreen/index.ts +++ b/src/cli/plugins/offscreen/index.ts @@ -1,19 +1,21 @@ import path from "path"; -import {Configuration as RspackConfig, DefinePlugin, HtmlRspackPlugin, Plugins} from "@rspack/core"; +import {Configuration as RspackConfig, HtmlRspackPlugin, Plugins} from "@rspack/core"; import {RspackVirtualModulePlugin} from "rspack-plugin-virtual-module"; import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; import {definePlugin} from "@main/plugin"; -import {EntrypointPlugin} from "@cli/bundler"; +import {EntrypointPlugin, GenerateModulePlugin} from "@cli/bundler"; import {virtualOffscreenBackgroundModule} from "@cli/virtual"; -import Offscreen, {OffscreenParameters} from "./Offscreen"; +import Offscreen from "./Offscreen"; import OffscreenDeclaration from "./OffscreenDeclaration"; +import {createOffscreenModule, OffscreenModuleName} from "./offscreen-module"; import {Command} from "@typing/app"; import {Browser} from "@typing/browser"; import {BackgroundEntryName} from "@typing/background"; +import type {OffscreenParametersMap} from "@typing/offscreen"; const OffscreenTempDir = "virtual"; const OffscreenBackgroundModule = "offscreen.background.ts"; @@ -45,7 +47,7 @@ export default definePlugin(() => { const plugins: Plugins = []; - let parameters: OffscreenParameters = {}; + let parameters: OffscreenParametersMap = {}; if (build) { parameters = await offscreen.parameters(); @@ -90,8 +92,8 @@ export default definePlugin(() => { return { ...rspack, plugins: [ - new DefinePlugin({ - __ADNBN_OFFSCREEN_PARAMETERS__: JSON.stringify(parameters), + new GenerateModulePlugin({ + [OffscreenModuleName]: createOffscreenModule(parameters), }), ...plugins, ], diff --git a/src/cli/plugins/offscreen/offscreen-module.ts b/src/cli/plugins/offscreen/offscreen-module.ts new file mode 100644 index 00000000..b5c504ef --- /dev/null +++ b/src/cli/plugins/offscreen/offscreen-module.ts @@ -0,0 +1,7 @@ +import type {OffscreenParametersMap} from "@typing/offscreen"; + +export const OffscreenModuleName = "#adnbn/offscreen"; + +export const createOffscreenModule = (parameters: OffscreenParametersMap): string => + // Preserve the JSON payload without retaining unused data. + `export const parameters = /*#__PURE__*/ JSON.parse(${JSON.stringify(JSON.stringify(parameters))});\n`; diff --git a/src/cli/plugins/popup/Popup.ts b/src/cli/plugins/popup/Popup.ts index 2f276f08..ebd6c9e5 100644 --- a/src/cli/plugins/popup/Popup.ts +++ b/src/cli/plugins/popup/Popup.ts @@ -8,7 +8,7 @@ import {ReadonlyConfig} from "@typing/config"; import {PopupEntrypointOptions} from "@typing/popup"; import {ManifestPopup} from "@typing/manifest"; -export type PopupNameToManifest = Map; +export type PopupNameToManifest = Record; export default class extends PopupFinder { protected _view?: View; @@ -50,7 +50,7 @@ export default class extends PopupFinder { icon, }, }; - }, new Map() as PopupNameToManifest); + }, {} as PopupNameToManifest); } public clear(): this { diff --git a/src/cli/plugins/popup/declaration/PopupDeclaration.ts b/src/cli/plugins/popup/declaration/PopupDeclaration.ts index 518c7725..8f82378e 100644 --- a/src/cli/plugins/popup/declaration/PopupDeclaration.ts +++ b/src/cli/plugins/popup/declaration/PopupDeclaration.ts @@ -2,7 +2,7 @@ import {FileBuilder} from "@cli/plugins/typescript"; import {ReadonlyConfig} from "@typing/config"; -export default class extends FileBuilder { +export default class PopupDeclaration extends FileBuilder { protected alias = new Set(); constructor(config: ReadonlyConfig) { @@ -20,11 +20,10 @@ export default class extends FileBuilder { protected template(): string { let content = this.readFile(); - if (this.alias.size > 0) { - const type = '"' + Array.from(this.alias).join('" | "') + '"'; - - content = content.replace("type PopupAlias = string", `type PopupAlias = ${type}`); - } + content = content.replace( + "// :popup-aliases", + Array.from(this.alias, alias => `${JSON.stringify(alias)}: true;`).join("\n ") + ); return content; } diff --git a/src/cli/plugins/popup/declaration/popup.d.ts b/src/cli/plugins/popup/declaration/popup.d.ts index c80a558c..143c3b57 100644 --- a/src/cli/plugins/popup/declaration/popup.d.ts +++ b/src/cli/plugins/popup/declaration/popup.d.ts @@ -1,7 +1,7 @@ import ":package"; declare module ":package" { - export type PopupAlias = string; - - export function changePopup(alias: PopupAlias, tab?: number | chrome.tabs.Tab): Promise; + export interface PopupAliasRegistry { + // :popup-aliases + } } diff --git a/src/cli/plugins/popup/index.ts b/src/cli/plugins/popup/index.ts index c78c7214..86e97e93 100644 --- a/src/cli/plugins/popup/index.ts +++ b/src/cli/plugins/popup/index.ts @@ -1,12 +1,13 @@ -import {Configuration as RspackConfig, DefinePlugin, HtmlRspackPlugin, Plugins} from "@rspack/core"; +import {Configuration as RspackConfig, HtmlRspackPlugin, Plugins} from "@rspack/core"; import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; import {definePlugin} from "@main/plugin"; -import {EntrypointPlugin} from "@cli/bundler"; +import {EntrypointPlugin, GenerateModulePlugin} from "@cli/bundler"; import {virtualViewModule} from "@cli/virtual"; import Popup, {PopupNameToManifest} from "./Popup"; +import {createPopupModule, PopupModuleName} from "./popup-module"; import {PopupDeclaration} from "./declaration"; @@ -28,7 +29,7 @@ export default definePlugin(() => { const plugins: Plugins = []; - let alias: PopupNameToManifest = new Map(); + let alias: PopupNameToManifest = {}; if (await popup.empty()) { if (config.debug) { @@ -57,8 +58,8 @@ export default definePlugin(() => { return { plugins: [ - new DefinePlugin({ - __ADNBN_POPUP_MAP__: JSON.stringify(alias), + new GenerateModulePlugin({ + [PopupModuleName]: createPopupModule(alias), }), ...plugins, ], diff --git a/src/cli/plugins/popup/popup-module.ts b/src/cli/plugins/popup/popup-module.ts new file mode 100644 index 00000000..dde1a99c --- /dev/null +++ b/src/cli/plugins/popup/popup-module.ts @@ -0,0 +1,7 @@ +import type {ManifestPopup} from "@typing/manifest"; + +export const PopupModuleName = "#adnbn/popup"; + +export const createPopupModule = (aliases: Record): string => + // Preserve own __proto__ keys and allow unused data to be removed from optimized bundles. + `export const aliases = /*#__PURE__*/ JSON.parse(${JSON.stringify(JSON.stringify(aliases))});\n`; diff --git a/src/cli/plugins/sandbox/Sandbox.ts b/src/cli/plugins/sandbox/Sandbox.ts index 6277dc99..8eeea2dd 100644 --- a/src/cli/plugins/sandbox/Sandbox.ts +++ b/src/cli/plugins/sandbox/Sandbox.ts @@ -4,9 +4,7 @@ import {SandboxFinder, SandboxViewFinder} from "@cli/entrypoint"; import {virtualSandboxModule} from "@cli/virtual"; import {EntrypointFile} from "@typing/entrypoint"; -import {SandboxEntrypointOptions, SandboxParameters} from "@typing/sandbox"; - -export type SandboxParametersMap = Record; +import {SandboxEntrypointOptions, SandboxParametersMap} from "@typing/sandbox"; export default class extends SandboxFinder { protected _view?: View; diff --git a/src/cli/plugins/sandbox/index.ts b/src/cli/plugins/sandbox/index.ts index a4c2b7c9..a7528968 100644 --- a/src/cli/plugins/sandbox/index.ts +++ b/src/cli/plugins/sandbox/index.ts @@ -1,13 +1,15 @@ -import {Configuration as RspackConfig, DefinePlugin, HtmlRspackPlugin, Plugins} from "@rspack/core"; +import {Configuration as RspackConfig, HtmlRspackPlugin, Plugins} from "@rspack/core"; import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; import {definePlugin} from "@main/plugin"; -import {EntrypointPlugin} from "@cli/bundler"; +import {EntrypointPlugin, GenerateModulePlugin} from "@cli/bundler"; -import Sandbox, {SandboxParametersMap} from "./Sandbox"; +import Sandbox from "./Sandbox"; import SandboxDeclaration from "./SandboxDeclaration"; +import {createSandboxModule, SandboxModuleName} from "./sandbox-module"; import {Command} from "@typing/app"; +import type {SandboxParametersMap} from "@typing/sandbox"; export default definePlugin(() => { let sandbox: Sandbox; @@ -59,8 +61,8 @@ export default definePlugin(() => { return { plugins: [ - new DefinePlugin({ - __ADNBN_SANDBOX_PARAMETERS__: JSON.stringify(parameters), + new GenerateModulePlugin({ + [SandboxModuleName]: createSandboxModule(parameters), }), ...plugins, ], diff --git a/src/cli/plugins/sandbox/sandbox-module.ts b/src/cli/plugins/sandbox/sandbox-module.ts new file mode 100644 index 00000000..d586d9c2 --- /dev/null +++ b/src/cli/plugins/sandbox/sandbox-module.ts @@ -0,0 +1,7 @@ +import type {SandboxParametersMap} from "@typing/sandbox"; + +export const SandboxModuleName = "#adnbn/sandbox"; + +export const createSandboxModule = (parameters: SandboxParametersMap): string => + // Preserve omitted optional values and allow unused data to be removed from optimized bundles. + `export const parameters = /*#__PURE__*/ JSON.parse(${JSON.stringify(JSON.stringify(parameters))});\n`; diff --git a/src/cli/plugins/sidebar/Sidebar.ts b/src/cli/plugins/sidebar/Sidebar.ts index 6e48d5ab..cb73be00 100644 --- a/src/cli/plugins/sidebar/Sidebar.ts +++ b/src/cli/plugins/sidebar/Sidebar.ts @@ -8,7 +8,7 @@ import {ReadonlyConfig} from "@typing/config"; import {SidebarEntrypointOptions} from "@typing/sidebar"; import {ManifestSidebar} from "@typing/manifest"; -export type SidebarNameToManifest = Map; +export type SidebarNameToManifest = Record; export default class extends SidebarFinder { protected _view?: View; @@ -50,7 +50,7 @@ export default class extends SidebarFinder { icon, }, }; - }, new Map() as SidebarNameToManifest); + }, {} as SidebarNameToManifest); } public clear(): this { diff --git a/src/cli/plugins/sidebar/declaration/SidebarDeclaration.ts b/src/cli/plugins/sidebar/declaration/SidebarDeclaration.ts index 9e5db7be..c48cee36 100644 --- a/src/cli/plugins/sidebar/declaration/SidebarDeclaration.ts +++ b/src/cli/plugins/sidebar/declaration/SidebarDeclaration.ts @@ -2,7 +2,7 @@ import {FileBuilder} from "@cli/plugins/typescript"; import {ReadonlyConfig} from "@typing/config"; -export default class extends FileBuilder { +export default class SidebarDeclaration extends FileBuilder { protected alias = new Set(); constructor(config: ReadonlyConfig) { @@ -20,11 +20,10 @@ export default class extends FileBuilder { protected template(): string { let content = this.readFile(); - if (this.alias.size > 0) { - const type = '"' + Array.from(this.alias).join('" | "') + '"'; - - content = content.replace("type SidebarAlias = string", `type SidebarAlias = ${type}`); - } + content = content.replace( + "// :sidebar-aliases", + Array.from(this.alias, alias => `${JSON.stringify(alias)}: true;`).join("\n ") + ); return content; } diff --git a/src/cli/plugins/sidebar/declaration/sidebar.d.ts b/src/cli/plugins/sidebar/declaration/sidebar.d.ts index 0cf7de1e..e78a29d9 100644 --- a/src/cli/plugins/sidebar/declaration/sidebar.d.ts +++ b/src/cli/plugins/sidebar/declaration/sidebar.d.ts @@ -1,7 +1,7 @@ import ":package"; declare module ":package" { - export type SidebarAlias = string; - - export function changeSidebar(alias: SidebarAlias, tab?: number | chrome.tabs.Tab): Promise; + export interface SidebarAliasRegistry { + // :sidebar-aliases + } } diff --git a/src/cli/plugins/sidebar/index.ts b/src/cli/plugins/sidebar/index.ts index 64e5d10b..2a1bc9e6 100644 --- a/src/cli/plugins/sidebar/index.ts +++ b/src/cli/plugins/sidebar/index.ts @@ -1,12 +1,13 @@ -import {Configuration as RspackConfig, DefinePlugin, HtmlRspackPlugin, Plugins} from "@rspack/core"; +import {Configuration as RspackConfig, HtmlRspackPlugin, Plugins} from "@rspack/core"; import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; import {definePlugin} from "@main/plugin"; -import {EntrypointPlugin} from "@cli/bundler"; +import {EntrypointPlugin, GenerateModulePlugin} from "@cli/bundler"; import {virtualViewModule} from "@cli/virtual"; import Sidebar, {SidebarNameToManifest} from "./Sidebar"; +import {createSidebarModule, SidebarModuleName} from "./sidebar-module"; import {SidebarDeclaration} from "./declaration"; @@ -49,7 +50,7 @@ export default definePlugin(() => { const plugins: Plugins = []; - let alias: SidebarNameToManifest = new Map(); + let alias: SidebarNameToManifest = {}; if (build) { alias = await sidebar.manifestByAlias(); @@ -74,8 +75,8 @@ export default definePlugin(() => { return { plugins: [ - new DefinePlugin({ - __ADNBN_SIDEBAR_MAP__: JSON.stringify(alias), + new GenerateModulePlugin({ + [SidebarModuleName]: createSidebarModule(alias), }), ...plugins, ], diff --git a/src/cli/plugins/sidebar/sidebar-module.ts b/src/cli/plugins/sidebar/sidebar-module.ts new file mode 100644 index 00000000..e8e35deb --- /dev/null +++ b/src/cli/plugins/sidebar/sidebar-module.ts @@ -0,0 +1,7 @@ +import type {ManifestSidebar} from "@typing/manifest"; + +export const SidebarModuleName = "#adnbn/sidebar"; + +export const createSidebarModule = (aliases: Record): string => + // Preserve own __proto__ keys and allow unused data to be removed from optimized bundles. + `export const aliases = /*#__PURE__*/ JSON.parse(${JSON.stringify(JSON.stringify(aliases))});\n`; diff --git a/src/main/icon.ts b/src/main/icon.ts index 34e117ce..cf129f1a 100644 --- a/src/main/icon.ts +++ b/src/main/icon.ts @@ -1,20 +1,18 @@ import {setActionIcon, setSidebarIcon} from "@addon-core/browser"; +import {groups} from "#adnbn/icon"; -import {DefaultIconGroupName} from "@typing/icon"; +import {DefaultIconGroupName, type IconName, type IconsMap} from "@typing/icon"; type Tab = chrome.tabs.Tab; -export type IconName = string; - -export type IconsMap = Map>; +export type {IconNameRegistry, IconName, IconsMap} from "@typing/icon"; export const getIcons = (): IconsMap => { const icons: IconsMap = new Map(); try { - // @ts-expect-error: __ADNBN_ICONS__ is a virtual variable generated by the bundler `src/cli/plugins/icon/index.ts` - Object.entries>(__ADNBN_ICONS__).forEach(([key, value]) => { - icons.set(key, value); + Object.entries(groups).forEach(([key, value]) => { + icons.set(key as IconName, value); }); } catch (e) { console.error("Failed getting icons: ", e); @@ -25,7 +23,7 @@ export const getIcons = (): IconsMap => { export const changeActionIcon = async (icon?: IconName, tab?: number | Tab): Promise => { if (!icon) { - icon = DefaultIconGroupName; + icon = DefaultIconGroupName as IconName; } const icons = getIcons().get(icon); @@ -43,7 +41,7 @@ export const changeActionIcon = async (icon?: IconName, tab?: number | Tab): Pro export const changeSidebarIcon = async (icon?: IconName, tab?: number | Tab): Promise => { if (!icon) { - icon = DefaultIconGroupName; + icon = DefaultIconGroupName as IconName; } const icons = getIcons().get(icon); diff --git a/src/main/offscreen.ts b/src/main/offscreen.ts index c8b79944..0b2fc332 100644 --- a/src/main/offscreen.ts +++ b/src/main/offscreen.ts @@ -1,16 +1,19 @@ import ProxyOffscreen from "@offscreen/providers/ProxyOffscreen"; - -import {type OffscreenDefinition, OffscreenReason, type OffscreenUnresolvedDefinition} from "@typing/offscreen"; -import type {OffscreenName, OffscreenProxyTarget} from "@offscreen/index"; +import {parameters as offscreenParameters} from "#adnbn/offscreen"; + +import { + type OffscreenDefinition, + type OffscreenMap, + type OffscreenName, + type OffscreenProxyTarget, + OffscreenReason, + type OffscreenUnresolvedDefinition, +} from "@typing/offscreen"; import type {TransportType} from "@typing/transport"; -type OffscreenParameters = chrome.offscreen.CreateParameters; - export {OffscreenReason, OffscreenDefinition, OffscreenUnresolvedDefinition}; -export type OffscreenAlias = string; - -export type OffscreenMap = Map; +export type {OffscreenAlias, OffscreenMap} from "@typing/offscreen"; export const defineOffscreen = (options: OffscreenDefinition): OffscreenDefinition => { return options; @@ -20,8 +23,7 @@ export const getOffscreens = (): OffscreenMap => { const offscreens: OffscreenMap = new Map(); try { - // @ts-expect-error: __ADNBN_OFFSCREEN_PARAMETERS__ is a virtual variable generated by the bundler `src/cli/plugins/offscreen/index.ts` - Object.entries(__ADNBN_OFFSCREEN_PARAMETERS__).forEach(([key, value]) => { + Object.entries(offscreenParameters).forEach(([key, value]) => { offscreens.set(key, value); }); } catch (e) { diff --git a/src/main/popup.ts b/src/main/popup.ts index 4ed729a9..42df60ec 100644 --- a/src/main/popup.ts +++ b/src/main/popup.ts @@ -1,15 +1,14 @@ import {setActionPopup, setActionTitle} from "@addon-core/browser"; +import {aliases} from "#adnbn/popup"; import {changeActionIcon} from "./icon"; -import {PopupDefinition} from "@typing/popup"; -import {ManifestPopup} from "@typing/manifest"; +import type {PopupAlias, PopupDefinition, PopupMap} from "@typing/popup"; +import type {IconName} from "@typing/icon"; type Tab = chrome.tabs.Tab; -export type PopupAlias = string; - -export type PopupMap = Map; +export type {PopupAliasRegistry, PopupAlias, PopupMap} from "@typing/popup"; export const definePopup = (options: PopupDefinition): PopupDefinition => { return options; @@ -19,9 +18,8 @@ export const getPopups = (): PopupMap => { const popups: PopupMap = new Map(); try { - // @ts-expect-error: __ADNBN_POPUP_MAP__ is a virtual variable generated by the bundler `src/cli/plugins/popup/index.ts` - Object.entries(__ADNBN_POPUP_MAP__).forEach(([key, value]) => { - popups.set(key, value); + Object.entries(aliases).forEach(([key, value]) => { + popups.set(key as PopupAlias, value); }); } catch (e) { console.error("Failed getting popups: ", e); @@ -54,6 +52,6 @@ export const changePopup = async (alias: PopupAlias, tab?: number | Tab): Promis } if (icon) { - await changeActionIcon(icon, tab); + await changeActionIcon(icon as IconName, tab); } }; diff --git a/src/main/sandbox.ts b/src/main/sandbox.ts index d5b67e4b..6bf352d8 100644 --- a/src/main/sandbox.ts +++ b/src/main/sandbox.ts @@ -1,15 +1,11 @@ import {ProxySandbox} from "@sandbox/providers"; +import {parameters as sandboxParameters} from "#adnbn/sandbox"; -import {SandboxDefinition, SandboxParameters, SandboxUnresolvedDefinition} from "@typing/sandbox"; -import type {SandboxName, SandboxProxyTarget} from "@sandbox/index"; +import type {SandboxDefinition, SandboxMap, SandboxName, SandboxProxyTarget} from "@typing/sandbox"; import type {TransportType} from "@typing/transport"; export * from "@typing/sandbox"; -export type SandboxAlias = string; - -export type SandboxMap = Map; - export const defineSandbox = (options: SandboxDefinition): SandboxDefinition => { return options; }; @@ -18,8 +14,7 @@ export const getSandboxes = (): SandboxMap => { const sandboxes: SandboxMap = new Map(); try { - // @ts-expect-error: __ADNBN_SANDBOX_PARAMETERS__ is a virtual variable generated by the sandbox plugin. - Object.entries(__ADNBN_SANDBOX_PARAMETERS__).forEach(([key, value]) => { + Object.entries(sandboxParameters).forEach(([key, value]) => { sandboxes.set(key, value); }); } catch (e) { diff --git a/src/main/service.ts b/src/main/service.ts index 7d58ec49..0d6bda47 100644 --- a/src/main/service.ts +++ b/src/main/service.ts @@ -1,8 +1,7 @@ import {ProxyService} from "@service/providers"; -import type {ServiceName, ServiceProxyTarget} from "@service/index"; +import type {ServiceDefinition, ServiceName, ServiceProxyTarget} from "@typing/service"; import type {TransportType} from "@typing/transport"; -import {ServiceDefinition} from "@typing/service"; export type {ServiceDefinition}; diff --git a/src/main/sidebar.ts b/src/main/sidebar.ts index 36e6c0e6..b1e4acc3 100644 --- a/src/main/sidebar.ts +++ b/src/main/sidebar.ts @@ -1,13 +1,11 @@ import {setSidebarPath, setSidebarTitle} from "@addon-core/browser"; +import {aliases} from "#adnbn/sidebar"; -import type {SidebarDefinition} from "@typing/sidebar"; -import type {ManifestSidebar} from "@typing/manifest"; +import type {SidebarAlias, SidebarDefinition, SidebarMap} from "@typing/sidebar"; type Tab = chrome.tabs.Tab; -export type SidebarAlias = string; - -export type SidebarMap = Map; +export type {SidebarAliasRegistry, SidebarAlias, SidebarMap} from "@typing/sidebar"; export const defineSidebar = (options: SidebarDefinition): SidebarDefinition => { return options; @@ -17,9 +15,8 @@ export const getSidebars = (): SidebarMap => { const sidebars: SidebarMap = new Map(); try { - // @ts-expect-error: __ADNBN_SIDEBAR_MAP__ is a virtual variable generated by the bundler `src/cli/plugins/sidebar/index.ts` - Object.entries(__ADNBN_SIDEBAR_MAP__).forEach(([key, value]) => { - sidebars.set(key, value); + Object.entries(aliases).forEach(([key, value]) => { + sidebars.set(key as SidebarAlias, value); }); } catch (e) { console.error("Failed getting sidebars: ", e); diff --git a/src/message/index.ts b/src/message/index.ts index 19cac7b1..a593638d 100644 --- a/src/message/index.ts +++ b/src/message/index.ts @@ -4,6 +4,7 @@ import {UnsupportedMessageTargetError} from "@message/error"; import { MessageData, MessageDictionary, + MessageRegistry, MessageGeneralHandler, MessageMapHandler, MessageResponse, @@ -15,6 +16,7 @@ import { } from "@typing/message"; export type { + MessageRegistry, MessageData, MessageDictionary, MessageGeneralHandler, @@ -29,8 +31,6 @@ export type { export {Message, UnsupportedMessageTargetError}; -export interface MessageRegistry extends MessageDictionary {} - export function sendMessage>( type: K, data: MessageData, diff --git a/src/offscreen/index.ts b/src/offscreen/index.ts index a8a6219f..3c40ba64 100644 --- a/src/offscreen/index.ts +++ b/src/offscreen/index.ts @@ -1,17 +1,11 @@ import {Offscreen, ProxyOffscreen, RegisterOffscreen} from "./providers"; import OffscreenBackground from "./OffscreenBackground"; -import type {TransportProxyTarget, TransportTarget} from "@transport/index"; +import type {OffscreenName, OffscreenTarget} from "@typing/offscreen"; export {ProxyOffscreen, RegisterOffscreen, OffscreenBackground}; -export interface OffscreenRegistry {} - -export type OffscreenName = Extract; - -export type OffscreenTarget = TransportTarget; - -export type OffscreenProxyTarget = TransportProxyTarget; +export type {OffscreenRegistry, OffscreenName, OffscreenTarget, OffscreenProxyTarget} from "@typing/offscreen"; export const getOffscreen = (name: N): OffscreenTarget => { return new Offscreen(name).get(); diff --git a/src/sandbox/index.ts b/src/sandbox/index.ts index 9fb2406f..46c536e0 100644 --- a/src/sandbox/index.ts +++ b/src/sandbox/index.ts @@ -1,11 +1,5 @@ import {ProxySandbox, RegisterSandbox} from "./providers"; -import type {RpcAsyncProxy} from "@typing/rpc"; - export {ProxySandbox, RegisterSandbox}; -export interface SandboxRegistry {} - -export type SandboxName = Extract; - -export type SandboxProxyTarget = RpcAsyncProxy; +export type {SandboxRegistry, SandboxName, SandboxProxyTarget} from "@typing/sandbox"; diff --git a/src/service/index.ts b/src/service/index.ts index 4de1059a..8deda358 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -1,16 +1,10 @@ import {ProxyService, RegisterService, Service} from "./providers"; -import type {TransportProxyTarget, TransportTarget} from "@transport/index"; +import type {ServiceName, ServiceTarget} from "@typing/service"; export {ProxyService, RegisterService}; -export interface ServiceRegistry {} - -export type ServiceName = Extract; - -export type ServiceTarget = TransportTarget; - -export type ServiceProxyTarget = TransportProxyTarget; +export type {ServiceRegistry, ServiceName, ServiceTarget, ServiceProxyTarget} from "@typing/service"; export const getService = (name: N): ServiceTarget => { return new Service(name).get(); diff --git a/src/types/background.ts b/src/types/background.ts index b4236d12..8d513765 100644 --- a/src/types/background.ts +++ b/src/types/background.ts @@ -1,10 +1,11 @@ import {EntrypointBuilder, EntrypointOptions} from "@typing/entrypoint"; import {Awaiter} from "@typing/helpers"; +export const BackgroundEntryName = "background"; + type ManifestPermissions = chrome.runtime.ManifestPermission; -type ManifestOptionalPermissions = chrome.runtime.ManifestOptionalPermission; -export const BackgroundEntryName = "background"; +type ManifestOptionalPermissions = chrome.runtime.ManifestOptionalPermission; export interface BackgroundConfig { persistent?: boolean; diff --git a/src/types/command.ts b/src/types/command.ts index bbc91fda..b4e8d940 100644 --- a/src/types/command.ts +++ b/src/types/command.ts @@ -4,10 +4,10 @@ import {EntrypointBuilder, EntrypointOptions} from "@typing/entrypoint"; import {BackgroundConfig} from "@typing/background"; import {Awaiter} from "@typing/helpers"; -type Tab = chrome.tabs.Tab; - export const CommandExecuteActionName = "_execute_action"; +type Tab = chrome.tabs.Tab; + export interface CommandConfig extends BackgroundConfig { name: string; description?: string; diff --git a/src/types/content.ts b/src/types/content.ts index 37e798f6..b8d8b3c1 100644 --- a/src/types/content.ts +++ b/src/types/content.ts @@ -5,13 +5,64 @@ import {Optional} from "utility-types"; import {EntrypointBuilder, EntrypointOptions} from "@typing/entrypoint"; import {Awaiter, PickNonFunctionProperties} from "@typing/helpers"; -type RunAt = chrome.extensionTypes.RunAt; - export const ContentScriptMatches = ["http://*/*", "https://*/*"]; /** Shared property used by the bundler plugin and the content script's style runtime getter. */ export const ContentScriptStylesRuntimeProperty = "__adnbnIsolatedStyles"; +export enum ContentScriptWorld { + Isolated = "ISOLATED", + Main = "MAIN", +} + +export enum ContentScriptDeclarative { + Required = "required", + Optional = "optional", +} + +export enum ContentScriptIsolation { + None = "none", + Shadow = "shadow", + Iframe = "iframe", +} + +export enum ContentScriptShadowMode { + Open = "open", + Closed = "closed", +} + +// Append +export enum ContentScriptAppend { + Last = "last", + First = "first", + Replace = "replace", + Before = "before", + After = "after", +} + +// Marker +export enum ContentScriptMarker { + /** In-memory marking (no DOM mutations). */ + Weak = "weak", + /** DOM attribute-based marking. */ + Attribute = "attribute", +} + +export enum ContentScriptMarkerValue { + Mounted = "1", + Unmounted = "0", +} + +// Event +export enum ContentScriptEvent { + Mount = "mount", + Unmount = "unmount", + Add = "add", + Remove = "remove", +} + +type RunAt = chrome.extensionTypes.RunAt; + export interface ContentScriptStylesRuntime { /** Resolves initial stylesheet URLs once, before the first root is registered. */ initialize(resolveUrl: (file: string) => string): void; @@ -21,18 +72,8 @@ export interface ContentScriptStylesRuntime { load(url: string): Promise; } -export enum ContentScriptWorld { - Isolated = "ISOLATED", - Main = "MAIN", -} - export type ContentScriptWorldValue = ContentScriptWorld | `${chrome.scripting.ExecutionWorld}`; -export enum ContentScriptDeclarative { - Required = "required", - Optional = "optional", -} - export interface ContentScriptConfig { matches?: string[]; /** @@ -99,17 +140,6 @@ export interface ContentScriptConfig { export type ContentScriptOptions = ContentScriptConfig & EntrypointOptions; -export enum ContentScriptIsolation { - None = "none", - Shadow = "shadow", - Iframe = "iframe", -} - -export enum ContentScriptShadowMode { - Open = "open", - Closed = "closed", -} - export interface ContentScriptShadowOptions { /** * Controls access through the host's shadowRoot property, not CSS isolation or UI visibility. @@ -182,15 +212,6 @@ export type ContentScriptEntrypointOptions = Partial & { isolation?: ContentScriptIsolationOptions; }; -// Append -export enum ContentScriptAppend { - Last = "last", - First = "first", - Replace = "replace", - Before = "before", - After = "after", -} - // Mount export type ContentScriptMountFunction = (anchor: Element, container: Element) => void | (() => void); @@ -207,20 +228,8 @@ export interface ContentScriptProps extends ContentScriptEntrypointOptions { // Anchor export type ContentScriptAnchor = string | Element | null | undefined; -export type ContentScriptAnchorGetter = () => Awaiter; - -// Marker -export enum ContentScriptMarker { - /** In-memory marking (no DOM mutations). */ - Weak = "weak", - /** DOM attribute-based marking. */ - Attribute = "attribute", -} -export enum ContentScriptMarkerValue { - Mounted = "1", - Unmounted = "0", -} +export type ContentScriptAnchorGetter = () => Awaiter; export interface ContentScriptMarkerContract { for(anchor: ContentScriptAnchor): ContentScriptMarkerContract; @@ -255,11 +264,14 @@ export type ContentScriptMarkerType = | undefined; export type ContentScriptMarkerGetter = (options: ContentScriptOptions) => Awaiter; + export type ContentScriptMarkerResolver = (options: ContentScriptOptions) => Awaiter; // Render export type ContentScriptRenderReactComponent = FC; + export type ContentScriptRenderValue = Element | ReactNode | ContentScriptRenderReactComponent; + export type ContentScriptRenderHandler = (props: ContentScriptProps) => Awaiter; // Container @@ -274,19 +286,12 @@ export type ContentScriptContainerOptions = { export type ContentScriptContainerFactory = ( props: ContentScriptProps ) => Awaiter; + export type ContentScriptContainerCreator = (props: ContentScriptProps) => Awaiter; // Watch export type ContentScriptWatchStrategy = (update: () => void, context: ContentScriptContext) => () => void; -// Event -export enum ContentScriptEvent { - Mount = "mount", - Unmount = "unmount", - Add = "add", - Remove = "remove", -} - export type ContentScriptEventCallback = (event: ContentScriptEvent, node: ContentScriptNode) => void; export interface ContentScriptEventEmitter { @@ -388,6 +393,7 @@ export interface ContentScriptResolvedDefinition extends Omit< } type ContentScriptAppendVariant = T extends unknown ? Omit & {append?: ContentScriptAppend} : never; + export type ContentScriptAppendDefinition = ContentScriptAppendVariant; // Builder diff --git a/src/types/icon.ts b/src/types/icon.ts index 707e48ef..07e51c09 100644 --- a/src/types/icon.ts +++ b/src/types/icon.ts @@ -3,3 +3,13 @@ export const DefaultIconGroupName = "default"; export const IconFileExtensions: ReadonlySet = new Set(["png"]); export const IconSizes: ReadonlySet = new Set([16, 32, 48, 64, 128, 256, 512]); + +/** + * Empty because icon group names depend on the consuming application's icon files. + * Generated `.adnbn/icon.d.ts` declarations augment `adnbn`, adding discovered names as keys with value `true`. + */ +export interface IconNameRegistry {} + +export type IconName = keyof IconNameRegistry extends never ? string : Extract; + +export type IconsMap = Map>; diff --git a/src/types/language.ts b/src/types/language.ts new file mode 100644 index 00000000..4e799110 --- /dev/null +++ b/src/types/language.ts @@ -0,0 +1,57 @@ +export enum Language { + Arabic = "ar", + Amharic = "am", + Bulgarian = "bg", + Bengali = "bn", + Catalan = "ca", + Czech = "cs", + Danish = "da", + German = "de", + Greek = "el", + English = "en", + EnglishAustralia = "en_AU", + EnglishGreatBritain = "en_GB", + EnglishUSA = "en_US", + Spanish = "es", + SpanishLatinAmericaAndCaribbean = "es_419", + Estonian = "et", + Persian = "fa", + Finnish = "fi", + Filipino = "fil", + French = "fr", + Gujarati = "gu", + Hebrew = "he", + Hindi = "hi", + Croatian = "hr", + Hungarian = "hu", + Indonesian = "id", + Italian = "it", + Japanese = "ja", + Kannada = "kn", + Korean = "ko", + Lithuanian = "lt", + Latvian = "lv", + Malayalam = "ml", + Marathi = "mr", + Malay = "ms", + Dutch = "nl", + Norwegian = "no", + Polish = "pl", + PortugueseBrazil = "pt_BR", + PortuguesePortugal = "pt_PT", + Romanian = "ro", + Russian = "ru", + Slovak = "sk", + Slovenian = "sl", + Serbian = "sr", + Swedish = "sv", + Swahili = "sw", + Tamil = "ta", + Telugu = "te", + Thai = "th", + Turkish = "tr", + Ukrainian = "uk", + Vietnamese = "vi", + ChineseChina = "zh_CN", + ChineseTaiwan = "zh_TW", +} diff --git a/src/types/locale.ts b/src/types/locale.ts index 060f8e1d..764a4271 100644 --- a/src/types/locale.ts +++ b/src/types/locale.ts @@ -1,65 +1,6 @@ -export enum Language { - Arabic = "ar", - Amharic = "am", - Bulgarian = "bg", - Bengali = "bn", - Catalan = "ca", - Czech = "cs", - Danish = "da", - German = "de", - Greek = "el", - English = "en", - EnglishAustralia = "en_AU", - EnglishGreatBritain = "en_GB", - EnglishUSA = "en_US", - Spanish = "es", - SpanishLatinAmericaAndCaribbean = "es_419", - Estonian = "et", - Persian = "fa", - Finnish = "fi", - Filipino = "fil", - French = "fr", - Gujarati = "gu", - Hebrew = "he", - Hindi = "hi", - Croatian = "hr", - Hungarian = "hu", - Indonesian = "id", - Italian = "it", - Japanese = "ja", - Kannada = "kn", - Korean = "ko", - Lithuanian = "lt", - Latvian = "lv", - Malayalam = "ml", - Marathi = "mr", - Malay = "ms", - Dutch = "nl", - Norwegian = "no", - Polish = "pl", - PortugueseBrazil = "pt_BR", - PortuguesePortugal = "pt_PT", - Romanian = "ro", - Russian = "ru", - Slovak = "sk", - Slovenian = "sl", - Serbian = "sr", - Swedish = "sv", - Swahili = "sw", - Tamil = "ta", - Telugu = "te", - Thai = "th", - Turkish = "tr", - Ukrainian = "uk", - Vietnamese = "vi", - ChineseChina = "zh_CN", - ChineseTaiwan = "zh_TW", -} +import {Language} from "@typing/language"; -export enum LocaleDir { - RightToLeft = "rtl", - LeftToRight = "ltr", -} +export {Language}; export const RtlLanguages: ReadonlySet = new Set([Language.Arabic, Language.Persian, Language.Hebrew]); @@ -136,6 +77,11 @@ export const LanguageNames: Readonly> = Object.freeze({ export const LocaleFileExtensions: ReadonlySet = new Set(["yaml", "yml", "json"]); +export enum LocaleDir { + RightToLeft = "rtl", + LeftToRight = "ltr", +} + export type LocaleValue = string | number | Array; export type LocaleData = { @@ -196,7 +142,10 @@ export interface LocaleStructure { [key: string]: LocaleFutures; } -/** Augmented through adnbn/locale by the generated .adnbn/locale.d.ts for the current app. */ +/** + * Empty because translation keys and their plural/substitution contracts belong to the consuming application. + * Generated `.adnbn/locale.d.ts` declarations populate it by augmenting `adnbn/locale`. + */ export interface LocaleRegistry {} export type LocaleNonPluralKeys = { diff --git a/src/types/manifest.ts b/src/types/manifest.ts index 81b77f92..0be0103b 100644 --- a/src/types/manifest.ts +++ b/src/types/manifest.ts @@ -6,11 +6,6 @@ import {BrowserSpecific, DataCollectionPermission} from "@typing/browser"; import {CspConfig} from "@typing/csp"; import {SandboxCspConfig} from "@typing/sandbox"; -type ManifestCommon = chrome.runtime.Manifest; -type ManifestBase = chrome.runtime.ManifestBase; -type ManifestPermission = chrome.runtime.ManifestPermission; -type ManifestOptionalPermission = chrome.runtime.ManifestOptionalPermission; - export const ManifestMatchSchemes: ReadonlySet = new Set(["http", "https", "file", "ftp", "ws", "wss"]); export const ManifestSpecialSchemes: ReadonlySet = new Set([ @@ -24,6 +19,20 @@ export const ManifestSpecialSchemes: ReadonlySet = new Set([ "resource", ]); +export enum ManifestIncognito { + Spanning = "spanning", + Split = "split", + NotAllowed = "not_allowed", +} + +type ManifestCommon = chrome.runtime.Manifest; + +type ManifestBase = chrome.runtime.ManifestBase; + +type ManifestPermission = chrome.runtime.ManifestPermission; + +type ManifestOptionalPermission = chrome.runtime.ManifestOptionalPermission; + type ManifestFixed = Omit & { manifest_version: ManifestVersion; }; @@ -35,12 +44,6 @@ interface ManifestUnstable { export type ManifestVersion = 2 | 3; -export enum ManifestIncognito { - Spanning = "spanning", - Split = "split", - NotAllowed = "not_allowed", -} - export type ManifestIncognitoValue = ManifestIncognito | `${ManifestIncognito}`; export type CoreManifest = ManifestFixed; @@ -199,9 +202,11 @@ export interface ManifestEntry { export type ManifestBackground = ManifestEntry & BackgroundConfig; export type ManifestContentScript = ManifestEntry & ContentScriptConfig; + export type ManifestContentScripts = Set; export type ManifestCommand = CommandConfig; + export type ManifestCommands = Set; export interface ManifestPopup { diff --git a/src/types/message.ts b/src/types/message.ts index 1088470f..04c3d93c 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -14,6 +14,13 @@ export interface MessageDictionary { [key: string]: (data: any) => any; } +/** + * Declares no application-specific message signatures by default. + * Applications add their signatures by augmenting `adnbn/message`. + * Undeclared names retain the MessageDictionary fallback. + */ +export interface MessageRegistry extends MessageDictionary {} + export interface MessageSenderAware { readonly [MessageSenderProperty]?: MessageSender; } diff --git a/src/types/offscreen.ts b/src/types/offscreen.ts index 2ec93a0f..cb14b127 100644 --- a/src/types/offscreen.ts +++ b/src/types/offscreen.ts @@ -1,10 +1,18 @@ -import {TransportConfig, TransportDefinition, TransportType} from "@typing/transport"; +import { + TransportConfig, + TransportDefinition, + TransportProxyTarget, + TransportTarget, + TransportType, +} from "@typing/transport"; import {ViewOptions} from "@typing/view"; import {Awaiter} from "@typing/helpers"; import {CspOptions} from "@typing/csp"; export const OffscreenGlobalKey = "adnbnOffscreen"; + export const OffscreenGlobalAccess = "adnbnOffscreenAccess"; + export const OffscreenBridgeReadyMessageType = "adnbn:offscreen:ready"; export enum OffscreenReason { @@ -40,6 +48,26 @@ export enum OffscreenReason { Geolocation = "GEOLOCATION", } +/** + * Empty because offscreen names and contracts belong to the consuming application. + * Generated `.adnbn/offscreen.d.ts` declarations populate it by augmenting `adnbn/offscreen`. + */ +export interface OffscreenRegistry {} + +export type OffscreenName = Extract; + +export type OffscreenTarget = TransportTarget; + +export type OffscreenProxyTarget = TransportProxyTarget; + +export type OffscreenAlias = string; + +export type OffscreenMap = Map; + +export type OffscreenParameters = chrome.offscreen.CreateParameters; + +export type OffscreenParametersMap = Record; + export interface OffscreenConfig extends TransportConfig { reasons?: `${OffscreenReason}` | `${OffscreenReason}`[] | OffscreenReason | OffscreenReason[]; justification?: string; diff --git a/src/types/page.ts b/src/types/page.ts index 6bf3d8de..aaff02f2 100644 --- a/src/types/page.ts +++ b/src/types/page.ts @@ -1,7 +1,10 @@ import {ViewDefinition, ViewOptions} from "@typing/view"; import {CspOptions} from "@typing/csp"; -/** Augmented by the generated page declarations of the consuming application. */ +/** + * Empty because page aliases depend on the consuming application's entrypoints. + * Generated `.adnbn/page.d.ts` declarations augment `adnbn`, adding discovered aliases as keys with value `true`. + */ export interface PageAliasRegistry {} export type PageAlias = keyof PageAliasRegistry extends never ? string : Extract; diff --git a/src/types/popup.ts b/src/types/popup.ts index de269ce4..b4775409 100644 --- a/src/types/popup.ts +++ b/src/types/popup.ts @@ -1,5 +1,16 @@ import {ViewDefinition, ViewOptions} from "@typing/view"; import {CspOptions} from "@typing/csp"; +import type {ManifestPopup} from "@typing/manifest"; + +/** + * Empty because popup aliases depend on the consuming application's entrypoints. + * Generated `.adnbn/popup.d.ts` declarations augment `adnbn`, adding discovered aliases as keys with value `true`. + */ +export interface PopupAliasRegistry {} + +export type PopupAlias = keyof PopupAliasRegistry extends never ? string : Extract; + +export type PopupMap = Map; export interface PopupConfig { icon?: string; diff --git a/src/types/relay.ts b/src/types/relay.ts index d2b50e81..0cf4c51b 100644 --- a/src/types/relay.ts +++ b/src/types/relay.ts @@ -17,11 +17,6 @@ import type {MessageError} from "@typing/message"; export const RelayGlobalKey = "adnbnRelay"; -/** Augmented through adnbn/relay by the generated Relay declarations of the consuming application. */ -export interface RelayRegistry {} - -export type RelayName = Extract; - export enum RelayMethod { Scripting = "scripting", Messaging = "messaging", @@ -49,6 +44,14 @@ export enum RelayFrameErrorKind { Unobservable = "unobservable", } +/** + * Empty because relay names and contracts belong to the consuming application. + * Generated `.adnbn/relay.d.ts` declarations populate it by augmenting `adnbn/relay`. + */ +export interface RelayRegistry {} + +export type RelayName = Extract; + export type RelayNonEmptyReadonlyArray = readonly [T, ...T[]]; export interface RelayExecutionOptions { diff --git a/src/types/sandbox.ts b/src/types/sandbox.ts index 59f9e460..26a0089b 100644 --- a/src/types/sandbox.ts +++ b/src/types/sandbox.ts @@ -2,12 +2,18 @@ import {TransportConfig, TransportDefinition, TransportType} from "@typing/trans import {ViewOptions} from "@typing/view"; import {Awaiter} from "@typing/helpers"; import {MessageError, MessageSender} from "@typing/message"; +import type {RpcAsyncProxy} from "@typing/rpc"; export const SandboxGlobalKey = "adnbnSandbox"; + export const SandboxGlobalAccess = "adnbnSandboxAccess"; + export const SandboxNamespace = "adnbn:sandbox"; + export const SandboxReadyMessageType = `${SandboxNamespace}:ready`; + export const SandboxRequestMessageType = `${SandboxNamespace}:request`; + export const SandboxResponseMessageType = `${SandboxNamespace}:response`; export enum SandboxAllow { @@ -27,6 +33,20 @@ export enum SandboxSource { UnsafeInline = "'unsafe-inline'", } +/** + * Empty because sandbox names and contracts belong to the consuming application. + * Generated `.adnbn/sandbox.d.ts` declarations populate it by augmenting `adnbn/sandbox`. + */ +export interface SandboxRegistry {} + +export type SandboxName = Extract; + +export type SandboxProxyTarget = RpcAsyncProxy; + +export type SandboxAlias = string; + +export type SandboxMap = Map; + export interface SandboxCspSources { connect?: Array; image?: Array; @@ -75,6 +95,8 @@ export type SandboxParameters = {url: string} & Pick< "readyTimeout" | "requestTimeout" | "removeOnRequestTimeout" >; +export type SandboxParametersMap = Record; + export interface SandboxReadyMessage { type: typeof SandboxReadyMessageType; channel: string; diff --git a/src/types/service.ts b/src/types/service.ts index 5e05c5c2..5aae2c68 100644 --- a/src/types/service.ts +++ b/src/types/service.ts @@ -1,9 +1,27 @@ import {BackgroundConfig} from "@typing/background"; import {EntrypointOptions} from "@typing/entrypoint"; -import {TransportConfig, TransportDefinition, TransportType} from "@typing/transport"; +import { + TransportConfig, + TransportDefinition, + TransportProxyTarget, + TransportTarget, + TransportType, +} from "@typing/transport"; export const ServiceGlobalKey = "adnbnService"; +/** + * Empty because service names and contracts belong to the consuming application. + * Generated `.adnbn/service.d.ts` declarations populate it by augmenting `adnbn/service`. + */ +export interface ServiceRegistry {} + +export type ServiceName = Extract; + +export type ServiceTarget = TransportTarget; + +export type ServiceProxyTarget = TransportProxyTarget; + export type ServiceConfig = TransportConfig & BackgroundConfig; export type ServiceOptions = ServiceConfig & EntrypointOptions; diff --git a/src/types/sidebar.ts b/src/types/sidebar.ts index c9731c83..ce2e3869 100644 --- a/src/types/sidebar.ts +++ b/src/types/sidebar.ts @@ -1,9 +1,22 @@ import {ViewDefinition, ViewOptions} from "@typing/view"; import {Browser} from "@typing/browser"; import {CspOptions} from "@typing/csp"; +import type {ManifestSidebar} from "@typing/manifest"; export const SidebarAlternativeBrowsers: ReadonlySet = new Set([Browser.Opera, Browser.Firefox]); +/** + * Empty because sidebar aliases depend on the consuming application's entrypoints. + * Generated `.adnbn/sidebar.d.ts` declarations augment `adnbn`, adding discovered aliases as keys with value `true`. + */ +export interface SidebarAliasRegistry {} + +export type SidebarAlias = keyof SidebarAliasRegistry extends never + ? string + : Extract; + +export type SidebarMap = Map; + export interface SidebarConfig { icon?: string; apply?: boolean; diff --git a/src/types/transport.ts b/src/types/transport.ts index 0d0bcd60..4484a06c 100644 --- a/src/types/transport.ts +++ b/src/types/transport.ts @@ -42,6 +42,10 @@ export interface TransportSender { send(data: TransportMessageData, options?: MessageSendOptions): any; } +/** + * Combines message sending and receiving without introducing additional members. + * Its members are inherited from TransportSender and TransportReceiver, rather than generated or augmented. + */ export interface TransportMessage extends TransportSender, TransportReceiver {} export interface TransportProvider { diff --git a/src/virtual/icon.ts b/src/virtual/icon.ts new file mode 100644 index 00000000..c032d9dd --- /dev/null +++ b/src/virtual/icon.ts @@ -0,0 +1,2 @@ +/** Package fallback; extension builds supply #adnbn/icon as a generated module. */ +export const groups: Record> = {}; diff --git a/src/virtual/modules.d.ts b/src/virtual/modules.d.ts index 0ed716f7..3415f976 100644 --- a/src/virtual/modules.d.ts +++ b/src/virtual/modules.d.ts @@ -13,3 +13,23 @@ declare module "#adnbn/page" { declare module "#adnbn/relay" { export const options: typeof import("./relay").options; } + +declare module "#adnbn/popup" { + export const aliases: typeof import("./popup").aliases; +} + +declare module "#adnbn/sidebar" { + export const aliases: typeof import("./sidebar").aliases; +} + +declare module "#adnbn/offscreen" { + export const parameters: typeof import("./offscreen").parameters; +} + +declare module "#adnbn/sandbox" { + export const parameters: typeof import("./sandbox").parameters; +} + +declare module "#adnbn/icon" { + export const groups: typeof import("./icon").groups; +} diff --git a/src/virtual/offscreen.ts b/src/virtual/offscreen.ts new file mode 100644 index 00000000..935054cb --- /dev/null +++ b/src/virtual/offscreen.ts @@ -0,0 +1,4 @@ +import type {OffscreenParametersMap} from "@typing/offscreen"; + +/** Package fallback; extension builds supply #adnbn/offscreen as a generated module. */ +export const parameters: OffscreenParametersMap = {}; diff --git a/src/virtual/popup.ts b/src/virtual/popup.ts new file mode 100644 index 00000000..39731b69 --- /dev/null +++ b/src/virtual/popup.ts @@ -0,0 +1,4 @@ +import type {ManifestPopup} from "@typing/manifest"; + +/** Package fallback; extension builds supply #adnbn/popup as a generated module. */ +export const aliases: Record = {}; diff --git a/src/virtual/sandbox.ts b/src/virtual/sandbox.ts new file mode 100644 index 00000000..8e92f3af --- /dev/null +++ b/src/virtual/sandbox.ts @@ -0,0 +1,4 @@ +import type {SandboxParametersMap} from "@typing/sandbox"; + +/** Package fallback; extension builds supply #adnbn/sandbox as a generated module. */ +export const parameters: SandboxParametersMap = {}; diff --git a/src/virtual/sidebar.ts b/src/virtual/sidebar.ts new file mode 100644 index 00000000..b037342f --- /dev/null +++ b/src/virtual/sidebar.ts @@ -0,0 +1,4 @@ +import type {ManifestSidebar} from "@typing/manifest"; + +/** Package fallback; extension builds supply #adnbn/sidebar as a generated module. */ +export const aliases: Record = {}; diff --git a/tests/integration/README.md b/tests/integration/README.md index ce40f239..2e2b1531 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1,6 +1,6 @@ # Integration tests -These tests build local Addon Bone applications through the public CLI. Build checks live in `build`; tests that launch Chrome or Firefox live in `browser`. Each area keeps its test beside the application scenarios it uses. +These tests cover application builds, browser execution, and generated TypeScript contracts. Build checks through the public CLI live in `build`; tests that launch Chrome or Firefox live in `browser`; declaration and consumer type checks live in `types`. Each area keeps its tests beside their fixtures. ## Layout @@ -35,6 +35,9 @@ tests/integration/ │ ├── chrome.ts │ ├── firefox.ts │ └── site.ts +├── types/ +│ ├── registries.integration.test.ts +│ └── fixtures/registries/ ├── utils/ │ ├── fixture.ts │ └── process.ts @@ -68,6 +71,8 @@ npm run typecheck:integration The root `typecheck` checks framework and test-runner code; `typecheck:integration` additionally checks the fixture applications against their generated declarations. CI runs both. +The fixtures in `types` are checked by their Jest tests with isolated TypeScript programs against source and built package APIs. They do not have application configs and are not part of `prepare:integration` or `typecheck:integration`. + ## Run tests Run these commands from the repository root: @@ -85,6 +90,7 @@ Tests copy application inputs to unique directories under `.cache/integration`. ## Coverage +- `types/registries`: generated registry augmentation, empty fallbacks, public and internal type agreement, and message contracts against source and built package APIs. Compiler-host path checks cover both slash styles. - `build/options/embedded`: ten manifest checks covering explicit `openInTab: false` across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. No browser is launched. - `browser/options`: two Chrome MV3 cases covering Vanilla and React rendering, CSS, state/events, opening Options from background, and a View chunk shared with a Page. - `browser/offscreen/service`: one Chrome MV3 round trip from background through Offscreen to a registered background service. diff --git a/tests/integration/build/locale/states/package-import.mjs b/tests/integration/build/locale/states/package-import.mjs index 810e7ac1..4a5f2cca 100644 --- a/tests/integration/build/locale/states/package-import.mjs +++ b/tests/integration/build/locale/states/package-import.mjs @@ -1,5 +1,15 @@ import assert from "node:assert/strict"; -import {getPages, getRelay} from "adnbn"; +import { + getIcons, + getOffscreen, + getOffscreens, + getPages, + getPopups, + getRelay, + getSandbox, + getSandboxes, + getSidebars, +} from "adnbn"; import {AbstractLocale, DynamicLocale, NativeLocale} from "adnbn/locale"; assert.equal(typeof globalThis.chrome, "undefined"); @@ -7,3 +17,17 @@ assert.equal(Object.getPrototypeOf(DynamicLocale.prototype), AbstractLocale.prot assert.equal(typeof NativeLocale, "function"); assert.equal(getPages().size, 0); assert.throws(() => getRelay("missing", 1), {message: 'Failed to get relay "missing"'}); + +const errors = []; +const originalError = console.error; +console.error = (...args) => errors.push(args); +try { + for (const getMap of [getPopups, getSidebars, getOffscreens, getSandboxes, getIcons]) { + assert.equal(getMap().size, 0); + } + assert.throws(() => getOffscreen("missing"), {message: "Unable to get offscreen: missing"}); + assert.throws(() => getSandbox("missing"), {message: "Unable to get sandbox: missing"}); +} finally { + console.error = originalError; +} +assert.deepEqual(errors, []); diff --git a/tests/integration/build/virtual-modules/fixture/adnbn.config.ts b/tests/integration/build/virtual-modules/fixture/adnbn.config.ts new file mode 100644 index 00000000..d0556d86 --- /dev/null +++ b/tests/integration/build/virtual-modules/fixture/adnbn.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Virtual Modules Integration", + description: "Generated entrypoint data and unused consumers.", + jsFilename: "[name].js", + iconSrcDir: "icons", + iconOutDir: "icons", +}); diff --git a/tests/integration/build/virtual-modules/fixture/package.json b/tests/integration/build/virtual-modules/fixture/package.json new file mode 100644 index 00000000..9970195d --- /dev/null +++ b/tests/integration/build/virtual-modules/fixture/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-virtual-modules", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "adnbn": "file:../../../../.." + } +} diff --git a/tests/integration/build/virtual-modules/fixture/src/background.ts b/tests/integration/build/virtual-modules/fixture/src/background.ts new file mode 100644 index 00000000..0c407c0c --- /dev/null +++ b/tests/integration/build/virtual-modules/fixture/src/background.ts @@ -0,0 +1,11 @@ +import {defineBackground, getIcons, getOffscreens, getPopups, getSandboxes, getSidebars} from "adnbn"; + +(globalThis as typeof globalThis & {readData: () => unknown}).readData = () => ({ + popup: Object.fromEntries(getPopups()), + sidebar: Object.fromEntries(getSidebars()), + offscreen: Object.fromEntries(getOffscreens()), + sandbox: Object.fromEntries(getSandboxes()), + icon: Object.fromEntries(getIcons()), +}); + +export default defineBackground({}); diff --git a/tests/integration/build/virtual-modules/fixture/src/icons/dataicons16.png b/tests/integration/build/virtual-modules/fixture/src/icons/dataicons16.png new file mode 100644 index 0000000000000000000000000000000000000000..0a3e580fb1504f3e4f6f312fbb18065955fca4f6 GIT binary patch literal 82 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`lAbP(Ar*6y4g6;RoG*07AnU*i f#>H$r;>--qc82ozUlbh#Dq`?-^>bP0l+XkK=Is@Z literal 0 HcmV?d00001 diff --git a/tests/integration/build/virtual-modules/fixture/src/offscreen.ts b/tests/integration/build/virtual-modules/fixture/src/offscreen.ts new file mode 100644 index 00000000..07b680e1 --- /dev/null +++ b/tests/integration/build/virtual-modules/fixture/src/offscreen.ts @@ -0,0 +1,8 @@ +import {defineOffscreen, OffscreenReason} from "adnbn"; + +export default defineOffscreen({ + name: "dataOffscreen", + reasons: [OffscreenReason.DOMParser], + justification: "Offscreen data", + init: () => ({ping: () => true}), +}); diff --git a/tests/integration/build/virtual-modules/fixture/src/popup.ts b/tests/integration/build/virtual-modules/fixture/src/popup.ts new file mode 100644 index 00000000..30842fce --- /dev/null +++ b/tests/integration/build/virtual-modules/fixture/src/popup.ts @@ -0,0 +1,3 @@ +import {definePopup} from "adnbn"; + +export default definePopup({title: "Popup data", icon: "dataicons"}); diff --git a/tests/integration/build/virtual-modules/fixture/src/sandbox.ts b/tests/integration/build/virtual-modules/fixture/src/sandbox.ts new file mode 100644 index 00000000..50bf0c99 --- /dev/null +++ b/tests/integration/build/virtual-modules/fixture/src/sandbox.ts @@ -0,0 +1,9 @@ +import {defineSandbox} from "adnbn"; + +export default defineSandbox({ + name: "dataSandbox", + readyTimeout: 1234, + requestTimeout: 5678, + removeOnRequestTimeout: false, + init: () => ({ping: () => true}), +}); diff --git a/tests/integration/build/virtual-modules/fixture/src/sidebar.ts b/tests/integration/build/virtual-modules/fixture/src/sidebar.ts new file mode 100644 index 00000000..4330f221 --- /dev/null +++ b/tests/integration/build/virtual-modules/fixture/src/sidebar.ts @@ -0,0 +1,3 @@ +import {defineSidebar} from "adnbn"; + +export default defineSidebar({title: "Sidebar data"}); diff --git a/tests/integration/build/virtual-modules/fixture/tsconfig.json b/tests/integration/build/virtual-modules/fixture/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/build/virtual-modules/fixture/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/build/virtual-modules/states/unused-background.ts b/tests/integration/build/virtual-modules/states/unused-background.ts new file mode 100644 index 00000000..68e9d9af --- /dev/null +++ b/tests/integration/build/virtual-modules/states/unused-background.ts @@ -0,0 +1,11 @@ +import {defineBackground, defineOffscreen, definePopup, defineSandbox, defineSidebar} from "adnbn"; + +// Keep helpers from the same public modules without consuming their generated data. +(globalThis as typeof globalThis & {definitions: unknown}).definitions = [ + definePopup({name: "local-popup"}), + defineSidebar({name: "local-sidebar"}), + defineOffscreen({name: "localOffscreen", init: () => ({})}), + defineSandbox({name: "localSandbox", init: () => ({})}), +]; + +export default defineBackground({}); diff --git a/tests/integration/build/virtual-modules/virtual-modules.integration.test.ts b/tests/integration/build/virtual-modules/virtual-modules.integration.test.ts new file mode 100644 index 00000000..dadafd43 --- /dev/null +++ b/tests/integration/build/virtual-modules/virtual-modules.integration.test.ts @@ -0,0 +1,55 @@ +/** @jest-environment node */ +import {copyFile, readFile} from "fs/promises"; +import path from "path"; +import vm from "vm"; + +import {createIntegrationFixture} from "../../utils/fixture"; + +jest.setTimeout(60_000); + +test("generated entrypoint data reach public getters and disappear without consumers", async () => { + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, path.join(__dirname, "fixture")); + + try { + const directory = await fixture.build(); + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const background = path.join(directory, manifest.background.service_worker); + const source = await readFile(background, "utf8"); + const runtime = {readData: undefined as undefined | (() => unknown)}; + vm.runInNewContext(source, runtime); + expect(runtime.readData!()).toEqual({ + popup: {popup: {path: "popup.html", title: "Popup data", icon: "dataicons"}}, + sidebar: {sidebar: {path: "sidebar.html", title: "Sidebar data"}}, + offscreen: { + dataOffscreen: {url: "offscreen.html", reasons: ["DOM_PARSER"], justification: "Offscreen data"}, + }, + sandbox: { + dataSandbox: { + url: "sandbox.html", + readyTimeout: 1234, + requestTimeout: 5678, + removeOnRequestTimeout: false, + }, + }, + icon: {dataicons: {16: "/icons/dataicons-16.png"}}, + }); + expect(await readFile(path.join(directory, "icons/dataicons-16.png"))).toEqual( + await readFile(path.join(fixture.directory, "src/icons/dataicons16.png")) + ); + + await copyFile( + path.join(__dirname, "states/unused-background.ts"), + path.join(fixture.directory, "src/background.ts") + ); + await fixture.build(); + const unusedSource = await readFile(background, "utf8"); + for (const marker of ["Popup data", "Sidebar data", "dataOffscreen", "dataSandbox", "dataicons"]) { + expect(unusedSource).not.toContain(marker); + } + const unusedRuntime = {definitions: undefined as unknown}; + vm.runInNewContext(unusedSource, unusedRuntime); + expect(unusedRuntime.definitions).toHaveLength(4); + } finally { + await fixture.dispose(); + } +}); diff --git a/tests/integration/types/fixtures/registries/assert.ts b/tests/integration/types/fixtures/registries/assert.ts new file mode 100644 index 00000000..70c54625 --- /dev/null +++ b/tests/integration/types/fixtures/registries/assert.ts @@ -0,0 +1,2 @@ +export type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +export type Expect = T; diff --git a/tests/integration/types/fixtures/registries/fallback.ts b/tests/integration/types/fixtures/registries/fallback.ts new file mode 100644 index 00000000..98e7ebfb --- /dev/null +++ b/tests/integration/types/fixtures/registries/fallback.ts @@ -0,0 +1,24 @@ +import * as api from "adnbn"; +import type {ServiceName} from "adnbn/service"; +import type {OffscreenName} from "adnbn/offscreen"; +import type {SandboxName} from "adnbn/sandbox"; +import type {Equal, Expect} from "./assert"; + +type PopupNames = Expect>; +type SidebarNames = Expect>; +type IconNames = Expect>; +type ServiceNames = Expect>; +type OffscreenNames = Expect>; +type SandboxNames = Expect>; + +api.changePopup("custom"); +api.changeSidebar("custom"); +api.changeActionIcon("custom"); +api.changeSidebarIcon(); + +// @ts-expect-error: Empty transport registries remain strict. +api.getService("unknown"); +// @ts-expect-error: Empty transport registries remain strict. +api.getOffscreen("unknown"); +// @ts-expect-error: Empty transport registries remain strict. +api.getSandbox("unknown"); diff --git a/tests/integration/types/fixtures/registries/generated.ts b/tests/integration/types/fixtures/registries/generated.ts new file mode 100644 index 00000000..a3122cff --- /dev/null +++ b/tests/integration/types/fixtures/registries/generated.ts @@ -0,0 +1,81 @@ +import * as api from "adnbn"; +import * as service from "adnbn/service"; +import * as offscreen from "adnbn/offscreen"; +import * as sandbox from "adnbn/sandbox"; +import type {Equal, Expect} from "./assert"; + +type PopupNames = Expect>; +type SidebarNames = Expect>; +type IconNames = Expect>; +type ServiceNames = Expect>; +type OffscreenNames = Expect>; +type SandboxNames = Expect>; +type PopupMap = Expect, api.PopupMap>>; +type SidebarMap = Expect, api.SidebarMap>>; +type IconsMap = Expect, api.IconsMap>>; +type OffscreenMap = Expect, api.OffscreenMap>>; +type SandboxMap = Expect, api.SandboxMap>>; + +api.changePopup("popup"); +api.changePopup('quoted"popup', 1); +api.changeSidebar("sidebar", 1); +api.changeActionIcon("brand", 1); +api.changeSidebarIcon("brand"); +api.changeActionIcon(); +api.changeSidebarIcon(); +api.getPopups().get("popup"); +api.getSidebars().get("sidebar"); +api.getIcons().get("brand"); + +const worker = api.getService("worker"); +const document = api.getOffscreen("document"); +const frame = api.getSandbox("frame"); +const localWorker = service.getService("worker"); +const localDocument = offscreen.getOffscreen("document"); +const workerResult = worker.run("input"); +const documentResult = document.parse("input"); +const frameResult = frame.render("input"); +const localWorkerResult = localWorker.run("input"); +const localDocumentResult = localDocument.parse("input"); + +type WorkerResult = Expect>>; +type DocumentResult = Expect>>; +type FrameResult = Expect>>; +type LocalWorkerResult = Expect>; +type LocalDocumentResult = Expect>; +type WorkerProxy = Expect>>; +type DocumentProxy = Expect>>; +type FrameProxy = Expect>>; +type WorkerTarget = Expect>>; +type DocumentTarget = Expect>>; + +// @ts-expect-error: The generated popup names constrain the original API. +api.changePopup("unknown"); +// @ts-expect-error: The generated sidebar names constrain the original API. +api.changeSidebar("unknown"); +// @ts-expect-error: Both icon APIs use the generated names. +api.changeActionIcon("unknown"); +// @ts-expect-error: Both icon APIs use the generated names. +api.changeSidebarIcon("unknown"); +// @ts-expect-error: Collection keys use the same registry as the setter. +api.getPopups().get("unknown"); +// @ts-expect-error: Collection keys use the same registry as the setter. +api.getSidebars().get("unknown"); +// @ts-expect-error: Collection keys use the same registry as the setter. +api.getIcons().get("unknown"); +// @ts-expect-error: A remote service requires a registered name. +api.getService("unknown"); +// @ts-expect-error: Local and remote services share the registry. +service.getService("unknown"); +// @ts-expect-error: A remote offscreen requires a registered name. +api.getOffscreen("unknown"); +// @ts-expect-error: Local and remote offscreens share the registry. +offscreen.getOffscreen("unknown"); +// @ts-expect-error: A sandbox requires a registered name. +api.getSandbox("unknown"); +// @ts-expect-error: Transport method arguments remain typed. +worker.run(1); +// @ts-expect-error: Transport method arguments remain typed. +document.parse(1); +// @ts-expect-error: Transport method arguments remain typed. +frame.render(1); diff --git a/tests/integration/types/fixtures/registries/internal.ts b/tests/integration/types/fixtures/registries/internal.ts new file mode 100644 index 00000000..79288d7f --- /dev/null +++ b/tests/integration/types/fixtures/registries/internal.ts @@ -0,0 +1,28 @@ +import type {PopupAlias, PopupAliasRegistry, PopupMap} from "@typing/popup"; +import type {SidebarAlias, SidebarAliasRegistry, SidebarMap} from "@typing/sidebar"; +import type {IconName, IconNameRegistry, IconsMap} from "@typing/icon"; +import type {ServiceName, ServiceRegistry} from "@typing/service"; +import type {OffscreenName, OffscreenRegistry, OffscreenMap} from "@typing/offscreen"; +import type {SandboxName, SandboxRegistry, SandboxMap} from "@typing/sandbox"; +import type {MessageRegistry} from "@typing/message"; +import type * as api from "adnbn"; +import type {Equal, Expect} from "./assert"; + +type PopupKeys = Expect>; +type SidebarKeys = Expect>; +type IconKeys = Expect>; +type ServiceKeys = Expect>; +type OffscreenKeys = Expect>; +type SandboxKeys = Expect>; +type PopupNames = Expect>; +type SidebarNames = Expect>; +type IconNames = Expect>; +type ServiceNames = Expect>; +type OffscreenNames = Expect>; +type SandboxNames = Expect>; +type PopupCollection = Expect>; +type SidebarCollection = Expect>; +type IconCollection = Expect>; +type OffscreenCollection = Expect>; +type SandboxCollection = Expect>; +type MessageData = Expect[0], {id: number}>>; diff --git a/tests/integration/types/fixtures/registries/message.ts b/tests/integration/types/fixtures/registries/message.ts new file mode 100644 index 00000000..1e726c2a --- /dev/null +++ b/tests/integration/types/fixtures/registries/message.ts @@ -0,0 +1,26 @@ +import {onMessage, sendMessage, type MessageRegistry} from "adnbn/message"; +import type {Equal, Expect} from "./assert"; + +declare module "adnbn/message" { + interface MessageRegistry { + "app:lookup": (data: {id: number}) => {title: string}; + } +} + +type LookupData = Expect[0], {id: number}>>; + +const result = sendMessage("app:lookup", {id: 1}); +type LookupResult = Expect>>; + +onMessage("app:lookup", data => { + type HandlerData = Expect>; + return {title: String(data.id)}; +}); + +// @ts-expect-error: Public augmentation still constrains known message payloads. +sendMessage("app:lookup", {id: "invalid"}); +// @ts-expect-error: Public augmentation still constrains known message responses. +onMessage("app:lookup", () => ({title: 1})); + +// Preserve the open MessageDictionary contract for messages without a declared signature. +sendMessage("unregistered", {value: true}); diff --git a/tests/integration/types/registries.integration.test.ts b/tests/integration/types/registries.integration.test.ts new file mode 100644 index 00000000..6a329674 --- /dev/null +++ b/tests/integration/types/registries.integration.test.ts @@ -0,0 +1,114 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import ts from "typescript"; + +import PopupDeclaration from "@cli/plugins/popup/declaration/PopupDeclaration"; +import SidebarDeclaration from "@cli/plugins/sidebar/declaration/SidebarDeclaration"; +import IconDeclaration from "@cli/plugins/icon/declaration/IconDeclaration"; +import TransportDeclaration, { + TransportDeclarationLayer, +} from "@cli/plugins/typescript/declaration/transport/TransportDeclaration"; +import type {ReadonlyConfig} from "@typing/config"; + +describe("shared registries", () => { + const projectDir = path.resolve(__dirname, "../../.."); + const fixtureDir = path.join(__dirname, "fixtures/registries"); + let rootDir: string; + let filled: string; + let empty: string; + + const generate = (directory: string, populated: boolean): string => { + const config = {rootDir: directory} as ReadonlyConfig; + new PopupDeclaration(config).setAlias(new Set(populated ? ["popup", 'quoted"popup'] : [])).build(); + new SidebarDeclaration(config).setAlias(new Set(populated ? ["sidebar"] : [])).build(); + new IconDeclaration(config).setNames(new Set(populated ? ["brand"] : [])).build(); + + for (const [layer, name, signature] of [ + [TransportDeclarationLayer.Service, "worker", "{ run(input: string): number; }"], + [TransportDeclarationLayer.Offscreen, "document", "{ parse(input: string): boolean; }"], + [TransportDeclarationLayer.Sandbox, "frame", "{ render(input: string): Promise; }"], + ] as const) { + new TransportDeclaration(config, layer).dictionary(populated ? {[name]: signature} : {}).build(); + } + + return ["popup", "sidebar", "icon", "service", "offscreen", "sandbox"] + .map(name => fs.readFileSync(path.join(directory, ".adnbn", `${name}.d.ts`), "utf8")) + .join("\n"); + }; + + beforeAll(() => { + rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-shared-registries-")); + filled = generate(path.join(rootDir, "filled"), true); + empty = generate(path.join(rootDir, "empty"), false); + }); + + afterAll(() => fs.rmSync(rootDir, {recursive: true, force: true})); + + test("generates registry augmentation without duplicate type aliases or function signatures", () => { + expect(filled).toContain("interface PopupAliasRegistry"); + expect(filled).toContain("interface SidebarAliasRegistry"); + expect(filled).toContain("interface IconNameRegistry"); + expect(filled).not.toMatch(/export (type|function) /); + expect(filled).not.toMatch(/\/\/ :(popup-aliases|sidebar-aliases|icon-names)/); + }); + + test.each( + ["source", "package"].flatMap(mode => + ["absent", "empty", "filled", "filled-windows-path"].map(state => ({mode, state})) + ) + )( + "checks $mode contracts with $state declarations", + ({mode, state}) => { + const config = ts.readConfigFile(path.join(projectDir, "tsconfig.json"), ts.sys.readFile); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, projectDir); + expect(config.error).toBeUndefined(); + expect(parsed.errors).toEqual([]); + + const options: ts.CompilerOptions = { + ...(mode === "source" ? parsed.options : {}), + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ESNext, + strict: true, + noEmit: true, + skipLibCheck: true, + types: ["node", "chrome"], + }; + const populated = state.startsWith("filled"); + const rootNames = [path.join(fixtureDir, populated ? "generated.ts" : "fallback.ts")]; + rootNames.push(path.join(fixtureDir, "message.ts")); + if (populated && mode === "source") rootNames.push(path.join(fixtureDir, "internal.ts")); + + const host = ts.createCompilerHost(options); + if (state !== "absent") { + const declaration = populated ? filled : empty; + const generatedFile = path.join(fixtureDir, "generated.d.ts"); + const generatedPath = state.endsWith("windows-path") + ? generatedFile.replace(/\//g, "\\") + : generatedFile; + const getSourceFile = host.getSourceFile.bind(host); + // Normalize both paths: TypeScript can request forward slashes on Windows. + host.getSourceFile = (file, languageVersion, onError, shouldCreateNewSourceFile) => + file.replace(/\\/g, "/") === generatedPath.replace(/\\/g, "/") + ? ts.createSourceFile(file, declaration, languageVersion, true) + : getSourceFile(file, languageVersion, onError, shouldCreateNewSourceFile); + rootNames.push(generatedPath); + } + + 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 apiFile = ts.resolveModuleName("adnbn", rootNames[0], options, host).resolvedModule?.resolvedFileName; + expect(apiFile).toBe( + path.join(projectDir, mode === "source" ? "src/index.ts" : "dist/index.d.ts").replace(/\\/g, "/") + ); + }, + 30_000 + ); +}); From 921466fd2dead6e2b860a3ef9f53909025b90373 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:25:27 +0300 Subject: [PATCH 36/38] fix(bundler): stabilize generated modules and watch updates --- .../GenerateModulePlugin.test.ts | 69 +++++++++++++++---- .../generate-module/GenerateModulePlugin.ts | 42 ++++++++--- .../bundler/plugins/generate-module/README.md | 14 ++++ 3 files changed, 102 insertions(+), 23 deletions(-) create mode 100644 src/cli/bundler/plugins/generate-module/README.md diff --git a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts index 23a90d2c..778a29ec 100644 --- a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts +++ b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts @@ -64,12 +64,9 @@ const run = async (compiler: Compiler): Promise => { test("bundles imports and arbitrary JavaScript exports without executing unused modules", async () => { const compiler = createCompiler(new GenerateModulePlugin(modules)); expect(await run(compiler)).toBe(6); - const file = readModule(compiler, "virtual/value"); - await close(compiler); - await expect(fs.access(file)).rejects.toMatchObject({code: "ENOENT"}); }); -test("updates before compilation, skips unchanged sources and reports recoverable callback errors", async () => { +test("updates generated sources and recovers from callback errors", async () => { let value = 3; let fail = false; const compiler = createCompiler( @@ -79,23 +76,14 @@ test("updates before compilation, skips unchanged sources and reports recoverabl }) ); expect(await run(compiler)).toBe(6); - const file = readModule(compiler, "virtual/value"); - const tools = readModule(compiler, "virtual/tools"); - const timestamp = new Date("2000-01-01T00:00:00Z"); - await fs.utimes(file, timestamp, timestamp); - await fs.utimes(tools, timestamp, timestamp); - await compiler.hooks.watchRun.promise(compiler); - expect((await fs.stat(file)).mtimeMs).toBe(timestamp.getTime()); value = 7; await compiler.hooks.watchRun.promise(compiler); - expect((await fs.stat(tools)).mtimeMs).toBe(timestamp.getTime()); expect(await run(compiler)).toBe(14); fail = true; await compiler.hooks.watchRun.promise(compiler); await expect(run(compiler)).rejects.toThrow("Cannot generate module"); - expect(await fs.readFile(file, "utf8")).toBe("export default 7;"); fail = false; value = 9; await compiler.hooks.watchRun.promise(compiler); @@ -136,13 +124,13 @@ test("recomputes file and directory dependencies after each refresh, including i expect(dependencies().has(second)).toBe(true); }); -test("isolates compilers with identical initial sources, updates and cleanup", async () => { +test("keeps compiler updates and shutdown isolated", async () => { const first = createCompiler( new GenerateModulePlugin(modules).watch(async () => ({"virtual/value": "export default 5;"})), "first" ); const second = createCompiler(new GenerateModulePlugin(modules), "second"); - expect(readModule(first, "virtual/value")).not.toBe(readModule(second, "virtual/value")); + expect(readModule(first, "virtual/value")).toBe(readModule(second, "virtual/value")); await first.hooks.watchRun.promise(first); expect(await run(first)).toBe(10); expect(await run(second)).toBe(6); @@ -150,6 +138,57 @@ test("isolates compilers with identical initial sources, updates and cleanup", a expect(await run(second)).toBe(6); }); +test("updates generated sources in the same cached watch compilation as their dependency", async () => { + const dependency = path.join(directory, "value.json"); + await fs.writeFile(dependency, "4"); + const plugin = new GenerateModulePlugin(modules).watch( + async () => ({"virtual/value": `export default ${await fs.readFile(dependency, "utf8")};`}), + async () => ({files: [dependency]}) + ); + const compiler = rspack({ + mode: "development", + context: fixtures, + target: "node", + devtool: false, + entry: "./entry.js", + output: {path: path.join(directory, "watch"), filename: "index.js"}, + plugins: [plugin], + }); + compilers.add(compiler); + const observations: number[] = []; + let resolve: () => void; + let reject: (error: unknown) => void; + const complete = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + const timer = setTimeout( + () => reject(new Error(`Generated module watch did not finish: ${JSON.stringify(observations)}`)), + 10_000 + ); + const watcher = compiler.watch({poll: 50, aggregateTimeout: 20}, (error, stats) => { + try { + if (error || !stats || stats.hasErrors()) + throw error ?? new Error(stats?.toString({all: false, errors: true})); + const sandbox = {result: 0}; + vm.runInNewContext(stats.compilation.getAsset("index.js")!.source.source().toString(), sandbox); + observations.push(sandbox.result); + expect(sandbox.result).toBe([8, 14, 18][observations.length - 1]); + if (observations.length === 3) resolve(); + else void fs.writeFile(dependency, observations.length === 1 ? "7" : "9").catch(reject); + } catch (error) { + reject(error); + } + }); + try { + await complete; + expect(observations).toEqual([8, 14, 18]); + } finally { + clearTimeout(timer); + await new Promise(done => watcher.close(done)); + } +}); + test.each([ {sharing: "none", layers: [null, "isolated", "main"]}, {sharing: "all", layers: ["shared-data"]}, diff --git a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts index 2aeb6f64..7b68c370 100644 --- a/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts +++ b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts @@ -1,8 +1,6 @@ -import {randomUUID} from "crypto"; import {existsSync} from "fs"; import path from "path"; -import type {Compiler, RuleSetCondition} from "@rspack/core"; -import {RspackVirtualModulePlugin} from "rspack-plugin-virtual-module"; +import {experiments, type Compiler, type RuleSetCondition} from "@rspack/core"; import {watchCompilation} from "../utils"; /** JavaScript source keyed by the module's import specifier. */ @@ -44,20 +42,41 @@ export default class GenerateModulePlugin { const modules = {...this.modules}; let dependencies: GenerateModulePluginDependencies = {}; - // The underlying plugin removes its entire directory on shutdown. - const plugin = new RspackVirtualModulePlugin(modules, `generate-module-${randomUUID()}`); + // Stable paths keep module IDs and hashes reproducible. The native store belongs + // to this compiler, so concurrent builds never share files or cleanup ownership. + const resources = Object.fromEntries( + Object.keys(modules).map(name => [ + name, + path.resolve( + compiler.context, + "node_modules", + ".adnbn-virtual", + path.extname(name) ? name : `${name}.js` + ), + ]) + ); + const plugin = new experiments.VirtualModulesPlugin( + Object.fromEntries(Object.entries(modules).map(([name, source]) => [resources[name], source])) + ); plugin.apply(compiler); + compiler.options.resolve.alias = {...compiler.options.resolve.alias, ...resources}; + const pending = new Map(); + let initialized = false; if (this.moduleLayer !== undefined) { - const resources = Object.keys(modules).map(name => compiler.options.resolve.alias![name] as string); compiler.options.module.rules.push({ - include: resources, + include: Object.values(resources), layer: this.moduleLayer, issuerLayer: this.issuerLayer, }); } compiler.hooks.compilation.tap(this.pluginName, compilation => { + // Native virtual files can be updated only after the Rust compiler exists. + for (const [name, source] of pending) plugin.writeModule(resources[name], source); + pending.clear(); + initialized = true; + for (const file of dependencies.files ?? []) { compilation.fileDependencies.add(path.resolve(compiler.context, file)); } @@ -75,7 +94,14 @@ export default class GenerateModulePlugin { for (const [name, source] of Object.entries(await this.update())) { if (modules[name] !== source) { - plugin.writeModule(name, source); + if (initialized) { + plugin.writeModule(resources[name], source); + // watchRun precedes native cache invalidation. Include generated changes + // in this rebuild rather than waiting for a second filesystem event. + compiler.modifiedFiles = new Set([...(compiler.modifiedFiles ?? []), resources[name]]); + } else { + pending.set(name, source); + } modules[name] = source; } } diff --git a/src/cli/bundler/plugins/generate-module/README.md b/src/cli/bundler/plugins/generate-module/README.md new file mode 100644 index 00000000..24cfc3d2 --- /dev/null +++ b/src/cli/bundler/plugins/generate-module/README.md @@ -0,0 +1,14 @@ +# Generated modules + +`GenerateModulePlugin` maps import specifiers to JavaScript source through Rspack's native virtual +file store. Files have stable paths under the compiler context's `node_modules/.adnbn-virtual` and +exist only in that compiler's memory. Identical builds therefore retain stable module identities; +concurrent compilers can use the same paths without sharing source updates or deleting each other's +files. + +`watch()` prepares changed sources before compilation. The first update is applied when the native +virtual store becomes available. Later updates are applied before cache invalidation and included in +the compiler's modified files, so consumers receive new data in the same rebuild. Unchanged sources +are not rewritten. Declared file and directory dependencies are registered per compilation, and +update failures are reported as compilation errors so watch can recover on the next edit. `layer()` +keeps its issuer selection and module-layer behavior independently of the virtual file storage. From 5185bbb7cc6538fd3200ee3ca5309649d730ec27 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:27:07 +0300 Subject: [PATCH 37/38] fix(bundler): embed entrypoint asset maps only for consumers --- .../BuildAssetsMapPlugin.test.ts | 226 +++++++++++++++++- .../build-assets-map/BuildAssetsMapPlugin.ts | 119 +++++---- .../plugins/build-assets-map/README.md | 22 ++ .../build-assets-map/entrypoint-module.ts | 20 ++ .../bundler/plugins/build-assets-map/index.ts | 2 + .../tests/fixtures/alpha-unused.entry.js | 7 + .../tests/fixtures/background-unused.entry.js | 3 + .../tests/fixtures/beta-unused.entry.js | 4 + .../tests/fixtures/usage/lazy.entry.js | 1 + .../tests/fixtures/usage/local.entry.js | 3 + .../tests/fixtures/usage/namespace.entry.js | 3 + .../tests/fixtures/usage/shared-reader.js | 3 + .../tests/fixtures/usage/shared.entry.js | 3 + .../tests/fixtures/usage/unused-helper.js | 4 + .../tests/fixtures/usage/unused.entry.js | 5 + .../tests/fixtures/watch/background-unused.js | 3 + .../tests/fixtures/watch/background.js | 3 + .../tests/fixtures/watch/local-unused.js | 4 + .../tests/fixtures/watch/local.js | 4 + .../tests/fixtures/watch/style-changed.css | 3 + .../tests/fixtures/watch/style.css | 3 + .../bundler/plugins/build-assets-map/usage.ts | 46 ++++ src/cli/bundler/plugins/index.ts | 9 +- .../plugins/manifest/ManifestPlugin.test.ts | 8 +- .../ResourceAccessPlugin.test.ts | 5 +- src/cli/plugins/output.ts | 10 +- src/main/entrypoint.test.ts | 73 ------ src/main/entrypoint.ts | 15 +- src/types/entrypoint.ts | 3 + src/virtual/entrypoint.ts | 7 + src/virtual/modules.d.ts | 5 + .../build/locale/states/package-import.mjs | 6 + .../style-routing/src/background-unused.js | 3 + .../build/style-routing/src/background.js | 4 +- .../style-routing.integration.test.ts | 27 ++- 35 files changed, 522 insertions(+), 144 deletions(-) create mode 100644 src/cli/bundler/plugins/build-assets-map/README.md create mode 100644 src/cli/bundler/plugins/build-assets-map/entrypoint-module.ts create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha-unused.entry.js create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-unused.entry.js create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta-unused.entry.js create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/lazy.entry.js create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/local.entry.js create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/namespace.entry.js create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/shared-reader.js create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/shared.entry.js create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/unused-helper.js create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/unused.entry.js create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/background-unused.js create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/background.js create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/local-unused.js create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/local.js create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/style-changed.css create mode 100644 src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/style.css create mode 100644 src/cli/bundler/plugins/build-assets-map/usage.ts delete mode 100644 src/main/entrypoint.test.ts create mode 100644 src/virtual/entrypoint.ts create mode 100644 tests/integration/build/style-routing/src/background-unused.js diff --git a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts index 2036d924..5cf5388c 100644 --- a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts +++ b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts @@ -2,6 +2,7 @@ import crypto from "crypto"; import fs from "fs"; +import {readFile, writeFile} from "fs/promises"; import os from "os"; import path from "path"; import vm from "vm"; @@ -24,6 +25,8 @@ import {appFilenameResolver, getCompilationBuildAssets} from "@cli/bundler/utils import type {EntrypointAssetsMap, EntrypointAssetsMapEntry, EntrypointAssets} from "@typing/entrypoint"; import BuildAssetsMapPlugin from "./BuildAssetsMapPlugin"; +import {GenerateModulePlugin} from "../generate-module"; +import {createEntrypointModule, EntrypointAssetsModule} from "./entrypoint-module"; const AppName = "Build Assets Fixture"; const AppToken = "build-assets-fixture"; @@ -215,7 +218,10 @@ const compile = async ( options: { assetInfoFilename?: boolean; assertEmptyOutputOnFailure?: boolean; - background?: "async" | "shared"; + background?: "async" | "shared" | "unused"; + noConsumers?: boolean; + usage?: boolean; + mode?: "production" | "development"; betaDirectory?: string; betaRuntimeMarker?: string; betaCssChange?: boolean; @@ -268,16 +274,30 @@ const compile = async ( ? "./background-async.entry.js" : options.background === "shared" ? "./background-shared.entry.js" - : "./background.entry.js"; + : options.background === "unused" || options.noConsumers + ? "./background-unused.entry.js" + : "./background.entry.js"; const compiler = rspack({ context: fixtures, - mode: "production", + mode: options.mode ?? "production", target: ["web", "es2020"], devtool: "source-map", entry: { ...(options.includeBackground === false ? {} : {background}), - alpha: "./alpha.entry.js", - beta: "./beta.entry.js", + alpha: options.noConsumers ? "./alpha-unused.entry.js" : "./alpha.entry.js", + beta: options.noConsumers ? "./beta-unused.entry.js" : "./beta.entry.js", + ...(options.usage + ? { + local: "./usage/local.entry.js", + isolated: {import: "./usage/local.entry.js", layer: "fixture:isolated"}, + unused: "./usage/unused.entry.js", + "unused-isolated": {import: "./usage/unused.entry.js", layer: "fixture:isolated"}, + namespace: "./usage/namespace.entry.js", + lazy: "./usage/lazy.entry.js", + "shared-a": "./usage/shared.entry.js", + "shared-b": "./usage/shared.entry.js", + } + : {}), }, output: { path: outputPath, @@ -324,6 +344,7 @@ const compile = async ( ], }, optimization: { + usedExports: true, chunkIds: "deterministic", moduleIds: "deterministic", minimize: true, @@ -343,6 +364,17 @@ const compile = async ( enforce: true, reuseExistingChunk: true, }, + ...(options.usage + ? { + fixtureReader: { + test: /[\\/]shared-reader\.js$/, + name: "shared-reader", + chunks: "all", + minChunks: 2, + enforce: true, + }, + } + : {}), ...(options.sharedCssChunk ? { fixtureSharedStyles: { @@ -388,7 +420,9 @@ const compile = async ( ...(options.renameBetaCss ? [new RenameBetaCssPlugin()] : []), ...(options.removeBetaCss ? [new RemoveBetaCssPlugin()] : []), ...(options.betaRuntimeMarker ? [new AddBetaRuntimeModulePlugin(options.betaRuntimeMarker)] : []), + new GenerateModulePlugin({[EntrypointAssetsModule.request]: createEntrypointModule()}), new BuildAssetsMapPlugin({ + module: EntrypointAssetsModule, buildHashSalt: options.buildHashSalt, cssChunkFilename: cssFilename, cssFilename, @@ -655,6 +689,58 @@ const expectNoChangedBytesUnderStableNames = (before: BuildResult, after: BuildR jest.setTimeout(120_000); describe("BuildAssetsMapPlugin", () => { + test.each(["production", "development"] as const)( + "collects the build inventory without embedding unused maps in %s", + async mode => { + const build = await compile(true, {noConsumers: true, mode}); + for (const source of Object.values(build.sources)) { + expect(source).not.toContain("__adnbnBuildAssetsFullMap__"); + expect(source).not.toContain("__adnbnBuildAssetsCurrentMap__"); + } + expect(build.assets.alpha.initial.css).toHaveLength(1); + expect(build.assets.beta.initial.css).toHaveLength(1); + expect(build.assets.alpha.assets.length).toBeGreaterThan(0); + expect(executeEntrypoint(build, "background").sandbox.__backgroundValue).toBe("unused"); + } + ); + + test.each([false, true])( + "selects consumers across layers, reexports and lazy/shared modules (shared=%s)", + async common => { + const build = await compile(common, {background: "unused", usage: true}); + for (const entry of ["background", "unused", "unused-isolated"]) { + const source = build.assets[entry].initial.js.map(file => build.sources[file]).join("\n"); + expect(source).not.toContain("__adnbnBuildAssetsFullMap__"); + expect(source).not.toContain("__adnbnBuildAssetsCurrentMap__"); + } + for (const entry of ["local", "isolated", "namespace", "lazy", "shared-a", "shared-b"]) { + const {sandbox} = executeEntrypoint(build, entry); + expect(await (sandbox.readCurrent as () => unknown)()).toEqual(runtimeAssets(build.assets[entry])); + } + if (common) { + const shared = build.assets["shared-a"].initial.js.filter(file => + build.assets["shared-b"].initial.js.includes(file) + ); + expect(shared.length).toBeGreaterThan(0); + for (const file of shared) expect(build.sources[file]).not.toContain("__adnbnBuildAssetsCurrentMap__"); + } + } + ); + + test.each(["chunkhash", "contenthash"] as const)( + "keeps an unused background's %s stable when another entry changes", + async jsHash => { + const [before, after] = await Promise.all([ + compile(false, {background: "unused", jsHash}), + compile(false, {background: "unused", betaCssChange: true, jsHash}), + ]); + expect(after.assets.background.initial.js).toEqual(before.assets.background.initial.js); + const background = before.assets.background.initial.js[0]; + expect(after.hashes[background]).toBe(before.hashes[background]); + expect(after.assets.beta.initial.css).not.toEqual(before.assets.beta.initial.css); + } + ); + test.each(["chunkhash", "contenthash", "fullhash"] as const)( "exposes isolated runtime maps with %s filenames", async jsHash => { @@ -878,4 +964,134 @@ describe("BuildAssetsMapPlugin", () => { }) ).rejects.toThrow('Build assets changed after the runtime map for entrypoint "beta" was embedded'); }); + + test("refreshes selected runtimes, final CSS filenames and removals in watch", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-assets-watch-")); + const context = path.join(directory, "src"); + const states = path.join(fixtures, "watch"); + fs.cpSync(states, context, {recursive: true}); + const update = async (file: string, state: string) => + writeFile(path.join(context, file), await readFile(path.join(states, state))); + await update("background.js", "background-unused.js"); + await update("local.js", "local-unused.js"); + const compiler = rspack({ + context, + mode: "production", + devtool: false, + entry: {background: "./background.js", local: "./local.js"}, + output: { + path: path.join(directory, "dist"), + filename: "[name].[chunkhash:8].js", + publicPath: "", + uniqueName: AppToken, + chunkLoadingGlobal: ChunkLoadingGlobal, + }, + resolve: {alias: {adnbn$: path.join(projectRoot, "dist/index.js")}}, + resolveLoader: {modules: [path.join(projectRoot, "node_modules")]}, + module: {rules: [{test: /\.css$/, use: [CssExtractRspackPlugin.loader, "css-loader"]}]}, + plugins: [ + new CssExtractRspackPlugin({filename: "[name].[contenthash:8].css"}), + new GenerateModulePlugin({[EntrypointAssetsModule.request]: createEntrypointModule()}), + new BuildAssetsMapPlugin({ + module: EntrypointAssetsModule, + fullMapEntrypoint: "background", + cssFilename: "[name].[contenthash:8].css", + cssChunkFilename: "[name].[contenthash:8].css", + }), + ], + }); + const builds: BuildResult[] = []; + let failure: Error | undefined; + let notify: (() => void) | undefined; + const watcher = compiler.watch({poll: 50, aggregateTimeout: 20}, (error, stats) => { + if (error || !stats || stats.hasErrors()) { + failure = + error ?? new Error(stats?.toString({all: false, errors: true}) ?? "Missing watch compilation"); + } else { + builds.push({ + assets: getCompilationBuildAssets(stats.compilation)!, + sources: Object.fromEntries( + stats.compilation.getAssets().map(asset => [asset.name, asset.source.source().toString()]) + ), + hashes: {}, + }); + } + notify?.(); + }); + const waitForBuild = (description: string, matches: (build: BuildResult) => boolean): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + notify = undefined; + reject(new Error(`Timed out waiting for ${description}`)); + }, 15_000); + notify = () => { + if (failure) { + clearTimeout(timer); + notify = undefined; + reject(failure); + return; + } + const build = builds.find(matches); + if (build) { + clearTimeout(timer); + notify = undefined; + resolve(build); + } + }; + notify(); + }); + const hasMap = (build: BuildResult, entry: string, key: string) => + build.assets[entry].initial.js.some(file => build.sources[file].includes(key)); + const currentKey = "__adnbnBuildAssetsCurrentMap__"; + const fullKey = "__adnbnBuildAssetsFullMap__"; + try { + const initial = await waitForBuild("initial build without maps", () => true); + expect(hasMap(initial, "background", fullKey)).toBe(false); + expect(hasMap(initial, "local", currentKey)).toBe(false); + + builds.length = 0; + await update("local.js", "local.js"); + const local = await waitForBuild("local map consumer", build => hasMap(build, "local", currentKey)); + expect(hasMap(local, "background", fullKey)).toBe(false); + expect((executeEntrypoint(local, "local").sandbox.readCurrent as () => unknown)()).toEqual( + runtimeAssets(local.assets.local) + ); + + builds.length = 0; + await update("background.js", "background.js"); + const full = await waitForBuild("background map consumer", build => hasMap(build, "background", fullKey)); + expect((executeEntrypoint(full, "background").sandbox.readFull as () => unknown)()).toEqual(full.assets); + + builds.length = 0; + await update("style.css", "style-changed.css"); + const changed = await waitForBuild( + "changed CSS filename", + build => build.assets.local.initial.css[0] !== full.assets.local.initial.css[0] + ); + expect((executeEntrypoint(changed, "background").sandbox.readFull as () => unknown)()).toEqual( + changed.assets + ); + expect((executeEntrypoint(changed, "local").sandbox.readCurrent as () => unknown)()).toEqual( + runtimeAssets(changed.assets.local) + ); + expect(changed.assets.background.initial.js).not.toEqual(full.assets.background.initial.js); + + builds.length = 0; + await update("local.js", "local-unused.js"); + const removedLocal = await waitForBuild("local map removal", build => !hasMap(build, "local", currentKey)); + expect((executeEntrypoint(removedLocal, "background").sandbox.readFull as () => unknown)()).toEqual( + removedLocal.assets + ); + + builds.length = 0; + await update("background.js", "background-unused.js"); + const removed = await waitForBuild("full map removal", build => !hasMap(build, "background", fullKey)); + expect(hasMap(removed, "local", currentKey)).toBe(false); + expect(removed.assets.local.initial.css).toEqual(changed.assets.local.initial.css); + } finally { + await new Promise(resolve => watcher.close(resolve)); + await new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); + fs.rmSync(directory, {recursive: true, force: true}); + } + }); }); diff --git a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts index c55b65cf..e1054275 100644 --- a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts +++ b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts @@ -14,14 +14,14 @@ import {toPosix} from "@cli/utils/path"; import type {EntrypointAssetsMap, EntrypointAssets} from "@typing/entrypoint"; +import {trackModuleUsage, type BuildAssetsMapPluginModuleOptions} from "./usage"; + const PluginName = "BuildAssetsMapPlugin"; // Replaced with the full map once Rspack has resolved the output filenames. const FullMapPlaceholder = "__ADNBN_BUILD_ASSETS_FULL_MAP_PLACEHOLDER__"; // These JSON envelope keys let final validation locate each map after minification. const FullMapEnvelopeKey = "__adnbnBuildAssetsFullMap__"; const CurrentMapEnvelopeKey = "__adnbnBuildAssetsCurrentMap__"; -const MapProperty = "__adnbnBuildAssets"; -const CurrentMapProperty = "__adnbnCurrentEntrypointAssets"; // Hash-based output names are fixed when processAssets starts, so the asset graph is frozen here. const EmbedStage = Number.NEGATIVE_INFINITY; const FinalValidationStage = Infinity; @@ -31,7 +31,14 @@ interface FullMapRuntime { readonly runtime: Chunk; } +interface RuntimeSelection { + readonly name: string; + readonly fullMap: boolean; + readonly property: string; +} + export interface BuildAssetsMapPluginOptions { + readonly module: BuildAssetsMapPluginModuleOptions; readonly buildHashSalt?: string; readonly cssChunkFilename: Filename; readonly cssFilename: Filename; @@ -221,10 +228,14 @@ const getChunkKey = (chunk: Chunk): string => { return `${String(chunk.name ?? "")}:${String(chunk.id ?? "")}`; }; -const getFullMapRuntime = (compilation: Compilation, name: string): FullMapRuntime | undefined => { +const getFullMapRuntime = ( + compilation: Compilation, + name: string, + runtimes: ReadonlySet +): FullMapRuntime | undefined => { const entrypoint = compilation.entrypoints.get(name); - if (!entrypoint) { + if (!entrypoint || !runtimes.has(entrypoint.getRuntimeChunk())) { return; } @@ -237,13 +248,12 @@ const getFullMapRuntimeFiles = (fullMap: FullMapRuntime, assets: EntrypointAsset return assets[fullMap.name].initial.js.filter(file => runtimeFiles.has(file)); }; -const validateEntrypointRuntimes = (compilation: Compilation): void => { - for (const [name, entrypoint] of compilation.entrypoints) { - if (entrypoint.getRuntimeChunk() !== entrypoint.getEntrypointChunk()) { - throw new Error( - `Build assets require a self-contained runtime for entrypoint "${name}"; disable optimization.runtimeChunk` - ); - } +const validateEntrypointRuntime = (compilation: Compilation, name: string): void => { + const entrypoint = compilation.entrypoints.get(name)!; + if (entrypoint.getRuntimeChunk() !== entrypoint.getEntrypointChunk()) { + throw new Error( + `Build assets require a self-contained runtime for entrypoint "${name}"; disable optimization.runtimeChunk` + ); } }; @@ -307,9 +317,11 @@ const collectRuntimeModuleFingerprints = (compilation: Compilation): RuntimeModu const updateBuildAssetsChunkHashes = ( compilation: Compilation, options: Required, - runtimeModuleFingerprints: RuntimeModuleFingerprints + runtimeModuleFingerprints: RuntimeModuleFingerprints, + runtimes: ReadonlySet ): void => { compilation.hooks.chunkHash.tap(PluginName, (chunk, hash) => { + if (!runtimes.has(chunk)) return; const runtimeEntry = Array.from(compilation.entrypoints).find(([, entrypoint]) => { return entrypoint.getRuntimeChunk() === chunk; }); @@ -405,6 +417,7 @@ class BuildAssetsRuntimeModule extends RuntimeModule { public constructor( private readonly fullMap: boolean, private readonly runtimeName: string, + private readonly property: string, private readonly cssFilename: Filename, private readonly cssChunkFilename: Filename, fullHash: boolean @@ -421,7 +434,7 @@ class BuildAssetsRuntimeModule extends RuntimeModule { const runtime = RuntimeGlobals.require; if (this.fullMap) { - return `${runtime}.${MapProperty} = JSON.parse(${JSON.stringify(FullMapPlaceholder)}).${FullMapEnvelopeKey};`; + return `${runtime}[${JSON.stringify(this.property)}] = JSON.parse(${JSON.stringify(FullMapPlaceholder)}).${FullMapEnvelopeKey};`; } const compilation = this.compilation; @@ -439,7 +452,7 @@ class BuildAssetsRuntimeModule extends RuntimeModule { const envelope = JSON.stringify({[CurrentMapEnvelopeKey]: current}); - return `${runtime}.${CurrentMapProperty} = JSON.parse(${JSON.stringify(envelope)}).${CurrentMapEnvelopeKey};`; + return `${runtime}[${JSON.stringify(this.property)}] = JSON.parse(${JSON.stringify(envelope)}).${CurrentMapEnvelopeKey};`; } } @@ -537,7 +550,8 @@ const validateEmbeddedMap = (compilation: Compilation, fullMap: FullMapRuntime, const validateCurrentEntrypointMaps = ( compilation: Compilation, assets: EntrypointAssetsMap, - fullMapEntrypoint: string + fullMapEntrypoint: string, + runtimes: ReadonlySet ): void => { for (const [name, entrypointAssets] of Object.entries(assets)) { if (name === fullMapEntrypoint) { @@ -550,6 +564,8 @@ const validateCurrentEntrypointMaps = ( throw new Error(`Build assets entrypoint "${name}" is unavailable`); } + if (!runtimes.has(entrypoint.getRuntimeChunk())) continue; + const runtimeFiles = new Set(Array.from(entrypoint.getRuntimeChunk().files, toPosix)); const runtime = entrypointAssets.initial.js .filter(file => runtimeFiles.has(file)) @@ -578,17 +594,20 @@ const validateCurrentEntrypointMaps = ( } }; -const finalizeBuildAssets = (compilation: Compilation, options: Required): void => { +const finalizeBuildAssets = ( + compilation: Compilation, + options: Required, + runtimes: ReadonlySet +): void => { const assets = collectBuildAssets(compilation); - const fullMap = getFullMapRuntime(compilation, options.fullMapEntrypoint); - - validateFullMapAssets(assets, options.fullMapEntrypoint); + const fullMap = getFullMapRuntime(compilation, options.fullMapEntrypoint, runtimes); if (fullMap) { + validateFullMapAssets(assets, options.fullMapEntrypoint); validateEmbeddedMap(compilation, fullMap, assets); } - validateCurrentEntrypointMaps(compilation, assets, options.fullMapEntrypoint); + validateCurrentEntrypointMaps(compilation, assets, options.fullMapEntrypoint, runtimes); setCompilationBuildAssets(compilation, assets); }; @@ -604,19 +623,32 @@ export default class BuildAssetsMapPlugin { } public apply(compiler: Compiler): void { + const runtimeSelections = new WeakMap>(); + compiler.hooks.thisCompilation.tap({name: PluginName, stage: EmbedStage}, compilation => { const injectedRuntimes = new Set(); + runtimeSelections.set(compilation, injectedRuntimes); let filenamesValidated = false; - let runtimesValidated = false; + let selectedRuntimes: Map | undefined; const runtimeModuleFingerprints = collectRuntimeModuleFingerprints(compilation); - - updateBuildAssetsChunkHashes(compilation, this.options, runtimeModuleFingerprints); - - compilation.hooks.additionalTreeRuntimeRequirements.tap(PluginName, chunk => { - if (!runtimesValidated) { - validateEntrypointRuntimes(compilation); - validateFullMapEntrypoint(compilation, this.options.fullMapEntrypoint); - runtimesValidated = true; + const isUsed = trackModuleUsage(compiler, compilation, this.options.module); + + updateBuildAssetsChunkHashes(compilation, this.options, runtimeModuleFingerprints, injectedRuntimes); + + compilation.hooks.additionalTreeRuntimeRequirements.tap(PluginName, (chunk, requirements) => { + if (!selectedRuntimes) { + selectedRuntimes = new Map(); + // Select and validate in entrypoint order, independently of runtime hook order. + for (const [name, entrypoint] of compilation.entrypoints) { + const runtime = entrypoint.getRuntimeChunk(); + const fullMap = name === this.options.fullMapEntrypoint; + const delivery = fullMap ? this.options.module.full : this.options.module.current; + if (!isUsed(runtime, delivery.export)) continue; + + validateEntrypointRuntime(compilation, name); + if (fullMap) validateFullMapEntrypoint(compilation, name); + selectedRuntimes.set(runtime, {name, fullMap, property: delivery.property}); + } } if (!filenamesValidated) { @@ -624,27 +656,22 @@ export default class BuildAssetsMapPlugin { filenamesValidated = true; } - const runtimeEntry = Array.from(compilation.entrypoints).find(([, entrypoint]) => { - return entrypoint.getRuntimeChunk() === chunk; - }); - - if (!runtimeEntry || injectedRuntimes.has(chunk)) { - return; - } - - const [runtimeName] = runtimeEntry; + const selection = selectedRuntimes.get(chunk); + if (!selection || injectedRuntimes.has(chunk)) return; + requirements.add(RuntimeGlobals.require); injectedRuntimes.add(chunk); compilation.addRuntimeModule( chunk, new BuildAssetsRuntimeModule( - runtimeName === this.options.fullMapEntrypoint, - runtimeName, + selection.fullMap, + selection.name, + selection.property, this.options.cssFilename, this.options.cssChunkFilename, entrypointRuntimeUsesFullHash( compilation, - runtimeName, + selection.name, this.options.cssFilename, this.options.cssChunkFilename ) @@ -659,12 +686,11 @@ export default class BuildAssetsMapPlugin { }, () => { const assets = collectBuildAssets(compilation); - const fullMap = getFullMapRuntime(compilation, this.options.fullMapEntrypoint); - - validateFullMapAssets(assets, this.options.fullMapEntrypoint); + const fullMap = getFullMapRuntime(compilation, this.options.fullMapEntrypoint, injectedRuntimes); setCompilationBuildAssets(compilation, assets); if (fullMap) { + validateFullMapAssets(assets, this.options.fullMapEntrypoint); replaceFullMapPlaceholder(compilation, getFullMapRuntimeFiles(fullMap, assets), assets); } } @@ -675,7 +701,7 @@ export default class BuildAssetsMapPlugin { name: `${PluginName}:finalize`, stage: Compilation.PROCESS_ASSETS_STAGE_ANALYSE, }, - () => finalizeBuildAssets(compilation, this.options) + () => finalizeBuildAssets(compilation, this.options, injectedRuntimes) ); }); @@ -686,7 +712,8 @@ export default class BuildAssetsMapPlugin { compiler.hooks.emit.tap( {name: `${PluginName}:finalValidation`, stage: FinalValidationStage}, compilation => { - finalizeBuildAssets(compilation, this.options); + const runtimes = runtimeSelections.get(compilation); + if (runtimes) finalizeBuildAssets(compilation, this.options, runtimes); } ); }, diff --git a/src/cli/bundler/plugins/build-assets-map/README.md b/src/cli/bundler/plugins/build-assets-map/README.md new file mode 100644 index 00000000..3c4122f4 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/README.md @@ -0,0 +1,22 @@ +# Entrypoint asset maps + +`BuildAssetsMapPlugin` collects the complete entrypoint inventory for manifest generation on every +compilation. Runtime delivery is selected separately through the configured module's used exports. +The output feature composes it with `GenerateModulePlugin`, which provides `#adnbn/entrypoint`. + +The virtual module exports readers, not snapshots. The current-entry reader is supplied in consuming +non-background runtimes; the full-map reader is supplied only in a consuming background runtime. +Public getters retain their errors in unsupported contexts. Usage is inspected for each runtime, +including its shared and async modules and every module layer, before the runtime code is added. +Unknown export usage is treated conservatively. Disabling tree shaking or passing an entire module +namespace to other code can retain more readers than an optimized named import. + +Only selected runtimes receive the additional map hash dependencies and embedded-map validation. +The current map is generated through Rspack's filename resolution lifecycle; the full map is embedded +at the start of asset processing, after filenames are resolved. Final validation checks the embedded +maps against the emitted inventory. The full-map runtime must remain a self-contained chunk, and +consuming entries must retain their own runtimes. Selection is recreated for every compilation, +including watch rebuilds that add or remove consumers. + +Isolated CSS delivery belongs to `IsolatedStylesPlugin` and does not require these public maps. +The build inventory remains complete for manifest dependencies even when no runtime requests a map. diff --git a/src/cli/bundler/plugins/build-assets-map/entrypoint-module.ts b/src/cli/bundler/plugins/build-assets-map/entrypoint-module.ts new file mode 100644 index 00000000..6986cfb1 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/entrypoint-module.ts @@ -0,0 +1,20 @@ +import {RuntimeGlobals} from "@rspack/core"; + +import {EntrypointAssetsMapRuntimeProperty, EntrypointAssetsRuntimeProperty} from "@typing/entrypoint"; + +import type {BuildAssetsMapPluginModuleOptions} from "./usage"; + +export const EntrypointAssetsModule = { + request: "#adnbn/entrypoint", + current: {export: "readAssets", property: EntrypointAssetsRuntimeProperty}, + full: {export: "readAssetsMap", property: EntrypointAssetsMapRuntimeProperty}, +} satisfies BuildAssetsMapPluginModuleOptions; + +/** Readers keep runtime data live without capturing a snapshot during module evaluation. */ +export const createEntrypointModule = (): string => + [EntrypointAssetsModule.current, EntrypointAssetsModule.full] + .map( + ({export: name, property}) => + `export const ${name} = () => ${RuntimeGlobals.require}[${JSON.stringify(property)}];\n` + ) + .join(""); diff --git a/src/cli/bundler/plugins/build-assets-map/index.ts b/src/cli/bundler/plugins/build-assets-map/index.ts index 337a9d98..ef939139 100644 --- a/src/cli/bundler/plugins/build-assets-map/index.ts +++ b/src/cli/bundler/plugins/build-assets-map/index.ts @@ -1 +1,3 @@ export {default, type BuildAssetsMapPluginOptions} from "./BuildAssetsMapPlugin"; +export {createEntrypointModule, EntrypointAssetsModule} from "./entrypoint-module"; +export type {BuildAssetsMapPluginModuleOptions, BuildAssetsMapPluginExportOptions} from "./usage"; diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha-unused.entry.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha-unused.entry.js new file mode 100644 index 00000000..131ee9ed --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha-unused.entry.js @@ -0,0 +1,7 @@ +import {getEntrypointAssets} from "adnbn"; +import "./alpha.css"; +import payload from "./payload.js?resource"; +import {shared} from "./shared.js"; + +globalThis.__alphaPayload = payload; +globalThis.__alphaShared = shared; diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-unused.entry.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-unused.entry.js new file mode 100644 index 00000000..ed1724ce --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-unused.entry.js @@ -0,0 +1,3 @@ +import {getEntrypointAssetsMap} from "adnbn"; + +globalThis.__backgroundValue = "unused"; diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta-unused.entry.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta-unused.entry.js new file mode 100644 index 00000000..20f4d1a8 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta-unused.entry.js @@ -0,0 +1,4 @@ +import "./beta.css"; +import {shared} from "./shared.js"; + +globalThis.__betaShared = shared; diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/lazy.entry.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/lazy.entry.js new file mode 100644 index 00000000..b8de0c50 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/lazy.entry.js @@ -0,0 +1 @@ +globalThis.readCurrent = async () => (await import("./shared-reader.js")).readCurrent(); diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/local.entry.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/local.entry.js new file mode 100644 index 00000000..815c3338 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/local.entry.js @@ -0,0 +1,3 @@ +import {getEntrypointAssets} from "adnbn"; + +globalThis.readCurrent = () => getEntrypointAssets(); diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/namespace.entry.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/namespace.entry.js new file mode 100644 index 00000000..e9d325be --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/namespace.entry.js @@ -0,0 +1,3 @@ +import * as api from "adnbn"; + +globalThis.readCurrent = () => api.getEntrypointAssets(); diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/shared-reader.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/shared-reader.js new file mode 100644 index 00000000..2fda0fd5 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/shared-reader.js @@ -0,0 +1,3 @@ +import {getEntrypointAssets} from "adnbn"; + +export const readCurrent = () => getEntrypointAssets(); diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/shared.entry.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/shared.entry.js new file mode 100644 index 00000000..4b6d37bf --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/shared.entry.js @@ -0,0 +1,3 @@ +import {readCurrent} from "./shared-reader.js"; + +globalThis.readCurrent = readCurrent; diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/unused-helper.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/unused-helper.js new file mode 100644 index 00000000..355f7cc0 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/unused-helper.js @@ -0,0 +1,4 @@ +import {getEntrypointAssets} from "adnbn"; + +export const unused = () => getEntrypointAssets(); +export const value = 42; diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/unused.entry.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/unused.entry.js new file mode 100644 index 00000000..a3d8a660 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/usage/unused.entry.js @@ -0,0 +1,5 @@ +import {getEntrypointAssets, getEntrypointAssetsMap} from "adnbn"; +import {value} from "./unused-helper.js"; + +if (false) globalThis.readCurrent = getEntrypointAssets; +globalThis.value = value; diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/background-unused.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/background-unused.js new file mode 100644 index 00000000..e408862c --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/background-unused.js @@ -0,0 +1,3 @@ +import {getEntrypointAssetsMap} from "adnbn"; + +globalThis.watchState = "background-unused"; diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/background.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/background.js new file mode 100644 index 00000000..7f5a6fad --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/background.js @@ -0,0 +1,3 @@ +import {getEntrypointAssetsMap} from "adnbn"; + +globalThis.readFull = () => getEntrypointAssetsMap(); diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/local-unused.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/local-unused.js new file mode 100644 index 00000000..85539016 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/local-unused.js @@ -0,0 +1,4 @@ +import {getEntrypointAssets} from "adnbn"; +import "./style.css"; + +globalThis.watchState = "local-unused"; diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/local.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/local.js new file mode 100644 index 00000000..08eba315 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/local.js @@ -0,0 +1,4 @@ +import {getEntrypointAssets} from "adnbn"; +import "./style.css"; + +globalThis.readCurrent = () => getEntrypointAssets(); diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/style-changed.css b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/style-changed.css new file mode 100644 index 00000000..e5fb2f06 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/style-changed.css @@ -0,0 +1,3 @@ +body { + color: green; +} diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/style.css b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/style.css new file mode 100644 index 00000000..b05faf8e --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/watch/style.css @@ -0,0 +1,3 @@ +body { + color: red; +} diff --git a/src/cli/bundler/plugins/build-assets-map/usage.ts b/src/cli/bundler/plugins/build-assets-map/usage.ts new file mode 100644 index 00000000..77f1866e --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/usage.ts @@ -0,0 +1,46 @@ +import type {Chunk, Compilation, Compiler, NormalModule} from "@rspack/core"; + +import type {RuntimePropertyOptions} from "../types"; + +export interface BuildAssetsMapPluginExportOptions extends RuntimePropertyOptions { + readonly export: string; +} + +export interface BuildAssetsMapPluginModuleOptions { + readonly request: string; + readonly current: BuildAssetsMapPluginExportOptions; + readonly full: BuildAssetsMapPluginExportOptions; +} + +/** Retain every layer's module before concatenation, then inspect export use per runtime. */ +export const trackModuleUsage = ( + compiler: Compiler, + compilation: Compilation, + options: BuildAssetsMapPluginModuleOptions +): ((chunk: Chunk, name: string) => boolean) => { + let resource: string | undefined; + let modules: NormalModule[] = []; + + compilation.hooks.finishModules.tapPromise("BuildAssetsMapPlugin:resolve", async () => { + const resolver = compiler.resolverFactory.get("normal", {dependencyType: "esm"}); + resource = await new Promise((resolve, reject) => { + resolver.resolve({}, compiler.context, options.request, {}, (error, result) => { + if (error) reject(error); + else if (typeof result !== "string") + reject(new Error(`Unable to resolve assets module "${options.request}"`)); + else resolve(result); + }); + }); + }); + + compilation.hooks.afterOptimizeModules.tap("BuildAssetsMapPlugin:usage", candidates => { + modules = Array.from(candidates).filter( + module => (module as NormalModule).resource === resource + ) as NormalModule[]; + }); + + return (chunk, name) => { + const runtime = new Set(chunk.runtime); + return modules.some(module => compilation.moduleGraph.getExportsInfo(module).getUsed(name, runtime) !== 0); + }; +}; diff --git a/src/cli/bundler/plugins/index.ts b/src/cli/bundler/plugins/index.ts index fbf9c77c..aa6757bb 100644 --- a/src/cli/bundler/plugins/index.ts +++ b/src/cli/bundler/plugins/index.ts @@ -17,7 +17,14 @@ export { type IsolatedStylesPluginFiles, } from "./isolated-styles"; -export {default as BuildAssetsMapPlugin, type BuildAssetsMapPluginOptions} from "./build-assets-map"; +export { + default as BuildAssetsMapPlugin, + type BuildAssetsMapPluginOptions, + type BuildAssetsMapPluginModuleOptions, + type BuildAssetsMapPluginExportOptions, + createEntrypointModule, + EntrypointAssetsModule, +} from "./build-assets-map"; export { default as GenerateJsonPlugin, diff --git a/src/cli/bundler/plugins/manifest/ManifestPlugin.test.ts b/src/cli/bundler/plugins/manifest/ManifestPlugin.test.ts index 62fc377f..763a4a18 100644 --- a/src/cli/bundler/plugins/manifest/ManifestPlugin.test.ts +++ b/src/cli/bundler/plugins/manifest/ManifestPlugin.test.ts @@ -5,7 +5,11 @@ import path from "path"; import {rspack, type Compiler, type RspackPluginInstance, type Stats} from "@rspack/core"; import ManifestV3 from "@cli/builders/manifest/ManifestV3"; -import BuildAssetsMapPlugin from "@cli/bundler/plugins/build-assets-map"; +import BuildAssetsMapPlugin, { + createEntrypointModule, + EntrypointAssetsModule, +} from "@cli/bundler/plugins/build-assets-map"; +import {GenerateModulePlugin} from "../generate-module"; import {getCompilationBuildAssets} from "@cli/bundler/utils/output"; import {Browser} from "@typing/browser"; import type {EntrypointAssetsMap} from "@typing/entrypoint"; @@ -124,7 +128,9 @@ describe("ManifestPlugin compilation hooks", () => { devtool: false, output: {path: directory, filename: "[name].[contenthash:8].js"}, plugins: [ + new GenerateModulePlugin({[EntrypointAssetsModule.request]: createEntrypointModule()}), new BuildAssetsMapPlugin({ + module: EntrypointAssetsModule, fullMapEntrypoint: "background", cssFilename: "[name].css", cssChunkFilename: "[name].css", diff --git a/src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.test.ts b/src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.test.ts index eab0da33..c9d3b44f 100644 --- a/src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.test.ts +++ b/src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.test.ts @@ -9,7 +9,8 @@ import ManifestV3 from "@cli/builders/manifest/ManifestV3"; import {Browser} from "@typing/browser"; import type {ManifestVersion} from "@typing/manifest"; -import BuildAssetsMapPlugin from "../build-assets-map"; +import BuildAssetsMapPlugin, {createEntrypointModule, EntrypointAssetsModule} from "../build-assets-map"; +import {GenerateModulePlugin} from "../generate-module"; import ManifestPlugin from "../manifest"; import ResourceAccessPlugin, {type ResourceAccessPluginOptions} from "./ResourceAccessPlugin"; @@ -42,7 +43,9 @@ describe("ResourceAccessPlugin", () => { devtool: false, output: {path: directory, filename: "[name].js"}, plugins: [ + new GenerateModulePlugin({[EntrypointAssetsModule.request]: createEntrypointModule()}), new BuildAssetsMapPlugin({ + module: EntrypointAssetsModule, fullMapEntrypoint: "background", cssFilename: "[name].css", cssChunkFilename: "[name].css", diff --git a/src/cli/plugins/output.ts b/src/cli/plugins/output.ts index 493fca4a..b1ce3dc7 100644 --- a/src/cli/plugins/output.ts +++ b/src/cli/plugins/output.ts @@ -3,7 +3,13 @@ import {Configuration as RspackConfig} from "@rspack/core"; import {definePlugin} from "@main/plugin"; -import {appFilenameResolver, BuildAssetsMapPlugin} from "@cli/bundler"; +import { + appFilenameResolver, + BuildAssetsMapPlugin, + GenerateModulePlugin, + createEntrypointModule, + EntrypointAssetsModule, +} from "@cli/bundler"; import {getOutputPath, getResolvePath} from "@cli/resolvers/path"; import {Command} from "@typing/app"; @@ -46,7 +52,9 @@ export default definePlugin(() => { clean: config.command === Command.Build, }, plugins: [ + new GenerateModulePlugin({[EntrypointAssetsModule.request]: createEntrypointModule()}), new BuildAssetsMapPlugin({ + module: EntrypointAssetsModule, buildHashSalt, cssChunkFilename: cssOutputFilename, cssFilename: cssOutputFilename, diff --git a/src/main/entrypoint.test.ts b/src/main/entrypoint.test.ts deleted file mode 100644 index 561d4891..00000000 --- a/src/main/entrypoint.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {getEntrypointAssetsMap, getEntrypointAssets} from "./entrypoint"; - -import type {EntrypointAssetsMap, EntrypointAssets} from "@typing/entrypoint"; - -interface BuildAssetsGlobal { - __webpack_require__?: ((moduleId: string | number) => unknown) & { - __adnbnBuildAssets?: EntrypointAssetsMap; - __adnbnCurrentEntrypointAssets?: EntrypointAssets; - }; -} - -describe("getEntrypointAssetsMap", () => { - const target = globalThis as BuildAssetsGlobal; - const descriptor = Object.getOwnPropertyDescriptor(target, "__webpack_require__"); - - afterEach(() => { - if (descriptor) { - Object.defineProperty(target, "__webpack_require__", descriptor); - } else { - Reflect.deleteProperty(target, "__webpack_require__"); - } - }); - - test("returns the map owned by the current bundle runtime", () => { - const assets: EntrypointAssetsMap = { - content: { - initial: {js: ["content.js"], css: ["content.css"]}, - async: {js: ["lazy.js"], css: ["lazy.css"]}, - assets: ["image.png"], - }, - }; - - const runtime = () => undefined; - runtime.__adnbnBuildAssets = assets; - target.__webpack_require__ = runtime; - - expect(getEntrypointAssetsMap()).toBe(assets); - }); - - test("returns the current entrypoint assets from its own bundle runtime", () => { - const assets: EntrypointAssets = { - initial: {js: ["content.js"], css: ["content.css"]}, - async: {js: ["lazy.js"], css: ["lazy.css"]}, - }; - - const runtime = () => undefined; - runtime.__adnbnCurrentEntrypointAssets = assets; - target.__webpack_require__ = runtime; - - expect(getEntrypointAssets()).toBe(assets); - }); - - test("fails clearly when a local runtime calls the background-only getter", () => { - const runtime = () => undefined; - runtime.__adnbnCurrentEntrypointAssets = { - initial: {js: ["content.js"], css: []}, - async: {js: [], css: []}, - }; - target.__webpack_require__ = runtime; - - expect(() => getEntrypointAssetsMap()).toThrow( - "getEntrypointAssetsMap() is available only in the background entrypoint" - ); - }); - - test("fails clearly when the current-entrypoint map is unavailable", () => { - const runtime = () => undefined; - runtime.__adnbnBuildAssets = {}; - target.__webpack_require__ = runtime; - - expect(() => getEntrypointAssets()).toThrow("Current entrypoint assets are unavailable in this runtime"); - }); -}); diff --git a/src/main/entrypoint.ts b/src/main/entrypoint.ts index 8ec85886..8bde63cf 100644 --- a/src/main/entrypoint.ts +++ b/src/main/entrypoint.ts @@ -1,12 +1,6 @@ -import type {EntrypointAssets, EntrypointAssetsMap} from "@typing/entrypoint"; - -interface BuildAssetsRuntime { - (moduleId: string | number): unknown; - __adnbnBuildAssets?: EntrypointAssetsMap; - __adnbnCurrentEntrypointAssets?: EntrypointAssets; -} +import {readAssets, readAssetsMap} from "#adnbn/entrypoint"; -declare const __webpack_require__: BuildAssetsRuntime; +import type {EntrypointAssets, EntrypointAssetsMap} from "@typing/entrypoint"; export type { EntrypointAssets, @@ -16,7 +10,7 @@ export type { } from "@typing/entrypoint"; export const getEntrypointAssetsMap = (): EntrypointAssetsMap => { - const assets = typeof __webpack_require__ === "function" ? __webpack_require__.__adnbnBuildAssets : undefined; + const assets = readAssetsMap(); if (!assets) { throw new Error("getEntrypointAssetsMap() is available only in the background entrypoint"); @@ -26,8 +20,7 @@ export const getEntrypointAssetsMap = (): EntrypointAssetsMap => { }; export const getEntrypointAssets = (): EntrypointAssets => { - const assets = - typeof __webpack_require__ === "function" ? __webpack_require__.__adnbnCurrentEntrypointAssets : undefined; + const assets = readAssets(); if (!assets) { throw new Error("Current entrypoint assets are unavailable in this runtime"); diff --git a/src/types/entrypoint.ts b/src/types/entrypoint.ts index c079b2a5..340a5071 100644 --- a/src/types/entrypoint.ts +++ b/src/types/entrypoint.ts @@ -4,6 +4,9 @@ import {ManifestVersion} from "@typing/manifest"; export const EntrypointFileExtensions: ReadonlySet = new Set(["ts", "tsx", "js", "jsx", "vue", "svelte"]); +export const EntrypointAssetsRuntimeProperty = "__adnbnCurrentEntrypointAssets"; +export const EntrypointAssetsMapRuntimeProperty = "__adnbnBuildAssets"; + export enum EntrypointType { Background = "background", Command = "command", diff --git a/src/virtual/entrypoint.ts b/src/virtual/entrypoint.ts new file mode 100644 index 00000000..f02c9235 --- /dev/null +++ b/src/virtual/entrypoint.ts @@ -0,0 +1,7 @@ +import type {EntrypointAssets, EntrypointAssetsMap} from "@typing/entrypoint"; + +/** Package fallback; extension builds provide access to their own bundler runtime. */ +export const readAssets = (): EntrypointAssets | undefined => undefined; + +/** Package fallback; the complete map is supplied only by a consuming background runtime. */ +export const readAssetsMap = (): EntrypointAssetsMap | undefined => undefined; diff --git a/src/virtual/modules.d.ts b/src/virtual/modules.d.ts index 3415f976..ad38eea8 100644 --- a/src/virtual/modules.d.ts +++ b/src/virtual/modules.d.ts @@ -10,6 +10,11 @@ declare module "#adnbn/page" { export const aliases: typeof import("./page").aliases; } +declare module "#adnbn/entrypoint" { + export const readAssets: typeof import("./entrypoint").readAssets; + export const readAssetsMap: typeof import("./entrypoint").readAssetsMap; +} + declare module "#adnbn/relay" { export const options: typeof import("./relay").options; } diff --git a/tests/integration/build/locale/states/package-import.mjs b/tests/integration/build/locale/states/package-import.mjs index 4a5f2cca..87cc67c3 100644 --- a/tests/integration/build/locale/states/package-import.mjs +++ b/tests/integration/build/locale/states/package-import.mjs @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { getIcons, + getEntrypointAssets, + getEntrypointAssetsMap, getOffscreen, getOffscreens, getPages, @@ -16,6 +18,10 @@ assert.equal(typeof globalThis.chrome, "undefined"); assert.equal(Object.getPrototypeOf(DynamicLocale.prototype), AbstractLocale.prototype); assert.equal(typeof NativeLocale, "function"); assert.equal(getPages().size, 0); +assert.throws(() => getEntrypointAssets(), {message: "Current entrypoint assets are unavailable in this runtime"}); +assert.throws(() => getEntrypointAssetsMap(), { + message: "getEntrypointAssetsMap() is available only in the background entrypoint", +}); assert.throws(() => getRelay("missing", 1), {message: 'Failed to get relay "missing"'}); const errors = []; diff --git a/tests/integration/build/style-routing/src/background-unused.js b/tests/integration/build/style-routing/src/background-unused.js new file mode 100644 index 00000000..31986c4d --- /dev/null +++ b/tests/integration/build/style-routing/src/background-unused.js @@ -0,0 +1,3 @@ +import {getEntrypointAssetsMap} from "adnbn"; + +globalThis.backgroundReady = true; diff --git a/tests/integration/build/style-routing/src/background.js b/tests/integration/build/style-routing/src/background.js index 980b3ed3..c25babfd 100644 --- a/tests/integration/build/style-routing/src/background.js +++ b/tests/integration/build/style-routing/src/background.js @@ -1 +1,3 @@ -globalThis.assets = __webpack_require__.__adnbnBuildAssets; +import {getEntrypointAssetsMap} from "adnbn"; + +globalThis.assets = getEntrypointAssetsMap(); diff --git a/tests/integration/build/style-routing/style-routing.integration.test.ts b/tests/integration/build/style-routing/style-routing.integration.test.ts index fab9df5e..d72f5514 100644 --- a/tests/integration/build/style-routing/style-routing.integration.test.ts +++ b/tests/integration/build/style-routing/style-routing.integration.test.ts @@ -8,7 +8,11 @@ import assetPlugin from "@cli/plugins/asset"; import optimizationPlugin from "@cli/plugins/optimization"; import {merge} from "webpack-merge"; import IsolatedStylesPlugin from "@cli/bundler/plugins/isolated-styles"; -import BuildAssetsMapPlugin from "@cli/bundler/plugins/build-assets-map"; +import BuildAssetsMapPlugin, { + createEntrypointModule, + EntrypointAssetsModule, +} from "@cli/bundler/plugins/build-assets-map"; +import {GenerateModulePlugin} from "@cli/bundler/plugins/generate-module"; import {getCompilationBuildAssets} from "@cli/bundler/utils/output"; import {getContentLayer, isContentLayer} from "@cli/bundler/utils/layers"; import ManifestPlugin from "@cli/bundler/plugins/manifest"; @@ -23,9 +27,10 @@ test.each<{ routing: string; layer: string; isolationIssuerLayer?: RuleSetCondition; + withoutMap?: boolean; }>([ ...[ - {browser: Browser.Chrome, commonChunks: true}, + {browser: Browser.Chrome, commonChunks: true, withoutMap: true}, {browser: Browser.Chrome, commonChunks: false}, {browser: Browser.Chromium, commonChunks: true}, {browser: Browser.Edge, commonChunks: true}, @@ -60,7 +65,7 @@ test.each<{ }, ])( "production CSS routing preserves entry assets and CSS/WAR ($browser, commonChunks=$commonChunks, routing=$routing)", - async ({browser, commonChunks, layer, isolationIssuerLayer}) => { + async ({browser, commonChunks, layer, isolationIssuerLayer, withoutMap}) => { const root = path.resolve(__dirname, "../../../.."); const output = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-css-routing-")); const config = { @@ -95,7 +100,7 @@ test.each<{ mode: "production", devtool: false, entry: { - background: "./background.js", + background: withoutMap ? "./background-unused.js" : "./background.js", popup: "./entry.js", assets: "./assets.js", other: "./other-assets.js", @@ -104,6 +109,7 @@ test.each<{ }, output: {...assets.output, path: output, filename: "js/[name].[chunkhash:8].js", publicPath: ""}, resolveLoader: {modules: [path.join(root, "node_modules")]}, + resolve: {alias: {adnbn$: path.join(root, "dist/index.js")}}, module: {rules: [...styles.module!.rules!, ...assets.module!.rules!]}, optimization: { ...merge(optimization, styles).optimization, @@ -121,7 +127,9 @@ test.each<{ property: ContentScriptStylesRuntimeProperty, test: entry => entry === "content", }), + new GenerateModulePlugin({[EntrypointAssetsModule.request]: createEntrypointModule()}), new BuildAssetsMapPlugin({ + module: EntrypointAssetsModule, fullMapEntrypoint: "background", cssFilename: "css/[name].[contenthash:8].css", cssChunkFilename: "css/[name].[contenthash:8].css", @@ -145,6 +153,17 @@ test.each<{ }) ); const map = getCompilationBuildAssets(stats.compilation)!; + for (const asset of stats.compilation.getAssets().filter(asset => asset.name.endsWith(".js"))) { + const source = asset.source.source().toString(); + expect(source).not.toContain("__adnbnBuildAssetsCurrentMap__"); + if (withoutMap) expect(source).not.toContain("__adnbnBuildAssetsFullMap__"); + } + if (isolatedStyles) { + const source = map.content.initial.js + .map(file => stats.compilation.getAsset(file)!.source.source().toString()) + .join("\n"); + expect(source).toContain(ContentScriptStylesRuntimeProperty); + } expect(map.assets.assets).toEqual( expect.arrayContaining([ expect.stringMatching(/assets\/css-font\.[a-f0-9]+\.woff2$/), From 166b48df84bfa535f72318155d3b8ac6e2503f08 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:31:52 +0300 Subject: [PATCH 38/38] style: organize package fields and frame type imports --- package.json | 8 ++++---- src/entry/content/core/nodes/FrameNode.ts | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 955aa5d1..aa646901 100644 --- a/package.json +++ b/package.json @@ -36,16 +36,16 @@ "scripts", "types" ], - "sideEffects": false, - "imports": { - "#adnbn/*": "./dist/virtual/*.js" - }, "engines": { "node": ">=22.0.0" }, "bin": { "adnbn": "bin/adnbn.js" }, + "sideEffects": false, + "imports": { + "#adnbn/*": "./dist/virtual/*.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/src/entry/content/core/nodes/FrameNode.ts b/src/entry/content/core/nodes/FrameNode.ts index 7fa35f01..b3060149 100644 --- a/src/entry/content/core/nodes/FrameNode.ts +++ b/src/entry/content/core/nodes/FrameNode.ts @@ -1,3 +1,4 @@ +import type {ContentScriptIsolationFrameOptions, ContentScriptNode, ContentScriptStylesRuntime} from "@typing/content"; import {ContentScriptIsolation} from "@typing/content"; import {getPageUrl} from "@main/page"; @@ -5,8 +6,6 @@ import {getContentScriptStylesRuntime} from "./isolated-styles"; import {isContentScriptFrameNavigation} from "@shared/content"; -import type {ContentScriptStylesRuntime, ContentScriptIsolationFrameOptions, ContentScriptNode} from "@typing/content"; - /** Owns the child document, but never moves the content script's JavaScript into it. */ export default class FrameNode implements ContentScriptNode { private frame?: HTMLIFrameElement;