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..329f0609 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: @@ -64,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/.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/.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/.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/AGENTS.md b/AGENTS.md index 9d9e84c3..db2b074e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,116 @@ # Repository conventions -## TypeScript file naming +## Generated release files -- 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`. +- `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 + +- `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/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. +- 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 `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 `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. +- 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. +- `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 + +- 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 + +- 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. +- 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. +- 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. +- 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. + +## 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. +- 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`. +- 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 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`. +- 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/jest.config.ts b/jest.config.ts index 1c895eff..0aff96b1 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -3,16 +3,22 @@ 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", moduleNameMapper: { + "^#adnbn/(.*)$": "/src/virtual/$1.ts", "^@cli/(.*)$": "/src/cli/$1", "^@entry/(.*)$": "/src/entry/$1", "^@frame/(.*)$": "/src/frame/$1", "^@locale/(.*)$": "/src/locale/$1", "^@offscreen/(.*)$": "/src/offscreen/$1", "^@message/(.*)$": "/src/message/$1", + "^@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", @@ -20,6 +26,7 @@ const config: Config = { }, extensionsToTreatAsEsm: [".ts", ".tsx"], transform: { + "^.+\\.template\\.js$": "/tests/raw-module-transformer.cjs", "^.+\\.(t|j)sx?$": [ "@swc/jest", { diff --git a/package-lock.json b/package-lock.json index 2ffffd50..3382b065 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", @@ -34,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", @@ -80,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", @@ -96,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": { @@ -107,6 +111,12 @@ }, "@types/react-dom": { "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true } } }, @@ -16823,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": { @@ -17516,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 acb1ad56..aa646901 100644 --- a/package.json +++ b/package.json @@ -36,13 +36,16 @@ "scripts", "types" ], - "sideEffects": false, "engines": { "node": ">=22.0.0" }, "bin": { "adnbn": "bin/adnbn.js" }, + "sideEffects": false, + "imports": { + "#adnbn/*": "./dist/virtual/*.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -52,6 +55,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" @@ -105,17 +112,21 @@ "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", + "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", + "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", @@ -133,6 +144,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", @@ -151,6 +163,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", @@ -194,6 +207,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", @@ -207,10 +222,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": { @@ -218,6 +233,12 @@ }, "@types/react-dom": { "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true } }, "overrides": { 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/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/builders/manifest/ManifestBase.ts b/src/cli/builders/manifest/ManifestBase.ts index d5c30c32..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 { @@ -814,7 +816,8 @@ 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?.assets; if (assets && assets.size > 0) { resources.push({ @@ -824,8 +827,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 07f8a0bc..a2819d8a 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), @@ -100,6 +101,33 @@ describe("ManifestV3", () => { }); }); + 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"], ["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/*"]}, + {entry: "normal", matches: ["https://example.com/*"]}, + ]) + ) + .build(); + + expect(manifest.content_scripts).toEqual( + expect.arrayContaining([ + 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"]) + ); + }); + it("builds permissions separately from host permissions", () => { const manifest: any = new ManifestV3(Browser.Chrome) .setPermissions(new Set(["storage"])) @@ -157,6 +185,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( @@ -167,23 +321,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/GenerateJsonPlugin.test.ts b/src/cli/bundler/plugins/GenerateJsonPlugin.test.ts deleted file mode 100644 index 0c2903f5..00000000 --- a/src/cli/bundler/plugins/GenerateJsonPlugin.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {rspack, Compiler} from "@rspack/core"; -import GenerateJsonPlugin from "./GenerateJsonPlugin"; - -describe("GenerateJsonPlugin watch updates", () => { - let compiler: Compiler; - - afterEach(async () => { - await new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); - }); - - test("propagates validation failures to the compiler and allows a later valid update", async () => { - const error = new Error('Locale "fr" is missing plural key "cart.items" required by default locale "en"'); - const update = jest.fn(async () => ({"messages.json": {title: "Updated"}})); - update.mockRejectedValueOnce(error); - compiler = rspack({ - mode: "none", - entry: {}, - plugins: [new GenerateJsonPlugin({}).watch(update)], - }); - - await expect(compiler.hooks.watchRun.promise(compiler)).rejects.toThrow(error); - await expect(compiler.hooks.watchRun.promise(compiler)).resolves.toBeUndefined(); - expect(update).toHaveBeenCalledTimes(2); - }); - - test("allows watch builds without an update callback", async () => { - compiler = rspack({mode: "none", entry: {}, plugins: [new GenerateJsonPlugin({})]}); - - await expect(compiler.hooks.watchRun.promise(compiler)).resolves.toBeUndefined(); - }); -}); diff --git a/src/cli/bundler/plugins/GenerateJsonPlugin.ts b/src/cli/bundler/plugins/GenerateJsonPlugin.ts deleted file mode 100644 index 890de2d5..00000000 --- a/src/cli/bundler/plugins/GenerateJsonPlugin.ts +++ /dev/null @@ -1,56 +0,0 @@ -import {Compilation, Compiler, sources} from "@rspack/core"; - -type JsonValue = string | number | boolean | null | JsonObject | JsonArray; - -interface JsonObject { - [key: string]: JsonValue; -} - -type JsonArray = JsonValue[]; - -export type GenerateJsonPluginData = Record; - -export type GenerateJsonPluginUpdate = () => Promise; - -export default class GenerateJsonPlugin { - private readonly pluginName: string = "GenerateJsonPlugin"; - - private update?: GenerateJsonPluginUpdate; - - constructor(protected data: GenerateJsonPluginData) {} - - public apply(compiler: Compiler): void { - compiler.hooks.watchRun.tapPromise(this.pluginName, async () => { - const update = this.update; - - if (update) { - // Let the compiler fail this rebuild rather than emit stale JSON. - this.data = await update(); - } - }); - - compiler.hooks.compilation.tap(this.pluginName, (compilation: Compilation) => { - compilation.hooks.processAssets.tap( - { - name: this.pluginName, - stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, - }, - () => this.generateFiles(compilation) - ); - }); - } - - public watch(update: GenerateJsonPluginUpdate): this { - this.update = update; - - return this; - } - - protected generateFiles(compilation: Compilation): void { - Object.entries(this.data).forEach(([filename, jsonData]) => { - const json = JSON.stringify(jsonData, null, 2); - - compilation.emitAsset(filename, new sources.RawSource(json)); - }); - } -} diff --git a/src/cli/bundler/plugins/ManifestPlugin.ts b/src/cli/bundler/plugins/ManifestPlugin.ts deleted file mode 100644 index b15cedd9..00000000 --- a/src/cli/bundler/plugins/ManifestPlugin.ts +++ /dev/null @@ -1,92 +0,0 @@ -import rspack, {Chunk, Compilation, Compiler} from "@rspack/core"; - -import {toPosix} from "@cli/utils/path"; - -import {ManifestBuilder, ManifestDependencies, ManifestDependency} from "@typing/manifest"; - -class ManifestPlugin { - constructor(private readonly manifest: ManifestBuilder) {} - - apply(compiler: Compiler): void { - compiler.hooks.compilation.tap("ManifestPlugin", compilation => { - compilation.hooks.processAssets.tap( - { - name: "ManifestPlugin", - stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, - }, - () => { - 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 || []; - - auxiliaryFiles.forEach((fileName: string) => { - fileName = toPosix(fileName); - - 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 json = JSON.stringify(manifest, null, 2); - - compilation.emitAsset("manifest.json", new rspack.sources.RawSource(json)); - } - ); - }); - } - - 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/WatchPlugin.ts b/src/cli/bundler/plugins/WatchPlugin.ts deleted file mode 100644 index d1f3a0fa..00000000 --- a/src/cli/bundler/plugins/WatchPlugin.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {Compiler} from "@rspack/core"; - -export type WatchPluginCallback = (files: ReadonlySet) => Promise; - -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); - } - }); - } -} diff --git a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts new file mode 100644 index 00000000..5cf5388c --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts @@ -0,0 +1,1097 @@ +/** @jest-environment node */ + +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"; + +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"; +import {GenerateModulePlugin} from "../generate-module"; +import {createEntrypointModule, EntrypointAssetsModule} from "./entrypoint-module"; + +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"); +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" | "unused"; + noConsumers?: boolean; + usage?: boolean; + mode?: "production" | "development"; + 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; + sharedCssChunk?: 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.entry.js" + : options.background === "shared" + ? "./background-shared.entry.js" + : options.background === "unused" || options.noConsumers + ? "./background-unused.entry.js" + : "./background.entry.js"; + const compiler = rspack({ + context: fixtures, + mode: options.mode ?? "production", + target: ["web", "es2020"], + devtool: "source-map", + entry: { + ...(options.includeBackground === false ? {} : {background}), + 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, + 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: { + usedExports: true, + 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, + }, + ...(options.usage + ? { + fixtureReader: { + test: /[\\/]shared-reader\.js$/, + name: "shared-reader", + chunks: "all", + minChunks: 2, + enforce: true, + }, + } + : {}), + ...(options.sharedCssChunk + ? { + fixtureSharedStyles: { + chunks: "all", + enforce: true, + minChunks: 2, + name: "shared-styles", + test: /[\\/]shared\.css$/, + type: "css/mini-extract", + }, + } + : {}), + }, + } + : 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.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})] + : []), + ...(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, + 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); +}; + +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", () => { + 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 => { + 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); + }); + + 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("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"}), + 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'); + }); + + 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 new file mode 100644 index 00000000..e1054275 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts @@ -0,0 +1,722 @@ +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"; + +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__"; +// 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; +} + +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; + readonly fullMapEntrypoint: string; +} + +type RuntimeModuleFingerprints = Map>; + +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 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"); + }); +}; + +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 + .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 => { + return resolveChunkFilename( + compilation, + chunk, + chunk === entryChunk ? cssFilename : cssChunkFilename, + CssContentHashType + ); + }), + }, + async: { + js: asyncChunks + .filter(chunk => chunkHasJavaScript(compilation, chunk)) + .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, + runtimes: ReadonlySet +): FullMapRuntime | undefined => { + const entrypoint = compilation.entrypoints.get(name); + + if (!entrypoint || !runtimes.has(entrypoint.getRuntimeChunk())) { + 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 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` + ); + } +}; + +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, + 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; + }); + + 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 property: 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}[${JSON.stringify(this.property)}] = 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}[${JSON.stringify(this.property)}] = 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, + runtimes: ReadonlySet +): 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`); + } + + 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)) + .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: ${JSON.stringify({embedded: runtimeAssets, finalized: finalizedAssets})}` + ); + } + } +}; + +const finalizeBuildAssets = ( + compilation: Compilation, + options: Required, + runtimes: ReadonlySet +): void => { + const assets = collectBuildAssets(compilation); + const fullMap = getFullMapRuntime(compilation, options.fullMapEntrypoint, runtimes); + + if (fullMap) { + validateFullMapAssets(assets, options.fullMapEntrypoint); + validateEmbeddedMap(compilation, fullMap, assets); + } + + validateCurrentEntrypointMaps(compilation, assets, options.fullMapEntrypoint, runtimes); + + 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 { + 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 selectedRuntimes: Map | undefined; + const runtimeModuleFingerprints = collectRuntimeModuleFingerprints(compilation); + 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) { + validateAsyncChunkFilenames(compilation, this.options.cssChunkFilename); + filenamesValidated = true; + } + + const selection = selectedRuntimes.get(chunk); + if (!selection || injectedRuntimes.has(chunk)) return; + + requirements.add(RuntimeGlobals.require); + injectedRuntimes.add(chunk); + compilation.addRuntimeModule( + chunk, + new BuildAssetsRuntimeModule( + selection.fullMap, + selection.name, + selection.property, + this.options.cssFilename, + this.options.cssChunkFilename, + entrypointRuntimeUsesFullHash( + compilation, + selection.name, + 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, injectedRuntimes); + setCompilationBuildAssets(compilation, assets); + + if (fullMap) { + validateFullMapAssets(assets, this.options.fullMapEntrypoint); + replaceFullMapPlaceholder(compilation, getFullMapRuntimeFiles(fullMap, assets), assets); + } + } + ); + + compilation.hooks.processAssets.tap( + { + name: `${PluginName}:finalize`, + stage: Compilation.PROCESS_ASSETS_STAGE_ANALYSE, + }, + () => finalizeBuildAssets(compilation, this.options, injectedRuntimes) + ); + }); + + // 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 => { + 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 new file mode 100644 index 00000000..ef939139 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/index.ts @@ -0,0 +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/alpha.css b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.css new file mode 100644 index 00000000..987c0998 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/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/build-assets-map/tests/fixtures/alpha.entry.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.entry.js new file mode 100644 index 00000000..d1853d23 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.entry.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/build-assets-map/tests/fixtures/alpha.lazy.css b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.lazy.css new file mode 100644 index 00000000..29712401 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.lazy.css @@ -0,0 +1,3 @@ +.alpha-lazy { + background-image: url("./lazy-image.svg"); +} diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.lazy.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.lazy.js new file mode 100644 index 00000000..e63dc6eb --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/alpha.lazy.js @@ -0,0 +1,3 @@ +import "./alpha.lazy.css"; + +export const alphaLazy = true; diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-async.entry.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-async.entry.js new file mode 100644 index 00000000..bc88f73c --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-async.entry.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/build-assets-map/tests/fixtures/background-shared.entry.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-shared.entry.js new file mode 100644 index 00000000..098692ce --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background-shared.entry.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/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/background.entry.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background.entry.js new file mode 100644 index 00000000..093189e8 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/background.entry.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/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/beta.changed.css b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.changed.css new file mode 100644 index 00000000..ceab525c --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.changed.css @@ -0,0 +1,3 @@ +.beta { + color: purple; +} diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.css b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.css new file mode 100644 index 00000000..776d9b35 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.css @@ -0,0 +1,3 @@ +.beta { + color: blue; +} diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.entry.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.entry.js new file mode 100644 index 00000000..37e02b1f --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.entry.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/build-assets-map/tests/fixtures/beta.lazy.css b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.lazy.css new file mode 100644 index 00000000..b8ed4764 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.lazy.css @@ -0,0 +1,3 @@ +.beta-lazy { + color: green; +} diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.lazy.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.lazy.js new file mode 100644 index 00000000..dfe8ba84 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/beta.lazy.js @@ -0,0 +1,3 @@ +import "./beta.lazy.css"; + +export const betaLazy = true; diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/font.woff2 b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/font.woff2 new file mode 100644 index 00000000..784e043d --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/font.woff2 @@ -0,0 +1 @@ +build-assets-fixture-font diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/lazy-image.svg b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/lazy-image.svg new file mode 100644 index 00000000..40fedc61 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/lazy-image.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/cli/bundler/plugins/build-assets-map/tests/fixtures/payload.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/payload.js new file mode 100644 index 00000000..55568142 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/payload.js @@ -0,0 +1 @@ +globalThis.__buildAssetsPayloadExecuted = true; 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/build-assets-map/tests/fixtures/shared.changed.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/shared.changed.js new file mode 100644 index 00000000..7840096c --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/shared.changed.js @@ -0,0 +1 @@ +export const shared = "changed shared fixture"; 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/build-assets-map/tests/fixtures/shared.js b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/shared.js new file mode 100644 index 00000000..1e743448 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/tests/fixtures/shared.js @@ -0,0 +1 @@ +export const shared = "shared fixture"; 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/chunk-loader/ChunkLoaderPlugin.test.ts b/src/cli/bundler/plugins/chunk-loader/ChunkLoaderPlugin.test.ts new file mode 100644 index 00000000..6a6aeafb --- /dev/null +++ b/src/cli/bundler/plugins/chunk-loader/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"); + +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/chunk-loader/ChunkLoaderPlugin.ts b/src/cli/bundler/plugins/chunk-loader/ChunkLoaderPlugin.ts new file mode 100644 index 00000000..7814a7a8 --- /dev/null +++ b/src/cli/bundler/plugins/chunk-loader/ChunkLoaderPlugin.ts @@ -0,0 +1,54 @@ +import {type Chunk, type Compiler, RuntimeGlobals, RuntimeModule} from "@rspack/core"; + +import {renderChunkLoaderRuntime} from "./templates"; + +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 { + return renderChunkLoaderRuntime({ + ensureChunk: RuntimeGlobals.ensureChunk, + loadScript: RuntimeGlobals.loadScript, + publicPath: RuntimeGlobals.publicPath, + }); + } +} + +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/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..a2bab527 --- /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/plugins/utils"; + +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/chunk-loader/tests/fixtures/entry.js b/src/cli/bundler/plugins/chunk-loader/tests/fixtures/entry.js new file mode 100644 index 00000000..12a555d2 --- /dev/null +++ b/src/cli/bundler/plugins/chunk-loader/tests/fixtures/entry.js @@ -0,0 +1 @@ +export const load = () => import("./lazy.js"); diff --git a/src/cli/bundler/plugins/chunk-loader/tests/fixtures/lazy.js b/src/cli/bundler/plugins/chunk-loader/tests/fixtures/lazy.js new file mode 100644 index 00000000..24e54566 --- /dev/null +++ b/src/cli/bundler/plugins/chunk-loader/tests/fixtures/lazy.js @@ -0,0 +1 @@ +export const value = "lazy"; diff --git a/src/cli/bundler/plugins/chunk-loader/tests/fixtures/unselected.js b/src/cli/bundler/plugins/chunk-loader/tests/fixtures/unselected.js new file mode 100644 index 00000000..4775ee3d --- /dev/null +++ b/src/cli/bundler/plugins/chunk-loader/tests/fixtures/unselected.js @@ -0,0 +1 @@ +export const load = () => import("./unselected.lazy.js"); diff --git a/src/cli/bundler/plugins/chunk-loader/tests/fixtures/unselected.lazy.js b/src/cli/bundler/plugins/chunk-loader/tests/fixtures/unselected.lazy.js new file mode 100644 index 00000000..b62bc73e --- /dev/null +++ b/src/cli/bundler/plugins/chunk-loader/tests/fixtures/unselected.lazy.js @@ -0,0 +1 @@ +export const value = "view-lazy"; diff --git a/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.test.ts b/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.test.ts new file mode 100644 index 00000000..115d38c2 --- /dev/null +++ b/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.test.ts @@ -0,0 +1,140 @@ +/** @jest-environment node */ + +import path from "path"; + +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.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([ + [ + "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", path.join("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/entrypoint/EntrypointPlugin.ts similarity index 67% rename from src/cli/bundler/plugins/EntrypointPlugin.ts rename to src/cli/bundler/plugins/entrypoint/EntrypointPlugin.ts index 1552261e..396927ee 100644 --- a/src/cli/bundler/plugins/EntrypointPlugin.ts +++ b/src/cli/bundler/plugins/entrypoint/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), }; }); @@ -126,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; @@ -149,35 +158,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); + }); + + this.modules.keys().forEach(name => { + if (!updatedModules.has(name)) { + delete compiler.options.entry[name]; + } + }); - updatedModules.entries().forEach(([name, modules]) => { - let entry: string[] = structuredClone(compiler.options.entry[name]); + updatedModules.entries().forEach(([name, modules]) => { + const current = structuredClone(compiler.options.entry[name] ?? []); + let entry = current; + let currentOptions: EntrypointPluginEntryOptions = {}; - if ("import" in entry) { - entry = entry.import as string[]; - } + if ("import" in entry) { + currentOptions = entry; + entry = entry.import as string[]; + } - entry = entry.filter(file => !removedContents.has(file)); + entry = entry.filter(file => !removedContents.has(file)); - entry.push(...Array.from(modules.values(), ({name}) => name)); + entry.push(...Array.from(modules.values(), ({name}) => name)); - compiler.options.entry[name] = _.uniq(entry); - }); - } + 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/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/generate-json/GenerateJsonPlugin.test.ts b/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.test.ts new file mode 100644 index 00000000..d8d5c5f6 --- /dev/null +++ b/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.test.ts @@ -0,0 +1,91 @@ +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 = () => + 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("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: output}, + optimization: {emitOnErrors: false}, + plugins: [new GenerateJsonPlugin({}).watch(update)], + }); + const filesystem = createFsFromVolume(new Volume()); + compiler.outputFileSystem = filesystem as Compiler["outputFileSystem"]; + + 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(path.join(output, "messages.json"))).toBe(false); + + await compiler.hooks.watchRun.promise(compiler); + expect((await run()).hasErrors()).toBe(false); + expect(JSON.parse(filesystem.readFileSync(path.join(output, "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: output, 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"]; + 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(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(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 () => { + compiler = rspack({mode: "none", entry: {}, plugins: [new GenerateJsonPlugin({})]}); + + await expect(compiler.hooks.watchRun.promise(compiler)).resolves.toBeUndefined(); + }); +}); diff --git a/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.ts b/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.ts new file mode 100644 index 00000000..c5de4353 --- /dev/null +++ b/src/cli/bundler/plugins/generate-json/GenerateJsonPlugin.ts @@ -0,0 +1,98 @@ +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; + +interface JsonObject { + [key: string]: JsonValue; +} + +type JsonArray = JsonValue[]; + +export type GenerateJsonPluginData = Record; + +export type GenerateJsonPluginUpdate = () => Promise; + +export default class GenerateJsonPlugin { + private readonly pluginName: string = "GenerateJsonPlugin"; + + private update?: GenerateJsonPluginUpdate; + + constructor(protected data: GenerateJsonPluginData) {} + + public apply(compiler: Compiler): void { + let emitted = new Set(); + + watchCompilation(compiler, this.pluginName, async () => { + const update = this.update; + + if (update) { + this.data = await update(); + } + }); + + compiler.hooks.compilation.tap(this.pluginName, (compilation: Compilation) => { + compilation.hooks.processAssets.tap( + { + name: this.pluginName, + stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + }, + () => 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 { + this.update = update; + + return this; + } + + protected generateFiles(compilation: Compilation): void { + Object.entries(this.data).forEach(([filename, jsonData]) => { + const json = JSON.stringify(jsonData, null, 2); + + 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-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/generate-module/GenerateModulePlugin.test.ts b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts new file mode 100644 index 00000000..778a29ec --- /dev/null +++ b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.test.ts @@ -0,0 +1,230 @@ +/** @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 "./index"; + +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); +}); + +test("updates generated sources and recovers from 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); + + value = 7; + await compiler.hooks.watchRun.promise(compiler); + expect(await run(compiler)).toBe(14); + + fail = true; + await compiler.hooks.watchRun.promise(compiler); + await expect(run(compiler)).rejects.toThrow("Cannot generate module"); + fail = false; + value = 9; + await compiler.hooks.watchRun.promise(compiler); + 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("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")).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("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"]}, + {sharing: "filtered", layers: ["shared-data", "main"]}, +])("shares generated modules across the selected issuer layers: $sharing", async ({sharing, layers}) => { + const plugin = new GenerateModulePlugin(modules); + if (sharing === "all") plugin.layer("shared-data"); + if (sharing === "filtered") plugin.layer("shared-data", {not: ["main"]}); + 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(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"]) { + 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..7b68c370 --- /dev/null +++ b/src/cli/bundler/plugins/generate-module/GenerateModulePlugin.ts @@ -0,0 +1,110 @@ +import {existsSync} from "fs"; +import path from "path"; +import {experiments, type Compiler, type RuleSetCondition} from "@rspack/core"; +import {watchCompilation} from "../utils"; + +/** 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 interface GenerateModulePluginDependencies { + files?: Iterable; + directories?: Iterable; +} + +export type GenerateModulePluginDependenciesResolver = () => Promise; + +export default class GenerateModulePlugin { + private readonly pluginName = "GenerateModulePlugin"; + private update?: GenerateModulePluginUpdate; + private dependencies?: GenerateModulePluginDependenciesResolver; + private moduleLayer?: string; + private issuerLayer?: RuleSetCondition; + + constructor(private readonly modules: GenerateModulePluginModules) {} + + /** 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; + } + + public watch(update: GenerateModulePluginUpdate, dependencies?: GenerateModulePluginDependenciesResolver): this { + this.update = update; + this.dependencies = dependencies; + return this; + } + + public apply(compiler: Compiler): void { + const modules = {...this.modules}; + let dependencies: GenerateModulePluginDependencies = {}; + + // 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) { + compiler.options.module.rules.push({ + 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)); + } + + for (const directory of dependencies.directories ?? []) { + const absolute = path.resolve(compiler.context, directory); + const target = existsSync(absolute) ? compilation.contextDependencies : compilation.missingDependencies; + target.add(absolute); + } + }); + + watchCompilation(compiler, this.pluginName, async () => { + dependencies = (await this.dependencies?.()) ?? {}; + if (!this.update) return; + + for (const [name, source] of Object.entries(await this.update())) { + if (modules[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. 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..3359d8aa --- /dev/null +++ b/src/cli/bundler/plugins/generate-module/index.ts @@ -0,0 +1,7 @@ +export { + default as GenerateModulePlugin, + type GenerateModulePluginDependencies, + type GenerateModulePluginDependenciesResolver, + 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 20072af0..aa6757bb 100644 --- a/src/cli/bundler/plugins/index.ts +++ b/src/cli/bundler/plugins/index.ts @@ -1,20 +1,55 @@ export { default as EntrypointPlugin, + type EntrypointPluginEntryOptions, + type EntrypointPluginEntryOptionsResolver, type EntrypointPluginModules, type EntrypointPluginModule, type EntrypointPluginEntryModules, type EntrypointPluginTemplate, type EntrypointPluginUpdate, -} from "./EntrypointPlugin"; +} from "./entrypoint"; + +export {default as ChunkLoaderPlugin, type ChunkLoaderPluginOptions} from "./chunk-loader"; + +export { + default as IsolatedStylesPlugin, + type IsolatedStylesPluginOptions, + type IsolatedStylesPluginFiles, +} from "./isolated-styles"; + +export { + default as BuildAssetsMapPlugin, + type BuildAssetsMapPluginOptions, + type BuildAssetsMapPluginModuleOptions, + type BuildAssetsMapPluginExportOptions, + createEntrypointModule, + EntrypointAssetsModule, +} from "./build-assets-map"; export { default as GenerateJsonPlugin, type GenerateJsonPluginData, type GenerateJsonPluginUpdate, -} from "./GenerateJsonPlugin"; +} from "./generate-json"; + +export { + GenerateModulePlugin, + type GenerateModulePluginDependencies, + type GenerateModulePluginDependenciesResolver, + type GenerateModulePluginModules, + type GenerateModulePluginUpdate, +} from "./generate-module"; + +export {default as ManifestPlugin} from "./manifest"; + +export { + default as ResourceAccessPlugin, + type ResourceAccessPluginOptions, + type ResourceAccessPluginRequirement, +} from "./resource-access"; -export {default as ManifestPlugin} from "./ManifestPlugin"; +export {default as ReplacePlugin} from "./replace"; -export {default as ReplacePlugin} from "./ReplacePlugin"; +export {default as WatchPlugin} from "./watch"; -export {default as WatchPlugin} from "./WatchPlugin"; +export type {RuntimePropertyOptions} from "./types"; 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/isolated-styles/templates.ts b/src/cli/bundler/plugins/isolated-styles/templates.ts new file mode 100644 index 00000000..9371db74 --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/templates.ts @@ -0,0 +1,43 @@ +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 IsolatedStylesRuntimeTemplateOptions extends RuntimePropertyOptions { + readonly entry: string; + readonly require: string; + readonly timeout: number; + readonly initialStyles: readonly string[]; +} + +interface IsolatedStylesCssLoaderTemplateOptions extends RuntimePropertyOptions { + readonly isolatedChunks: Readonly>; + readonly originalLoader: string; + readonly cssFilenameExpression: string; + readonly originalRuntime: string; + readonly publicPath: string; + readonly require: string; +} + +export const renderIsolatedStylesRuntime = (options: IsolatedStylesRuntimeTemplateOptions): string => { + return renderRuntimeTemplate(runtimeTemplate, { + __ADNBN_ENTRY__: JSON.stringify(options.entry), + __ADNBN_REQUIRE__: options.require, + __ADNBN_PROPERTY__: JSON.stringify(options.property), + __ADNBN_TIMEOUT__: JSON.stringify(options.timeout), + __ADNBN_INITIAL_STYLES__: JSON.stringify(options.initialStyles), + }); +}; + +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_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/isolated-styles/tests/fixtures/normal.css b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal.css new file mode 100644 index 00000000..76097eb4 --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal.css @@ -0,0 +1,3 @@ +.normal-initial { + color: green; +} diff --git a/src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal.js b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal.js new file mode 100644 index 00000000..dec49e90 --- /dev/null +++ b/src/cli/bundler/plugins/isolated-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/isolated-styles/tests/fixtures/normal.lazy.css b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal.lazy.css new file mode 100644 index 00000000..cdcb0a8c --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal.lazy.css @@ -0,0 +1,3 @@ +.normal-lazy { + color: orange; +} diff --git a/src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal.lazy.js b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/normal.lazy.js new file mode 100644 index 00000000..9d5b1a3f --- /dev/null +++ b/src/cli/bundler/plugins/isolated-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/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/isolated-styles/tests/fixtures/shadow.css b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.css new file mode 100644 index 00000000..a3c87121 --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.css @@ -0,0 +1,3 @@ +.shadow-initial { + color: navy; +} 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/isolated-styles/tests/fixtures/shadow.lazy-a.css b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.lazy-a.css new file mode 100644 index 00000000..16e02580 --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.lazy-a.css @@ -0,0 +1,3 @@ +.shadow-lazy-a { + color: purple; +} diff --git a/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.lazy-b.css b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.lazy-b.css new file mode 100644 index 00000000..38e49c58 --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.lazy-b.css @@ -0,0 +1,3 @@ +.shadow-lazy-b { + background: white; +} diff --git a/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.lazy.js b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/shadow.lazy.js new file mode 100644 index 00000000..84216c79 --- /dev/null +++ b/src/cli/bundler/plugins/isolated-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/plugins/manifest/ManifestPlugin.test.ts b/src/cli/bundler/plugins/manifest/ManifestPlugin.test.ts new file mode 100644 index 00000000..763a4a18 --- /dev/null +++ b/src/cli/bundler/plugins/manifest/ManifestPlugin.test.ts @@ -0,0 +1,230 @@ +/** @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, { + 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"; + +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", () => { + 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"); + }); + + 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 GenerateModulePlugin({[EntrypointAssetsModule.request]: createEntrypointModule()}), + new BuildAssetsMapPlugin({ + module: EntrypointAssetsModule, + 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 new file mode 100644 index 00000000..3417d5b7 --- /dev/null +++ b/src/cli/bundler/plugins/manifest/ManifestPlugin.ts @@ -0,0 +1,65 @@ +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"; + +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 | (() => ManifestBuilder)) {} + + apply(compiler: Compiler): void { + compiler.hooks.compilation.tap("ManifestPlugin", compilation => { + const hooks = getManifestHooks(compilation); + + compilation.hooks.processAssets.tapPromise( + { + name: "ManifestPlugin", + stage: Compilation.PROCESS_ASSETS_STAGE_REPORT, + }, + async () => { + try { + const buildAssets = getCompilationBuildAssets(compilation); + + if (!buildAssets) { + throw new Error("Build assets are unavailable before manifest generation"); + } + + const dependencies = createManifestDependencies(buildAssets); + await hooks.prepareDependencies.promise(dependencies, buildAssets); + + const builder = typeof this.manifest === "function" ? this.manifest() : this.manifest; + const manifest = builder.setDependencies(dependencies).get(); + await hooks.validate.promise(manifest); + + 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))); + } + } + ); + }); + } +} + +export default ManifestPlugin; 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/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/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/resource-access/ResourceAccessPlugin.test.ts b/src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.test.ts new file mode 100644 index 00000000..c9d3b44f --- /dev/null +++ b/src/cli/bundler/plugins/resource-access/ResourceAccessPlugin.test.ts @@ -0,0 +1,111 @@ +/** @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, {createEntrypointModule, EntrypointAssetsModule} from "../build-assets-map"; +import {GenerateModulePlugin} from "../generate-module"; +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 GenerateModulePlugin({[EntrypointAssetsModule.request]: createEntrypointModule()}), + new BuildAssetsMapPlugin({ + module: EntrypointAssetsModule, + 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/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/index.ts b/src/cli/bundler/plugins/utils/index.ts new file mode 100644 index 00000000..36d91164 --- /dev/null +++ b/src/cli/bundler/plugins/utils/index.ts @@ -0,0 +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/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/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 new file mode 100644 index 00000000..a3db5fc9 --- /dev/null +++ b/src/cli/bundler/plugins/utils/runtime-template.ts @@ -0,0 +1,19 @@ +const PlaceholderPattern = /__ADNBN_[A-Z0-9_]+__/g; + +export const renderRuntimeTemplate = (template: string, values: Readonly>): string => { + for (const placeholder of Object.keys(values)) { + if (!template.includes(placeholder)) { + throw new Error(`Runtime template placeholder "${placeholder}" is unavailable`); + } + } + + // 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/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 new file mode 100644 index 00000000..d5b92d21 --- /dev/null +++ b/src/cli/bundler/plugins/watch/WatchPlugin.ts @@ -0,0 +1,14 @@ +import {Compiler} from "@rspack/core"; +import {watchCompilation} from "../utils"; + +export type WatchPluginCallback = (files: ReadonlySet) => Promise; + +export default class WatchPlugin { + constructor(private readonly callback: WatchPluginCallback) {} + + public apply(compiler: Compiler): void { + watchCompilation(compiler, "WatchPlugin", async () => { + await this.callback(compiler.modifiedFiles ?? new Set()); + }); + } +} 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/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/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.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..2fce1c65 100644 --- a/src/cli/bundler/utils/output.ts +++ b/src/cli/bundler/utils/output.ts @@ -1,20 +1,249 @@ -import {Filename} from "@rspack/core"; +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, EntrypointAssetsFiles} 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(); + 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"; + }) + .map(([file]) => file) + .toSorted(); +}; + +const collectCodeFiles = ( + compilation: Compilation, + files: Iterable, + sourceMaps: ReadonlySet +): EntrypointAssetsFiles => { + 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)}; +}; + +const collectAsyncCodeFiles = ( + compilation: Compilation, + chunks: Iterable, + sourceMaps: ReadonlySet, + initialFiles: ReadonlySet +): EntrypointAssetsFiles => { + 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); +}; - return name; +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/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/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 755b022e..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,59 +30,60 @@ export default class> extends SourceFile { } public getOptions(): T { - return {...this.getOptionsFromVariables(), ...this.getOptionsFromDefinition()}; - } - - public getDefinition(): string | undefined { - if (this.definition === undefined) { - this.getOptionsFromDefinition(); + 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]); } - - if (this.definition === null) { - return; - } - - return this.definition; + return Object.fromEntries(options) as T; } - 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; + /** Presence does not require evaluating the property's value (for example a render method). */ + public getDeclaredProperties(): Set { + return new Set(this.getOptionBindings().keys()); } - 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); - }; + public getDefinition(): string | undefined { + if (this.definition === undefined) this.getOptionBindings(); + return this.definition ?? undefined; + } - parse(this.getSourceFile()); + 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}` + ); + } - 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; @@ -92,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 55780ef1..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,125 +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) { - // 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}`); + } + 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}`); + } + 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; } - default: - return undefined; + return success(values); } + 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 15e664f5..3dfce35e 100644 --- a/src/cli/entrypoint/file/injectors/core.ts +++ b/src/cli/entrypoint/file/injectors/core.ts @@ -1,7 +1,14 @@ 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 { + ContentScriptIsolation, + ContentScriptShadowMode, + 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 +72,23 @@ export default (): Injector[] => { }); }); + Object.entries(ContentScriptIsolation).forEach(([key, value]) => { + 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, + target: "ContentScriptWorld", + name: key, + value, + }); + }); + Object.entries(CspSource).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/finder/AbstractEntrypointFinder.ts b/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts index b5c6eca0..c9574629 100644 --- a/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts +++ b/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts @@ -18,9 +18,14 @@ export default abstract class extends AbstractParse 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/parser/AbstractParser.ts b/src/cli/entrypoint/parser/AbstractParser.ts index 8ef7ea39..c65dd457 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[]; @@ -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/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/entrypoint/parser/ContentParser.test.ts b/src/cli/entrypoint/parser/ContentParser.test.ts new file mode 100644 index 00000000..f279b5ae --- /dev/null +++ b/src/cli/entrypoint/parser/ContentParser.test.ts @@ -0,0 +1,138 @@ +import path from "path"; + +import ContentParser from "./ContentParser"; + +import type {ReadonlyConfig} from "@typing/config"; +import {ContentScriptShadowMode, 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 = (...parts: string[]) => { + const filename = path.join(fixtures, ...parts); + + return {file: filename, import: filename}; +}; + +describe("ContentParser", () => { + test.each([ + ["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); + 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", + "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({ + 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: {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: {type: "shadow", mode: "open"}, + }); + }); + test("resolves a local page alias", () => { + expect(parser.options(file("options", "isolation", "page.content.ts"))).toMatchObject({ + 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([ + ["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", /statically known/], + ["isolation-dynamic", /statically known/], + ["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); + }); + 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, + isolation: {type: "none"}, + }); + }); +}); diff --git a/src/cli/entrypoint/parser/ContentParser.ts b/src/cli/entrypoint/parser/ContentParser.ts index 7c729a66..b5339d5f 100644 --- a/src/cli/entrypoint/parser/ContentParser.ts +++ b/src/cli/entrypoint/parser/ContentParser.ts @@ -1,12 +1,18 @@ +import {isContentScriptFrameNavigation, resolveContentScriptIsolation} from "@shared/content"; import z from "zod"; import AbstractParser from "./AbstractParser"; +import {ExportValueKind} from "../file"; import { ContentScriptDeclarative, ContentScriptEntrypointOptions, ContentScriptMarker, ContentScriptMatches, + ContentScriptWorld, + ContentScriptIsolation, + ContentScriptIsolationOptions, + ContentScriptShadowMode, } from "@typing/content"; import {EntrypointFile, EntrypointOptions} from "@typing/entrypoint"; @@ -15,7 +21,29 @@ export default class; + return this.CommonPropertiesSchema.extend({ matches: z.array(z.string()).optional(), excludeMatches: z.array(z.string()).optional(), @@ -23,14 +51,34 @@ export default class { + 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); @@ -40,4 +88,25 @@ export default class { }; 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: {type: "iframe", 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.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( expect.objectContaining({ diff --git a/src/cli/entrypoint/parser/RelayParser.ts b/src/cli/entrypoint/parser/RelayParser.ts index 903914c2..26555352 100644 --- a/src/cli/entrypoint/parser/RelayParser.ts +++ b/src/cli/entrypoint/parser/RelayParser.ts @@ -15,7 +15,12 @@ export default class extends ContentParser { return "init"; } - protected schema(): typeof this.CommonPropertiesSchema { + /** 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().extend({ allFrames: z.union([z.boolean(), z.nativeEnum(RelayAllFrames)]).optional(), name: z 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/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..2473ed1e --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/anonymous-function.content.tsx @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..de782e30 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/arrow.content.tsx @@ -0,0 +1,3 @@ +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 new file mode 100644 index 00000000..56814da1 --- /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 = {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 new file mode 100644 index 00000000..28659c9b --- /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 = {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 new file mode 100644 index 00000000..ca8b65ce --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/local-component.content.tsx @@ -0,0 +1,4 @@ +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 new file mode 100644 index 00000000..d9a09fb8 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/named-function.content.tsx @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..6e97c902 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/default-render/reexported-component.content.tsx @@ -0,0 +1,3 @@ +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 new file mode 100644 index 00000000..22e5b174 --- /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: {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 new file mode 100644 index 00000000..26d0ad28 --- /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: {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 new file mode 100644 index 00000000..8b04654b --- /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: {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 new file mode 100644 index 00000000..2a017456 --- /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: {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 new file mode 100644 index 00000000..99a0aa9c --- /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: {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 new file mode 100644 index 00000000..41f7dc25 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/invalid/frame-render.content.ts @@ -0,0 +1,7 @@ +import {defineContentScriptAppend} from "adnbn"; +export default defineContentScriptAppend({ + 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 new file mode 100644 index 00000000..9642c41d --- /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: {type: "iframe", ...settings}}); 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/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/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..22d49ed4 --- /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 = {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 new file mode 100644 index 00000000..c062a6c6 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/local-object.content.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 00000000..a69e905c --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/default-render/object.content.ts @@ -0,0 +1 @@ +export default {isolation: {type: "iframe", 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..d45decc4 --- /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 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 new file mode 100644 index 00000000..bf7572b5 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/isolation/iframe.content.ts @@ -0,0 +1,5 @@ +import {ContentScriptIsolation, defineContentScriptAppend} from "adnbn"; + +export default defineContentScriptAppend({ + 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 new file mode 100644 index 00000000..80b11a99 --- /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: {type: "iframe", 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/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/content/options/world/enum.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/world/enum.content.ts new file mode 100644 index 00000000..a14ffdc7 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/world/enum.content.ts @@ -0,0 +1,5 @@ +import {ContentScriptWorld, defineContentScript} from "adnbn"; + +export default defineContentScript({ + world: ContentScriptWorld.Main, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/content/options/world/string.content.ts b/src/cli/entrypoint/parser/tests/fixtures/content/options/world/string.content.ts new file mode 100644 index 00000000..0f7df279 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/content/options/world/string.content.ts @@ -0,0 +1,5 @@ +import {defineContentScript} from "adnbn"; + +export default defineContentScript({ + world: "ISOLATED", +}); 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 new file mode 100644 index 00000000..8af64db1 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/invalid/frame-render.relay.ts @@ -0,0 +1,6 @@ +export const isolation = {type: "iframe", page: "panel"}; + +export function render() { + return "UI"; +} +export default () => ({ping: () => "pong"}); 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 new file mode 100644 index 00000000..920b3167 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/function.relay.ts @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..c476bfce --- /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 = {type: "iframe", 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..dec0be9a --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/default-init/named-render.relay.ts @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..34c00bef --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/relay/options/isolation/relay.ts @@ -0,0 +1,8 @@ +import {defineRelay} from "adnbn"; + +export default defineRelay({ + 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/asset.ts b/src/cli/plugins/asset.ts index 3d6877f3..51432d4f 100644 --- a/src/cli/plugins/asset.ts +++ b/src/cli/plugins/asset.ts @@ -19,23 +19,29 @@ export default definePlugin(() => { module: { rules: [ { - 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/background/index.ts b/src/cli/plugins/background/index.ts index 8ccf3b81..302bfce6 100644 --- a/src/cli/plugins/background/index.ts +++ b/src/cli/plugins/background/index.ts @@ -44,6 +44,7 @@ export default definePlugin(() => { // 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/Content.ts b/src/cli/plugins/content/Content.ts index 881bc1e6..dda65544 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 { 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 { @@ -27,10 +32,13 @@ class DriverFixture implements ContentDriver { } 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 { @@ -42,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: {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( + 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: {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("isolation.src", "https://example.com"); + manager.clear(); + expect(() => manager.virtual(file)).toThrow(/group is not prepared/); + driver.setItems(new Map([["changing", {file, options: {isolation: {type: "shadow"}}}]])); + await manager.entries(); + expect(JSON.parse(manager.virtual(file))).toEqual({isolation: {type: "shadow"}}); + }); +}); + describe("ContentManager permissions", () => { test("aggregates driver permissions and gives required permissions precedence", async () => { const manager = new ContentManager({rootDir: process.cwd()} as ReadonlyConfig) @@ -66,3 +116,263 @@ describe("ContentManager permissions", () => { await expect(manager.optionalPermissions()).resolves.toEqual(new Set(["activeTab"])); }); }); + +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: {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\) isolation.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"}; + 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.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}), + ]); + 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.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.entryOptions()).resolves.toEqual( + new Map([["changing.content", {world: 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.entryOptions(); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + } + }); + + 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"}; + 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.entryOptions()).resolves.toEqual( + new Map([ + ["isolated.content", {}], + ["main.content", {world: ContentScriptWorld.Main}], + ["common-main1.content", {world: 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])]])); + }); + + 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: {isolation: {type: "shadow"}, 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.entryOptions()).resolves.toEqual( + new Map([["shadow.content", {world: ContentScriptWorld.Isolated, isolation: {type: "shadow"}}]]) + ); + } 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: {isolation: {type: "shadow"}, 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 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: {type: "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: {type: "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"}; + 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, isolation: {type: "shadow"}}}], + ["shadow-b", {file: shadowB, options: {matches, isolation: {type: "shadow"}}}], + ]); + 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.entryOptions()).resolves.toEqual( + new Map([ + ["normal-a.content", {matches}], + ["shadow-a.content", {matches, isolation: {type: "shadow"}}], + ["shadow-b.content", {matches, isolation: {type: "shadow"}}], + ]) + ); + }); + + 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: {isolation: {type: "shadow"}}}]]) + ); + const manager = new ContentManager({ + manifestVersion: 3, + concatContentScripts: false, + rootDir: process.cwd(), + } as ReadonlyConfig).provider(new ProviderFixture(driver)); + + await expect(manager.entryOptions()).resolves.toEqual( + new Map([["changing.content", {isolation: {type: "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: {isolation: {type: "none"}}}]])); + manager.clear(); + 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: {type: "shadow"}}}]])); + manager.clear(); + 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 223d53bc..5f064f31 100644 --- a/src/cli/plugins/content/ContentManager.ts +++ b/src/cli/plugins/content/ContentManager.ts @@ -1,11 +1,18 @@ import ContentName from "./ContentName"; import {ContentGroupItems, ContentProvider} from "./types"; -import {getContentScriptConfigFromOptions} from "./utils"; +import {getContentScriptConfigFromOptions, hasIsolatedTarget} from "./utils"; +import {getContentChunkName} from "./bundler"; import {ReadonlyConfig} from "@typing/config"; -import {ContentScriptDeclarative, ContentScriptEntrypointOptions} from "@typing/content"; -import {EntrypointEntries, EntrypointFile, EntrypointType} from "@typing/entrypoint"; +import { + ContentScriptDeclarative, + ContentScriptIsolation, + ContentScriptEntrypointOptions, + ContentScriptWorld, + type ContentScriptWorldValue, +} from "@typing/content"; +import {EntrypointEntries, EntrypointFile} from "@typing/entrypoint"; import { ManifestContentScripts, ManifestHostPermissions, @@ -13,7 +20,7 @@ import { ManifestPermissions, } from "@typing/manifest"; -export default class { +export default class ContentManager { protected readonly providers = new Set>(); protected readonly names: ContentName; @@ -24,8 +31,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 { @@ -35,21 +44,55 @@ 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(); 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) { + if ( + world === ContentScriptWorld.Main && + (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) isolation.src is supported` + ); + } + + 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()); } @@ -64,18 +107,34 @@ export default class { 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()) { - const options = Array.from(items, ({options}) => options).reduce((acc, opt) => { - return {...acc, ...opt}; - }, {} as ContentScriptEntrypointOptions); + 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`); + } - manifest.add({entry, ...getContentScriptConfigFromOptions(options)}); + 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 { @@ -167,9 +226,23 @@ export default class { } 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 {} } @@ -180,18 +253,16 @@ export default class { return (await this.group()).size === 0; } - public chunkName(): string { - return this.names.getChunkName(); - } - - public likely(name?: string): boolean { - if (!name) { - return false; + 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)}"`); } - - return [EntrypointType.Relay, EntrypointType.ContentScript].some( - type => name === type || name.endsWith(`.${type}`) - ); } public clear(): this { diff --git a/src/cli/plugins/content/ContentName.ts b/src/cli/plugins/content/ContentName.ts index 68f95c4e..4b23b1e3 100644 --- a/src/cli/plugins/content/ContentName.ts +++ b/src/cli/plugins/content/ContentName.ts @@ -7,26 +7,21 @@ 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(); 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; + if ( + !this.config.concatContentScripts || + (options.isolation !== undefined && options.isolation.type !== ContentScriptIsolation.None) + ) { + return this.name(name); } const key = stringify(getContentScriptConfigFromOptions(options)); @@ -37,8 +32,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/Relay.ts b/src/cli/plugins/content/Relay.ts index f4948c4d..b2434b70 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..837dbb18 --- /dev/null +++ b/src/cli/plugins/content/bundler.test.ts @@ -0,0 +1,88 @@ +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 [{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}"`); + expect(warnings[0]).toContain("?isolation"); + } + }); + + test.each([ + undefined, + {}, + {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: {type: "shadow"}}, {document, isolated: ["ui.css"]}) + ).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: {type: "iframe", ...frame}}, + {document: [], isolated: ["ui.css"]} + ) + ).toThrow('Entrypoint "frame" uses ?isolation CSS with isolation.page/isolation.src'); + } + ); +}); + +describe("createPageAccessRequirements", () => { + test("resolves page aliases from supplied filenames without weakening matches using exclusions", () => { + const entries = new Map([ + [ + "frame.content", + { + isolation: {type: "iframe", page: "panel"}, + + matches: ["https://*.example.com/*"], + excludeMatches: ["https://private.example.com/*"], + }, + ], + ["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([ + { + 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: {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 new file mode 100644 index 00000000..05185dab --- /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.isolation)) { + throw new Error( + `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?.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 []; +}; + +export const createPageAccessRequirements = ( + entries: ReadonlyMap, + pages: ReadonlyMap +): ResourceAccessPluginRequirement[] => { + const requirements: ResourceAccessPluginRequirement[] = []; + + for (const [entry, options] of entries) { + const alias = options.isolation?.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 e58cc26e..d7ca7c85 100644 --- a/src/cli/plugins/content/index.ts +++ b/src/cli/plugins/content/index.ts @@ -1,119 +1,198 @@ -import {Configuration as RspackConfig, DefinePlugin, 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 {hasIsolatedTarget} from "./utils"; +import {createPageAccessRequirements, getContentChunkName, validateContentStyles} from "./bundler"; +import {createRelayModule, RelayModuleName} from "./relay-module"; import {definePlugin} from "@main/plugin"; - -import {EntrypointPlugin, onlyViaTopLevelEntry} from "@cli/bundler"; -import {getResolvePath, getSourcePath} from "@cli/resolvers/path"; +import {PageFinder} from "@cli/entrypoint"; +import {getContentLayer} from "@cli/bundler/utils/layers"; + +import { + appFilenameResolver, + ChunkLoaderPlugin, + EntrypointPlugin, + onlyViaTopLevelEntry, + IsolatedStylesPlugin, + ResourceAccessPlugin, + GenerateModulePlugin, +} from "@cli/bundler"; import {Command} from "@typing/app"; -import {RelayOptions} from "@typing/relay"; +import {ContentScriptStylesRuntimeProperty, ContentScriptWorld} from "@typing/content"; 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 entryOptionsByName = await contentManager.entryOptions(); - let rspack: RspackConfig = {}; - let options: Record = {}; + const getModules = async () => ({ + [RelayModuleName]: createRelayModule(await relayProvider.getOptionsMap()), + }); + const modulePlugin = new GenerateModulePlugin(await getModules()); + + if (config.command === Command.Watch) { + modulePlugin.watch(getModules); + } - if (await manager.empty()) { + const basePlugins: Plugins = [ + modulePlugin, + new ResourceAccessPlugin({ + requirements: async () => { + if ( + !Array.from(entryOptionsByName.values()).some( + options => options.isolation?.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 contentManager.empty()) { if (config.debug) { console.warn("Content script or relay entries not found"); } - } else { - options = await relay.getOptionsMap(); - // prettier-ignore - const plugin = EntrypointPlugin.from(await manager.entries()) - .virtual(file => manager.virtual(file)); - - if (config.command === Command.Watch) { - plugin.watch(async () => { - manager.clear(); + return {plugins: basePlugins}; + } - relayDeclaration.dictionary(await relay.dictionary()).build(); + const entries = await contentManager.entries(); + const getEntryWorld = (name: string): ContentScriptWorld => { + const options = entryOptionsByName.get(name); - return manager.entries(); - }); + if (!options) { + throw new Error(`Execution world for content entrypoint "${name}" is unavailable`); } - 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, - }, - }, - }, - }, - }; + return options.world === ContentScriptWorld.Main + ? ContentScriptWorld.Main + : ContentScriptWorld.Isolated; + }; + + // 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: "", + }; + }); + + 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(); + + return entries; + }); } - return mergeConfig(rspack, { + 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: [ - new DefinePlugin({ - __ADNBN_RELAY_OPTIONS__: JSON.stringify(options), + 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/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/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..0c312e28 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,27 @@ describe("content utils - getContentScriptConfigFromOptions", () => { expect(cfgUndefined.excludeGlobs).toBeUndefined(); }); }); + +describe("content render targets", () => { + test.each([ + {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: {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: {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 6eba1c6f..f55675d0 100644 --- a/src/cli/plugins/content/utils.ts +++ b/src/cli/plugins/content/utils.ts @@ -1,6 +1,12 @@ import _ from "lodash"; +import {isContentScriptFrameNavigation} from "@shared/content"; -import {ContentScriptConfig, ContentScriptEntrypointOptions} from "@typing/content"; +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?.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/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/locale/Locale.test.ts b/src/cli/plugins/locale/Locale.test.ts index a2405add..386dfbbc 100644 --- a/src/cli/plugins/locale/Locale.test.ts +++ b/src/cli/plugins/locale/Locale.test.ts @@ -1,21 +1,14 @@ -jest.mock("@addon-core/browser", () => ({ - getI18nMessage: jest.fn(() => "de"), -})); - import fs from "fs"; import os from "os"; import path from "path"; -import {getI18nMessage} from "@addon-core/browser"; import Locale from "./Locale"; -import DynamicLocale from "@locale/providers/DynamicLocale"; -import NativeLocale from "@locale/providers/NativeLocale"; -import {getLocaleFilename} from "@locale/utils"; import {Command, Mode, Workspace} from "@typing/app"; import {Browser} from "@typing/browser"; import {ReadonlyConfig} from "@typing/config"; import {Language, LocaleMessages} from "@typing/locale"; import {GenerateJsonPluginData} from "@cli/bundler"; +import {flattenLocaleMessages, getLocaleFilename} from "@shared/locale"; const fixtures = path.resolve(__dirname, "tests/fixtures/completion"); @@ -60,22 +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(); - - 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}); } @@ -188,6 +172,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,33 +201,36 @@ 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("dynamic selection uses the generated default rather than a third native language", async () => { - const json = await makeLocale("single").json(); - const german = messages(json, Language.German); - jest.mocked(getI18nMessage).mockImplementation(key => german[key]?.message ?? ""); - Object.defineProperty(globalThis, "fetch", { - configurable: true, - value: jest.fn(async () => ({json: async () => messages(json, Language.French)})), - }); - const dynamic = new DynamicLocale<{ - "app.greeting": {plural: false; substitutions: ["name"]}; - "cart.items": {plural: true; substitutions: ["count"]}; - }>(false); - const native = new NativeLocale<{ - "app.greeting": {plural: false; substitutions: ["name"]}; - }>(); - - expect(native.trans("app.greeting", {name: "Ada"})).toBe("Hallo Ada"); - await dynamic.change(Language.French); - - expect(dynamic.lang()).toBe(Language.French); - expect(dynamic.trans("app.greeting", {name: "Ada"})).toBe("Hello Ada"); - expect(dynamic.choice("cart.items", 2, {count: 2})).toBe("2 articles"); - expect(globalThis.fetch).toHaveBeenCalledWith(getLocaleFilename(Language.French)); + 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"); }); }); diff --git a/src/cli/plugins/locale/Locale.ts b/src/cli/plugins/locale/Locale.ts index ad1b67e8..5189dac6 100644 --- a/src/cli/plugins/locale/Locale.ts +++ b/src/cli/plugins/locale/Locale.ts @@ -1,13 +1,49 @@ -import {getLocaleFilename} from "@locale/utils"; - +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"; + +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}; + } -export default class extends LocaleFinder { 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 +53,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/declaration/LocaleDeclaration.test.ts b/src/cli/plugins/locale/declaration/LocaleDeclaration.test.ts index 4b9511e2..801c826a 100644 --- a/src/cli/plugins/locale/declaration/LocaleDeclaration.test.ts +++ b/src/cli/plugins/locale/declaration/LocaleDeclaration.test.ts @@ -4,119 +4,45 @@ 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, strict: true, - skipLibCheck: true, + skipLibCheck: target === "source", + jsx: ts.JsxEmit.ReactJSX, noEmit: true, 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 +66,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"); @@ -148,21 +74,23 @@ 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"); }); 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", () => { @@ -175,77 +103,13 @@ 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); -`, + [ + "language-names.ts", + "custom-structure.ts", + "generated-registry.ts", + "native-react.tsx", + "dynamic-react.tsx", + ], declaration, target ) 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..184155d2 --- /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 {LocaleReactContract} 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 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 +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 new file mode 100644 index 00000000..55395b2c --- /dev/null +++ b/src/cli/plugins/locale/declaration/tests/fixtures/empty-registry.ts @@ -0,0 +1,28 @@ +import {NativeLocale, DynamicLocale, t, choice, key, resolve, type LocaleRegistry} from "adnbn/locale"; +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 +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 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 new file mode 100644 index 00000000..247df87e --- /dev/null +++ b/src/cli/plugins/locale/declaration/tests/fixtures/generated-registry.ts @@ -0,0 +1,71 @@ +import {Language, NativeLocale, DynamicLocale, t, choice, key, resolve, type LocaleRegistry} from "adnbn/locale"; +import {useNativeLocale} 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); +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(); +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 +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 +useNativeLocale().t("extra"); +// @ts-expect-error React retains the substitution contract +useNativeLocale().t("app.greeting"); +// @ts-expect-error React retains the plural contract +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 new file mode 100644 index 00000000..7e14e27f --- /dev/null +++ b/src/cli/plugins/locale/declaration/tests/fixtures/language-names.ts @@ -0,0 +1,36 @@ +import {Language, NativeLocale, DynamicLocale, type LocaleProvider} from "adnbn/locale"; +import {useLocale, useNativeLocale} from "adnbn/locale/react"; + +const native: ReadonlyMap = new NativeLocale().langNames(); +const dynamic: ReadonlyMap = new DynamicLocale().langNames(); +declare const provider: LocaleProvider<{}>; +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 +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/cli/plugins/locale/index.ts b/src/cli/plugins/locale/index.ts index 39603a7e..33609529 100644 --- a/src/cli/plugins/locale/index.ts +++ b/src/cli/plugins/locale/index.ts @@ -1,16 +1,18 @@ import _ from "lodash"; -import {Configuration as RspackConfig, DefinePlugin} from "@rspack/core"; +import type {Configuration as RspackConfig, RspackPluginInstance} from "@rspack/core"; import {definePlugin} from "@main/plugin"; -import {GenerateJsonPlugin} from "@cli/bundler"; -import {extractLocaleKey, modifyLocaleMessageKey} from "@locale/utils"; +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, LocaleModuleLayer, LocaleModuleName} from "./locale-module"; import {Command} from "@typing/app"; import {Browser} from "@typing/browser"; +import {ContentScriptWorld} from "@typing/content"; export default definePlugin(() => { let locale: Locale; @@ -24,32 +26,68 @@ export default definePlugin(() => { }, locale: () => locale.files(), bundler: async ({config}) => { - await locale.validate(); - - declaration.structure(await locale.structure()).build(); + const prepareLocale = async () => { + await locale.validate(); + declaration.structure(await locale.structure()).build(); + }; - const plugin = new GenerateJsonPlugin(await locale.json()); + const getModules = async () => ({ + [LocaleModuleName]: createLocaleModule(await locale.catalogue(), await locale.keys(), config.lang), + }); - if (config.command === Command.Watch) { - plugin.watch(async () => { - locale.clear(); + await prepareLocale(); - await locale.validate(); + const jsonPlugin = new GenerateJsonPlugin(await locale.json()); + // 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)], + }); + const plugins: RspackPluginInstance[] = []; - declaration.structure(await locale.structure()).build(); + if (config.command === Command.Watch) { + plugins.push( + new WatchPlugin(async () => { + locale.clear(); + await prepareLocale(); + }) + ); - return await locale.json(); - }); + jsonPlugin.watch(() => locale.json()); + modulePlugin.watch(getModules, () => locale.dependencies()); } + plugins.push(jsonPlugin, modulePlugin); + return { - plugins: [ - plugin, - new DefinePlugin({ - __ADNBN_LOCALE_KEYS__: JSON.stringify([...(await locale.keys())]), - __ADNBN_DEFINED_LOCALES__: JSON.stringify([...(await locale.languages())]), - }), - ], + plugins, + optimization: { + emitOnErrors: false, + splitChunks: config.commonChunks + ? { + cacheGroups: { + adnbnLocaleShared: { + layer: LocaleModuleLayer, + name: "locale", + // Share the module across consumers of different exports. + usedExports: false, + minChunks: 2, + minSize: 0, + enforce: true, + priority: 60, + }, + adnbnLocaleLarge: { + layer: LocaleModuleLayer, + name: "locale", + usedExports: false, + minChunks: 1, + minSize: 100_000, + enforce: true, + priority: 70, + }, + }, + } + : undefined, + }, } satisfies RspackConfig; }, manifest: async ({config, manifest}) => { @@ -94,13 +132,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/locale-module.ts b/src/cli/plugins/locale/locale-module.ts new file mode 100644 index 00000000..dc35b91a --- /dev/null +++ b/src/cli/plugins/locale/locale-module.ts @@ -0,0 +1,11 @@ +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` + + `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/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/output.ts b/src/cli/plugins/output.ts index 0d9677a6..b1ce3dc7 100644 --- a/src/cli/plugins/output.ts +++ b/src/cli/plugins/output.ts @@ -3,21 +3,42 @@ import {Configuration as RspackConfig} from "@rspack/core"; import {definePlugin} from "@main/plugin"; -import {appFilenameResolver} from "@cli/bundler"; +import { + appFilenameResolver, + BuildAssetsMapPlugin, + GenerateModulePlugin, + createEntrypointModule, + EntrypointAssetsModule, +} 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 +51,16 @@ export default definePlugin(() => { uniqueName: kebabApp, clean: config.command === Command.Build, }, + plugins: [ + new GenerateModulePlugin({[EntrypointAssetsModule.request]: createEntrypointModule()}), + new BuildAssetsMapPlugin({ + module: EntrypointAssetsModule, + buildHashSalt, + cssChunkFilename: cssOutputFilename, + cssFilename: cssOutputFilename, + fullMapEntrypoint: BackgroundEntryName, + }), + ], } satisfies RspackConfig; }, }; 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..efed19b5 100644 --- a/src/cli/plugins/page/index.ts +++ b/src/cli/plugins/page/index.ts @@ -1,14 +1,14 @@ -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"; import {PageDeclaration} from "./declaration"; +import {createPageModule, PageModuleName} from "./page-module"; import {definePlugin} from "@main/plugin"; import {virtualViewModule} from "@cli/virtual"; -import {EntrypointPlugin} from "@cli/bundler"; -import {ViewAliasToFilename} from "@cli/entrypoint"; +import {EntrypointPlugin, GenerateModulePlugin} from "@cli/bundler"; import {Command} from "@typing/app"; @@ -26,17 +26,24 @@ export default definePlugin(() => { bundler: async ({config}) => { declaration.setAlias(await page.getAlias()).build(); - const plugins: Plugins = []; + const getModules = async () => ({ + [PageModuleName]: createPageModule( + Object.fromEntries(Array.from((await page.views()).values(), item => [item.alias, item.filename])) + ), + }); + const modulePlugin = new GenerateModulePlugin(await getModules()); - let alias: ViewAliasToFilename = new Map(); + if (config.command === Command.Watch) { + modulePlugin.watch(getModules); + } + + 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)); @@ -56,12 +63,8 @@ export default definePlugin(() => { } return { - plugins: [ - new DefinePlugin({ - __ADNBN_PAGE_ALIAS__: JSON.stringify(alias), - }), - ...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/cli/plugins/popup/Popup.ts b/src/cli/plugins/popup/Popup.ts index a0487642..ebd6c9e5 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"; @@ -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 9d74fb15..cb73be00 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"; @@ -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/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/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/resolvers/bundler.ts b/src/cli/resolvers/bundler.ts index 1f84ce86..8e3b12ff 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"; @@ -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}; }; 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/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/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 edc99f28..222e4c31 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"; @@ -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/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.d.ts b/src/cli/virtual/virtual.d.ts index 079755d0..244f5f7e 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; @@ -29,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; } @@ -37,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; } @@ -69,6 +66,7 @@ declare module "virtual:relay-entrypoint" { main, name, method, + isolation, allFrames, matches, excludeMatches, diff --git a/src/cli/virtual/virtual.test.ts b/src/cli/virtual/virtual.test.ts index 783ef734..422f5c82 100644 --- a/src/cli/virtual/virtual.test.ts +++ b/src/cli/virtual/virtual.test.ts @@ -1,5 +1,7 @@ import {execFileSync} from "child_process"; +import {existsSync, readFileSync} from "fs"; import path from "path"; +import {fileURLToPath} from "url"; import ts from "typescript"; type Generator = keyof typeof import("./index"); @@ -13,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", @@ -41,6 +43,7 @@ describe("Built virtual modules", () => { { generator: "virtualRelayModule", imports: [ + "adnbn", "adnbn/transport", "adnbn/entry/transport", "adnbn/entry/relay", @@ -61,14 +64,16 @@ 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>; + 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, [ @@ -79,25 +84,58 @@ 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", () => { 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"); + }); + + 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"}, @@ -114,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/README.md b/src/entry/content/README.md new file mode 100644 index 00000000..9a058242 --- /dev/null +++ b/src/entry/content/README.md @@ -0,0 +1,251 @@ +# Content entrypoints + +## Internal organization + +`core/Builder.ts` and `core/MountBuilder.ts` coordinate the content lifecycle. Related implementations +and their tests live together: + +- `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 `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. + +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"`. 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" +import {ContentScriptIsolation, defineContentScriptAppend} from "adnbn"; +import "./panel.css?isolation"; +import Panel from "./Panel"; + +export default defineContentScriptAppend({ + matches: ["https://example.com/*"], + anchor: ".product", + isolation: {type: ContentScriptIsolation.Iframe, height: 320}, + render: Panel, +}); +``` + +| 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 | + +`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. + +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. + +## 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" +import {defineContentScriptAppend} from "adnbn"; + +export default defineContentScriptAppend({ + matches: ["https://example.com/*"], + isolation: {type: "iframe", page: "panel", height: 320}, +}); +``` + +`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 `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 +`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 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 `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 +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"; +``` + +`?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. +`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 +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\]/], + }, +}); +``` + +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; +} +``` + +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/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.test.ts b/src/entry/content/adapters/react/Node.test.ts new file mode 100644 index 00000000..d2794f2f --- /dev/null +++ b/src/entry/content/adapters/react/Node.test.ts @@ -0,0 +1,48 @@ +import {createRoot} from "react-dom/client"; + +import ReactNode from "./Node"; + +import {ContentScriptShadowMode, 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.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/react/Node.ts b/src/entry/content/adapters/react/Node.ts index 3e00dc7e..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 @@ -19,10 +21,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 && this.renderedTarget === this.target)) { return false; } @@ -32,7 +38,9 @@ export default class implements ContentScriptNode { return false; } - this.root = createRoot(this.container); + this.root?.unmount(); + this.renderedTarget = this.target; + this.root = createRoot(this.target); this.root.render(this.component); @@ -42,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.test.ts b/src/entry/content/adapters/vanilla/Node.test.ts new file mode 100644 index 00000000..74eee873 --- /dev/null +++ b/src/entry/content/adapters/vanilla/Node.test.ts @@ -0,0 +1,32 @@ +import VanillaNode from "./Node"; + +import {ContentScriptShadowMode, type ContentScriptNode} from "@typing/content"; + +describe("VanillaNode", () => { + 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.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..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, @@ -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.renderedTarget === this.target) { return false; } let result: boolean = true; - if (this.value instanceof Element) { - this.container.appendChild(this.value); + 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.container.textContent = String(this.value); + this.target.textContent = String(this.value); } else if (this.value === null || this.value === undefined || this.value === false) { result = false; @@ -37,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 8b20d03d..f609f918 100644 --- a/src/entry/content/core/Builder.ts +++ b/src/entry/content/core/Builder.ts @@ -1,3 +1,4 @@ +import {isContentScriptFrameNavigation, resolveContentScriptIsolation} from "@shared/content"; import AwaitLock from "await-lock"; import Builder from "@entry/core/Builder"; @@ -12,10 +13,8 @@ import { contentScriptRenderResolver, } from "./resolvers"; -import ManagedContext from "./ManagedContext"; -import EventEmitter from "./EventEmitter"; -import AttributeMarker from "./AttributeMarker"; -import WeakMarker from "./WeakMarker"; +import {ManagedContext, EventEmitter} from "./context"; +import {AttributeMarker, WeakMarker} from "./markers"; import { ContentScriptAnchor, @@ -63,6 +62,8 @@ export default abstract class extends Builder implements ContentScriptBuilder { protected constructor(definition: ContentScriptDefinition) { super(); + const isolation = resolveContentScriptIsolation(definition.isolation, "render" in definition); + this.definition = { ...definition, marker: this.resolveMarker(definition.marker), @@ -70,6 +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, watch: this.resolveWatch(definition.watch), }; } @@ -146,10 +148,12 @@ export default abstract class extends Builder implements ContentScriptBuilder { await main?.(this.context, options); - if (render !== undefined) { + if (render !== undefined || isContentScriptFrameNavigation(this.definition.isolation)) { await this.processing(); this.unwatch = watch(() => { + this.context.mount(); + this.processing().catch(e => { console.error("Content script processing on watch error", e); }); @@ -161,6 +165,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/MountBuilder.ts b/src/entry/content/core/MountBuilder.ts index 94d63b82..a7872086 100644 --- a/src/entry/content/core/MountBuilder.ts +++ b/src/entry/content/core/MountBuilder.ts @@ -1,15 +1,16 @@ import Builder from "./Builder"; -import Node from "./Node"; -import MountNode from "./MountNode"; -import MarkerNode from "./MarkerNode"; -import {ContentScriptNode, ContentScriptProps, ContentScriptRenderValue} from "@typing/content"; +import {FrameNode, MountNode, MarkerNode, ShadowNode, Node} from "./nodes"; + +import {isContentScriptFrameNavigation} from "@shared/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, ...options} = this.definition; + const {anchor: _, mount, watch, render, container, main, isolation, ...options} = this.definition; return {...options, anchor}; } @@ -39,11 +40,23 @@ export default abstract class extends Builder { const value = await this.getValue(anchor); - if (typeof value !== "boolean" && value !== undefined) { + if ( + isContentScriptFrameNavigation(this.definition.isolation) || + (typeof value !== "boolean" && value !== undefined) + ) { 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); + + switch (this.definition.isolation.type) { + case ContentScriptIsolation.Shadow: + return new ShadowNode(node, this.definition.isolation); + case ContentScriptIsolation.Iframe: + return new FrameNode(node, this.definition.isolation, () => this.context.mount()); + default: + return node; + } } protected cleanupNode(anchor: Element): void { diff --git a/src/entry/content/core/context/Context.test.ts b/src/entry/content/core/context/Context.test.ts new file mode 100644 index 00000000..5ffd6a91 --- /dev/null +++ b/src/entry/content/core/context/Context.test.ts @@ -0,0 +1,29 @@ +import {Context} from "./index"; + +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/Context.ts similarity index 86% rename from src/entry/content/core/Context.ts rename to src/entry/content/core/context/Context.ts index 1a77d5ef..9eeb4eb0 100644 --- a/src/entry/content/core/Context.ts +++ b/src/entry/content/core/context/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/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 90% rename from src/entry/content/core/EventNode.ts rename to src/entry/content/core/nodes/EventNode.ts index 6c5316f5..b48cfb4b 100644 --- a/src/entry/content/core/EventNode.ts +++ b/src/entry/content/core/nodes/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/nodes/FrameNode.test.ts b/src/entry/content/core/nodes/FrameNode.test.ts new file mode 100644 index 00000000..cce6cbb6 --- /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); + }), + {type: "iframe"}, + () => {} + ); + 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), + {type: "iframe", 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); + }), + {type: "iframe", 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..b3060149 --- /dev/null +++ b/src/entry/content/core/nodes/FrameNode.ts @@ -0,0 +1,157 @@ +import type {ContentScriptIsolationFrameOptions, ContentScriptNode, ContentScriptStylesRuntime} from "@typing/content"; +import {ContentScriptIsolation} from "@typing/content"; +import {getPageUrl} from "@main/page"; + +import {getContentScriptStylesRuntime} from "./isolated-styles"; + +import {isContentScriptFrameNavigation} from "@shared/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: ContentScriptIsolationFrameOptions = {type: ContentScriptIsolation.Iframe}, + 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 89% rename from src/entry/content/core/MarkerNode.ts rename to src/entry/content/core/nodes/MarkerNode.ts index 90ba0e7b..c97a17d1 100644 --- a/src/entry/content/core/MarkerNode.ts +++ b/src/entry/content/core/nodes/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/MountNode.ts b/src/entry/content/core/nodes/MountNode.ts similarity index 92% rename from src/entry/content/core/MountNode.ts rename to src/entry/content/core/nodes/MountNode.ts index 6d0a632b..db1643dc 100644 --- a/src/entry/content/core/MountNode.ts +++ b/src/entry/content/core/nodes/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/nodes/Node.ts similarity index 90% rename from src/entry/content/core/Node.ts rename to src/entry/content/core/nodes/Node.ts index 0ce397de..d855d443 100644 --- a/src/entry/content/core/Node.ts +++ b/src/entry/content/core/nodes/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/nodes/ShadowNode.test.ts b/src/entry/content/core/nodes/ShadowNode.test.ts new file mode 100644 index 00000000..e4fbf01c --- /dev/null +++ b/src/entry/content/core/nodes/ShadowNode.test.ts @@ -0,0 +1,152 @@ +import MountNode from "./MountNode"; +import Node from "./Node"; +import ShadowNode from "./ShadowNode"; +import {getContentScriptStylesRuntime} from "./isolated-styles"; +import { + ContentScriptShadowMode, + type ContentScriptShadowOptions, + type ContentScriptStylesRuntime, +} from "@typing/content"; + +jest.mock("./isolated-styles", () => ({ + getContentScriptStylesRuntime: jest.fn(), +})); + +const createNode = ( + runtime: ContentScriptStylesRuntime, + host = document.createElement("section"), + options?: ContentScriptShadowOptions +) => { + const anchor = document.createElement("div"); + document.body.appendChild(anchor); + jest.mocked(getContentScriptStylesRuntime).mockReturnValue(runtime); + + return { + anchor, + host, + node: new ShadowNode( + new MountNode(new Node(anchor, host), (_anchor, container) => { + anchor.appendChild(container); + }), + options + ), + }; +}; + +describe("ShadowNode", () => { + beforeEach(() => { + document.body.innerHTML = ""; + jest.clearAllMocks(); + }); + + 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); + 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"]); + + node.mount(); + expect(runtime.add).toHaveBeenCalledTimes(1); + } + ); + + 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}); + + node.mount(); + const firstHost = node.container; + const firstRoot = node.target!.getRootNode(); + + 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 = { + initialize: jest.fn(), + 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(); + }); + + 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 new file mode 100644 index 00000000..20551d95 --- /dev/null +++ b/src/entry/content/core/nodes/ShadowNode.ts @@ -0,0 +1,82 @@ +import {getContentScriptStylesRuntime} from "./isolated-styles"; +import { + ContentScriptShadowMode, + type ContentScriptShadowOptions, + type ContentScriptStylesRuntime, + type ContentScriptNode, +} from "@typing/content"; + +export default class ShadowNode implements ContentScriptNode { + private root?: ShadowRoot; + + private _target?: Element; + + private runtime?: ContentScriptStylesRuntime; + + public constructor( + protected readonly node: ContentScriptNode, + private readonly options: ContentScriptShadowOptions = {} + ) {} + + 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"); + } + + 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); + + const runtime = getContentScriptStylesRuntime(); + runtime.add(root, target); + + 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/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/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/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/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/locale/README.md b/src/locale/README.md new file mode 100644 index 00000000..9b979e5b --- /dev/null +++ b/src/locale/README.md @@ -0,0 +1,164 @@ +--- +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, 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 + +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. + +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. +- `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 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. + +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. + +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. + +`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 0d668235..00000000 --- a/src/locale/adapters/react/LocaleProvider.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, {PropsWithChildren, useCallback, useEffect, useMemo, useState} from "react"; - -import {LocaleContext, LocaleContract} from "./context"; - -import {getLocaleDir, isLocaleRtl} from "@locale/utils"; - -import {DynamicLocale} from "@locale/providers"; - -import {Language} from "@typing/locale"; - -export interface LocaleProviderProps { - storage?: string | 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)); - }, []); - - 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(() => { - locale.sync().then(lang => setLang(lang)); - }, []); - - useEffect(() => { - 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 243a2cc9..00000000 --- a/src/locale/adapters/react/context.ts +++ /dev/null @@ -1,41 +0,0 @@ -import {createContext, useContext} from "react"; - -import {LocaleDir, Language, LocaleNonPluralKeys, LocaleSubstitutionArgs, LocalePluralKeys} from "@typing/locale"; - -import {LocaleNativeStructure} from "@locale/providers"; - -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 796546d7..00000000 --- a/src/locale/adapters/react/localeProvider.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -jest.mock("@addon-core/browser", () => ({ - getI18nMessage: jest.fn(() => "en"), -})); - -import {createElement, PropsWithChildren} from "react"; -import {act, cleanup, renderHook, waitFor} from "@testing-library/react"; -import {getI18nMessage} from "@addon-core/browser"; -import LocaleProvider from "./LocaleProvider"; -import {useLocale, type LocaleContract} from "./context"; -import {Language} from "@typing/locale"; - -describe("React locale provider", () => { - const languagesDescriptor = Object.getOwnPropertyDescriptor(globalThis, "__ADNBN_DEFINED_LOCALES__"); - const keysDescriptor = Object.getOwnPropertyDescriptor(globalThis, "__ADNBN_LOCALE_KEYS__"); - const fetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, "fetch"); - - beforeEach(() => { - jest.mocked(getI18nMessage).mockReset().mockReturnValue("en"); - Object.defineProperty(globalThis, "__ADNBN_DEFINED_LOCALES__", { - configurable: true, - value: [Language.English, Language.French], - }); - Object.defineProperty(globalThis, "__ADNBN_LOCALE_KEYS__", { - configurable: true, - value: ["demo.empty"], - }); - Object.defineProperty(globalThis, "fetch", { - configurable: true, - value: jest.fn(async () => ({json: async () => ({locale: {message: "fr"}})})), - }); - }); - - afterEach(() => { - cleanup(); - jest.restoreAllMocks(); - - for (const [key, descriptor] of [ - ["__ADNBN_DEFINED_LOCALES__", languagesDescriptor], - ["__ADNBN_LOCALE_KEYS__", keysDescriptor], - ["fetch", fetchDescriptor], - ] as const) { - if (descriptor) { - Object.defineProperty(globalThis, key, descriptor); - } else { - Reflect.deleteProperty(globalThis, key); - } - } - }); - - test("returns an empty map outside a provider", () => { - const {result} = renderHook(() => useLocale()); - - expect(result.current.langs).toEqual(new Map()); - expect(result.current).not.toHaveProperty("languageNames"); - }); - - test("returns keys outside a provider, ignoring substitutions and plural counts", () => { - const {result} = renderHook(() => useLocale()); - const locale = result.current as LocaleContract<{ - "app.title": {plural: false; substitutions: []}; - "app.greeting": {plural: false; substitutions: ["name"]}; - "cart.items": {plural: true; substitutions: ["count"]}; - }>; - - expect(locale.t("app.title")).toBe("app.title"); - expect(locale.t("app.greeting", {name: "Ada"})).toBe("app.greeting"); - expect(locale.choice("cart.items", 2, {count: 2})).toBe("cart.items"); - }); - - test("preserves an empty native translation on the first render without warnings or fetching", async () => { - jest.mocked(getI18nMessage).mockImplementation(key => (key === "locale" ? "ru" : "")); - Object.defineProperty(globalThis, "__ADNBN_DEFINED_LOCALES__", { - value: [Language.English, Language.Russian], - }); - const warn = jest.spyOn(console, "warn").mockImplementation(); - const wrapper = ({children}: PropsWithChildren) => - createElement(LocaleProvider, {container: false, storage: "native-empty-test"}, children); - const {result} = renderHook( - () => { - const locale = useLocale() as LocaleContract<{ - "demo.empty": {plural: false; substitutions: []}; - }>; - - return {lang: locale.lang, message: locale.t("demo.empty")}; - }, - {wrapper} - ); - - expect(result.current).toEqual({lang: Language.Russian, message: ""}); - await act(async () => {}); - expect(result.current).toEqual({lang: Language.Russian, message: ""}); - expect(warn).not.toHaveBeenCalled(); - expect(fetch).not.toHaveBeenCalled(); - }); - - test("keeps the available language map stable while changing the selected language", async () => { - const wrapper = ({children}: PropsWithChildren) => - createElement(LocaleProvider, {container: false, storage: "locale-test"}, children); - const {result, rerender} = renderHook(() => useLocale(), {wrapper}); - - await act(async () => {}); - const langs = result.current.langs; - expect([...langs]).toEqual([ - [Language.English, "English"], - [Language.French, "Français"], - ]); - - act(() => result.current.change(Language.French)); - await waitFor(() => expect(result.current.lang).toBe(Language.French)); - rerender(); - - expect(result.current.langs).toBe(langs); - expect([...result.current.langs.keys()]).toEqual([Language.English, Language.French]); - }); -}); diff --git a/src/locale/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/helpers.test.ts b/src/locale/helpers.test.ts index fd301c09..01ce02c7 100644 --- a/src/locale/helpers.test.ts +++ b/src/locale/helpers.test.ts @@ -11,25 +11,16 @@ jest.mock("@addon-core/browser", () => ({ })); import {getI18nMessage} from "@addon-core/browser"; -import {resolve} from "./helpers"; +import {resolve} from "./index"; -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/helpers.ts b/src/locale/helpers.ts index 793d997d..35eab270 100644 --- a/src/locale/helpers.ts +++ b/src/locale/helpers.ts @@ -1,6 +1,6 @@ -import {LocaleNativeStructure, NativeLocale} from "@locale/providers"; -import {convertLocaleMessageKey, extractLocaleKey} from "@locale/utils"; -import {LocaleNonPluralKeys, LocalePluralKeys, LocaleSubstitutionArgs} from "@typing/locale"; +import {NativeLocale} from "@locale/providers"; +import {convertLocaleMessageKey, extractLocaleKey} from "@shared/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 19d6d8d9..bf6bbc7c 100644 --- a/src/locale/index.ts +++ b/src/locale/index.ts @@ -1,13 +1,17 @@ export * from "./providers"; export * from "./helpers"; +export * from "./storage"; export { Language, LanguageCodes, LocaleDir, type LocaleStructure, + type LocaleRegistry, type LocaleProvider, type LocaleDynamicProvider, + type LocaleSnapshot, + type LocaleStorageDriver, type LocaleKeys, type LocaleSubstitutionArgs, type LocaleSubstitutionKeys, diff --git a/src/locale/providers/AbstractLocale.ts b/src/locale/providers/AbstractLocale.ts index fb4bebf7..f34911e2 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,19 +9,18 @@ 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; - 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; @@ -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..237731c2 100644 --- a/src/locale/providers/CustomLocale.test.ts +++ b/src/locale/providers/CustomLocale.test.ts @@ -41,15 +41,21 @@ 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()", () => { + 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..cbb4d036 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 = {} @@ -34,12 +34,13 @@ export default class extends AbstractLocale< return new Set(Object.keys(this.data)) as Set; } - public languages(): Set { + public langs(): ReadonlySet { return new Set([this.lang()]); } 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.test.ts b/src/locale/providers/DynamicLocale.test.ts index 32d5494d..4a7af6ad 100644 --- a/src/locale/providers/DynamicLocale.test.ts +++ b/src/locale/providers/DynamicLocale.test.ts @@ -1,111 +1,330 @@ -jest.mock("@addon-core/browser", () => ({ - getI18nMessage: jest.fn(() => "en"), -})); - -import DynamicLocale from "./DynamicLocale"; -import CustomLocale, {CustomLocaleData} from "./CustomLocale"; +jest.mock("@addon-core/browser", () => ({getI18nMessage: jest.fn(() => "en")})); +jest.mock("@addon-core/storage", () => ({Storage: {Local: jest.fn()}})); +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 {MemoryLocaleStorage} from "../tests/fixtures"; import {Language} from "@typing/locale"; -interface Structure { - empty: {plural: false; substitutions: []}; -} +import type {Structure} from "./tests/fixtures/dynamic"; -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); + }), +}; - return this; - } -} +beforeEach(() => { + stored = undefined; + listeners.clear(); + jest.clearAllMocks(); + jest.mocked(getI18nMessage).mockReturnValue("en"); + jest.mocked(Storage.Local).mockReturnValue(driver as unknown as StorageProvider>); +}); -describe("DynamicLocale", () => { - const keysDescriptor = Object.getOwnPropertyDescriptor(globalThis, "__ADNBN_LOCALE_KEYS__"); +afterEach(() => { + jest.restoreAllMocks(); +}); - beforeEach(() => { - Object.defineProperty(globalThis, "__ADNBN_LOCALE_KEYS__", { - configurable: true, - value: ["empty"], - }); - jest.mocked(getI18nMessage).mockReset().mockReturnValue("en"); - }); +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(); +}); - afterEach(() => { - if (keysDescriptor) { - Object.defineProperty(globalThis, "__ADNBN_LOCALE_KEYS__", keysDescriptor); - } else { - Reflect.deleteProperty(globalThis, "__ADNBN_LOCALE_KEYS__"); - } - jest.restoreAllMocks(); +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); - 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()); - }); - }); + 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("preserves an empty native message before loading a dynamic catalog", () => { - jest.mocked(getI18nMessage).mockImplementation(key => (key === "locale" ? "ru" : "")); - const warn = jest.spyOn(console, "warn").mockImplementation(); - const locale = new DynamicLocale(false); +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([undefined, "", "unknown"])("uses the build language when the native marker is invalid: %s", marker => { + jest.mocked(getI18nMessage).mockReturnValue(marker); + const locale = new DynamicLocale(false); + expect(locale.lang()).toBe(Language.French); + expect(locale.trans("greeting", {name: "Ada"})).toBe("Bonjour Ada"); +}); - expect(locale.lang()).toBe(Language.Russian); - expect(locale.trans("empty")).toBe(""); - expect(warn).not.toHaveBeenCalled(); +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 exposes available languages", () => { + const locale = new DynamicLocale(false); + expect([...locale.keys()]).toEqual(keys); + 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.keys().clear(); + 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(); - test("does not fall back to native locale when dynamic value is empty", () => { - const locale = new TestDynamicLocale(false).setLocale(Language.French, { - empty: "", - }); + 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); - expect(locale.trans("empty")).toBe(""); + 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); + 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("locale", Language.French); + write.resolve(); + await expect(saved).resolves.toBe(Language.French); +}); + +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(Storage.Local).toHaveBeenCalledWith({namespace: "adnbn"}); + expect(driver.set).toHaveBeenCalledWith("locale", 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(); + expect(locale.lang()).toBe(Language.English); + expect(driver.get).not.toHaveBeenCalled(); + await expect(locale.sync()).resolves.toBe(Language.French); + expect(driver.get).toHaveBeenCalledWith("locale"); + expect(driver.set).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"); + driver.get.mockRejectedValueOnce(error); + const locale = new DynamicLocale(); + await expect(locale.sync()).rejects.toBe(error); + expect(locale.lang()).toBe(Language.English); +}); + +test("applies external changes and own storage events without another write", async () => { + const locale = new DynamicLocale(); + const handler = jest.fn(); + const stop = locale.watch(handler); + notify("locale", 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("locale", Language.French); + expect(locale.lang()).toBe(Language.English); + expect(listeners.size).toBe(0); +}); + +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("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); + expect(driver.set).not.toHaveBeenCalled(); + stop(); +}); + +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(); +}); + +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 = new MemoryLocaleStorage(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 = new MemoryLocaleStorage(); + const first = new DynamicLocale(storage); + const second = new DynamicLocale(storage); + const independent = new DynamicLocale(new MemoryLocaleStorage()); + 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 = new MemoryLocaleStorage(); + 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 ee2b3092..d23d5022 100644 --- a/src/locale/providers/DynamicLocale.ts +++ b/src/locale/providers/DynamicLocale.ts @@ -1,72 +1,94 @@ -import {flattenLocaleMessages, getLocaleFilename} from "../utils"; +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 NativeLocale, {LocaleNativeStructure} from "./NativeLocale"; -import CustomLocale, {CustomLocaleData} from "./CustomLocale"; +import AbstractLocale from "./AbstractLocale"; +import {LocaleStorage} from "../storage"; -import {Language, LanguageCodes, LocaleDynamicProvider, LocaleMessages} from "@typing/locale"; +import {convertLocaleKey, resolveLanguage} from "@shared/locale"; +import { + Language, + LocaleCustomKeyForLanguage, + type LocaleDynamicProvider, + type LocaleRegistry, + type LocaleStorageDriver, +} from "@typing/locale"; -export default class - extends NativeLocale +export default class DynamicLocale + extends AbstractLocale implements LocaleDynamicProvider { - protected cache = new Map(); - - protected locale?: CustomLocale; - - protected storage?: StorageProvider>; - protected storageKey?: string; + private language!: Language; + private data!: Readonly>; + protected storage?: LocaleStorageDriver; protected unsubscribe?: () => void; - constructor(storage: string | false = "lang") { + constructor(storage?: LocaleStorageDriver | false) { super(); - if (storage) { - this.storageKey = storage; - this.storage = Storage.Local(); + let marker: string | undefined; + + try { + marker = getI18nMessage(LocaleCustomKeyForLanguage); + } catch { + // MAIN has no extension i18n; use the configured language from the bundled catalogue. } + + this.select(resolveLanguage(marker) ?? defaultLanguage); + + this.storage = storage === false ? undefined : (storage ?? new LocaleStorage()); } - public async change(lang: Language): Promise { - if (lang === this.lang()) { - return lang; + /** + * 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.`); } - const messages = await this.fetch(lang); + this.language = lang; + this.data = catalogue[lang]!; - (this.locale ??= new CustomLocale()).setLang(lang).setData(messages); + return lang; + } + + /** Selects a language immediately and saves it when storage is enabled. */ + 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(); } - 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 { - if (!this.storage || !this.storageKey) { + if (!this.storage) { throw new Error("Language is not saved in storage"); } @@ -74,14 +96,13 @@ export default class throw new Error("Already subscribed to language changes in storage"); } - 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)); - } - }, + 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); @@ -93,28 +114,25 @@ 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); + /** Completed messages for the current language, with underscore keys and no substitutions applied. */ + public messages(): Readonly> { + return this.data; } - protected async fetch(lang: Language): Promise { - let messages = this.cache.get(lang); - - if (!messages) { - const response: LocaleMessages = await (await fetch(getLocaleFilename(lang))).json(); - - if (!response || typeof response !== "object") { - throw new Error(`Invalid or empty locale data for "${lang}"`); - } + public keys(): Set { + return new Set(keys) as Set; + } - messages = flattenLocaleMessages(response); + public langs(): ReadonlySet { + return new Set(languages); + } - this.cache.set(lang, messages); - } + protected value(key: Extract): string | undefined { + const name = convertLocaleKey(key); - return messages; + 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 b3dd6dea..ec2ca089 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", () => { @@ -67,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); @@ -74,10 +74,27 @@ 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.langs()]).toEqual(["en", "fr", "ru"]); + expect([...locale.langNames()]).toEqual([ + ["en", "English"], + ["fr", "Français"], + ["ru", "Русский"], + ]); + expect(error).not.toHaveBeenCalled(); + }); }); diff --git a/src/locale/providers/NativeLocale.ts b/src/locale/providers/NativeLocale.ts index 67be2b5d..85174912 100644 --- a/src/locale/providers/NativeLocale.ts +++ b/src/locale/providers/NativeLocale.ts @@ -1,18 +1,16 @@ import {getI18nMessage} from "@addon-core/browser"; +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, LocaleProvider} from "@typing/locale"; +import {Language, LocaleCustomKeyForLanguage, 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?: NativeLocale; -export default class NativeLocale extends AbstractLocale { - private static instance?: LocaleProvider; - - public static getInstance(): LocaleProvider { + public static getInstance(): NativeLocale { return (NativeLocale.instance ??= new NativeLocale()); } @@ -52,25 +50,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(); - } + public langs(): ReadonlySet { + return new Set(languages); } protected value(key: Extract): string | undefined { 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 4e1efc68..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, type LocaleNativeStructure} from "./NativeLocale"; +export {default as NativeLocale} from "./NativeLocale"; +export {default as ObservableLocale} from "./ObservableLocale"; 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/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/storage/LocaleStorage.test.ts b/src/locale/storage/LocaleStorage.test.ts new file mode 100644 index 00000000..2a310142 --- /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 "./index"; +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..e53d7603 --- /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 default 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..64941301 --- /dev/null +++ b/src/locale/storage/index.ts @@ -0,0 +1 @@ +export {default as LocaleStorage} from "./LocaleStorage"; 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/utils.ts b/src/locale/utils.ts deleted file mode 100644 index dce5ac07..00000000 --- a/src/locale/utils.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - Language, - LanguageCodes, - LocaleDir, - LocaleKeyMarker, - LocaleKeysSeparator, - LocaleMessages, - LocaleNestedKeysSeparator, - RtlLanguages, -} from "@typing/locale"; - -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 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; - } - - 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/main/entrypoint.ts b/src/main/entrypoint.ts new file mode 100644 index 00000000..8bde63cf --- /dev/null +++ b/src/main/entrypoint.ts @@ -0,0 +1,30 @@ +import {readAssets, readAssetsMap} from "#adnbn/entrypoint"; + +import type {EntrypointAssets, EntrypointAssetsMap} from "@typing/entrypoint"; + +export type { + EntrypointAssets, + EntrypointAssetsFiles, + EntrypointAssetsMap, + EntrypointAssetsMapEntry, +} from "@typing/entrypoint"; + +export const getEntrypointAssetsMap = (): EntrypointAssetsMap => { + const assets = readAssetsMap(); + + if (!assets) { + throw new Error("getEntrypointAssetsMap() is available only in the background entrypoint"); + } + + return assets; +}; + +export const getEntrypointAssets = (): EntrypointAssets => { + const assets = readAssets(); + + if (!assets) { + throw new Error("Current entrypoint assets are unavailable in this runtime"); + } + + return assets; +}; 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/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/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/page.ts b/src/main/page.ts index b5a57afe..f70fbd91 100644 --- a/src/main/page.ts +++ b/src/main/page.ts @@ -1,12 +1,9 @@ import {getUrl} from "@addon-core/browser"; +import {aliases} from "#adnbn/page"; -import {PageConfig, PageDefinition, PageProps} from "@typing/page"; +import type {PageAlias, PageDefinition, 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; @@ -16,8 +13,7 @@ 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(aliases).forEach(([key, value]) => { pages.set(key, value); }); } catch (e) { diff --git a/src/main/popup.ts b/src/main/popup.ts index 541a5533..42df60ec 100644 --- a/src/main/popup.ts +++ b/src/main/popup.ts @@ -1,17 +1,14 @@ import {setActionPopup, setActionTitle} from "@addon-core/browser"; - -import {resolve} from "@locale/helpers"; +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; @@ -21,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); @@ -52,10 +48,10 @@ 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) { - await changeActionIcon(icon, tab); + await changeActionIcon(icon as IconName, tab); } }; diff --git a/src/main/relay.test.ts b/src/main/relay.test.ts new file mode 100644 index 00000000..48f7c11c --- /dev/null +++ b/src/main/relay.test.ts @@ -0,0 +1,30 @@ +jest.mock("#adnbn/relay", () => ({options: {}})); + +import {getRelay} from "./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 generated options", () => { + const permissionDescriptor = Object.getOwnPropertyDescriptor(globalThis, RelayPermissionGlobalKey); + + afterEach(() => { + 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 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("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 bc81421b..bd1c8a2d 100644 --- a/src/main/relay.ts +++ b/src/main/relay.ts @@ -1,17 +1,17 @@ import RelayPermission from "@relay/RelayPermission"; import {ProxyRelay, type ProxyRelayParams} from "@relay/providers"; +import {options as relayOptions} from "#adnbn/relay"; -import type {RelayName} from "@relay/index"; import type {TransportType} from "@typing/transport"; import { RelayDefinition, RelayAllFrames, RelayFrameErrorKind, RelayMethod, - RelayOptions, RelayOptionsMap, type RelayBatchOptions, type RelayBatchProxyTarget, + type RelayName, type RelayProxyTarget, type RelayScalarOptions, } from "@typing/relay"; @@ -50,8 +50,7 @@ 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(relayOptions).forEach(([name, options]) => relays.set(name, options)); } catch (e) { console.error("Failed getting relays: ", e); } 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 ec6ef02b..b1e4acc3 100644 --- a/src/main/sidebar.ts +++ b/src/main/sidebar.ts @@ -1,15 +1,11 @@ import {setSidebarPath, setSidebarTitle} from "@addon-core/browser"; +import {aliases} from "#adnbn/sidebar"; -import {resolve} from "@locale/helpers"; - -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; @@ -19,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); @@ -50,6 +45,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); } }; 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/relay/README.md b/src/relay/README.md index 61dedb90..8ef5fe9c 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 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. ## Discovery and transport internals @@ -288,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/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/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/shared/content/index.ts b/src/shared/content/index.ts new file mode 100644 index 00000000..33a2eb61 --- /dev/null +++ b/src/shared/content/index.ts @@ -0,0 +1 @@ +export {isContentScriptFrameNavigation, resolveContentScriptIsolation} from "./isolation"; diff --git a/src/shared/content/isolation.test.ts b/src/shared/content/isolation.test.ts new file mode 100644 index 00000000..a0692cd5 --- /dev/null +++ b/src/shared/content/isolation.test.ts @@ -0,0 +1,64 @@ +/** @jest-environment node */ +import {isContentScriptFrameNavigation, resolveContentScriptIsolation} from "./isolation"; +import {ContentScriptIsolation, ContentScriptShadowMode} from "@typing/content"; + +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], + ["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(() => 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 new file mode 100644 index 00000000..aacd67c0 --- /dev/null +++ b/src/shared/content/isolation.ts @@ -0,0 +1,92 @@ +import { + ContentScriptIsolation, + ContentScriptShadowMode, + type ContentScriptIsolationOptions, + type ContentScriptIsolationFrameOptions, + type ContentScriptFramePageOptions, + type ContentScriptFrameSourceOptions, +} from "@typing/content"; + +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 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"); + } + 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" + ); + } + 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}"`); + } + 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("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("isolation.src must be an absolute HTTP(S) or extension URL"); + } + try { + new URL(src); + } catch { + throw new Error("isolation.src must be a valid absolute URL"); + } + } + if ((page !== undefined || src !== undefined) && hasRender) { + throw new Error( + "isolation.page/isolation.src cannot be combined with render; the embedded page owns its UI and resources" + ); + } + for (const [name, size] of [ + ["width", width], + ["height", height], + ] as const) { + if ( + size !== undefined && + !( + (typeof size === "number" && Number.isFinite(size) && size >= 0) || + (typeof size === "string" && size.trim()) + ) + ) { + throw new Error(`isolation.${name} must be pixels or a CSS size`); + } + } + if (typeof height === "string" && height.trim().toLowerCase() === "auto") { + 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/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 new file mode 100644 index 00000000..48c992b6 --- /dev/null +++ b/src/shared/locale/messages.ts @@ -0,0 +1,8 @@ +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; +}; 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 ef47248b..b8d8b3c1 100644 --- a/src/types/content.ts +++ b/src/types/content.ts @@ -1,19 +1,79 @@ +import type {PageAlias} from "@typing/page"; import type {FC, ReactNode} from "react"; 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://*/*"]; +/** 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; + /** 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 type ContentScriptWorldValue = ContentScriptWorld | `${chrome.scripting.ExecutionWorld}`; + export interface ContentScriptConfig { matches?: string[]; /** @@ -43,8 +103,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 @@ -75,17 +140,78 @@ export interface ContentScriptConfig { export type ContentScriptOptions = ContentScriptConfig & EntrypointOptions; -export type ContentScriptEntrypointOptions = Partial; +export interface ContentScriptShadowOptions { + /** + * Controls access through the host's shadowRoot property, not CSS isolation or UI visibility. + * @default ContentScriptShadowMode.Open + */ + mode?: ContentScriptShadowMode | `${ContentScriptShadowMode}`; +} -// Append -export enum ContentScriptAppend { - Last = "last", - First = "first", - Replace = "replace", - Before = "before", - After = "after", +export interface ContentScriptFrameOptions { + width?: number | string; + /** Defaults to 150px. Automatic height is not supported yet. */ + height?: number | string; } +export interface ContentScriptFrameRenderOptions extends ContentScriptFrameOptions { + page?: never; + src?: never; +} + +export interface ContentScriptFramePageOptions extends ContentScriptFrameOptions { + page: PageAlias; + src?: never; +} + +export interface ContentScriptFrameSourceOptions extends ContentScriptFrameOptions { + src: string; + page?: never; +} + +export type ContentScriptFrame = + | ContentScriptFrameRenderOptions + | 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?: ContentScriptIsolationOptions; +}; + // Mount export type ContentScriptMountFunction = (anchor: Element, container: Element) => void | (() => void); @@ -102,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; @@ -150,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 @@ -169,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 { @@ -234,23 +344,46 @@ 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 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 { +export type ContentScriptDefinition = ContentScriptDefinitionBase & + ( + | { + isolation?: + | ContentScriptIsolation + | `${ContentScriptIsolation}` + | ContentScriptIsolationNoneOptions + | ContentScriptIsolationShadowOptions + | (ContentScriptIsolationFrameOptions & ContentScriptFrameRenderOptions); + render?: ContentScriptRenderValue | ContentScriptRenderHandler; + } + | { + isolation: ContentScriptIsolationFrameOptions & + (ContentScriptFramePageOptions | ContentScriptFrameSourceOptions); + render?: never; + } + ); + +export interface ContentScriptResolvedDefinition extends Omit< + ContentScriptDefinitionBase, + "anchor" | "marker" | "mount" | "container" | "watch" +> { + isolation: ContentScriptIsolationOptions; marker: ContentScriptMarkerResolver; anchor: ContentScriptAnchorGetter; mount: ContentScriptMountFunction; @@ -259,9 +392,9 @@ 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/entrypoint.ts b/src/types/entrypoint.ts index 251234b3..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", @@ -88,6 +91,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; @@ -103,6 +122,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; } 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 e703058a..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 = { @@ -172,6 +118,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; @@ -193,6 +142,12 @@ export interface LocaleStructure { [key: string]: LocaleFutures; } +/** + * 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 = { [K in keyof T]: T[K] extends {plural: false} ? K : never; }[keyof T] & @@ -220,11 +175,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; @@ -238,3 +196,22 @@ export interface LocaleProvider { 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. */ + 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/src/types/manifest.ts b/src/types/manifest.ts index a311001b..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 { @@ -266,8 +271,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/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 0dd89a07..aaff02f2 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"; +/** + * 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; + +export type PageMap = Map; + export interface PageConfig { name?: string; matches?: string[]; 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 6f8c6fad..0cf4c51b 100644 --- a/src/types/relay.ts +++ b/src/types/relay.ts @@ -1,9 +1,19 @@ import {EntrypointOptions} from "@typing/entrypoint"; -import {ContentScriptConfig, ContentScriptContext, ContentScriptDefinition} from "@typing/content"; -import {TransportConfig, TransportDefinition, TransportType, type TransportProxyTarget} 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"; -import type {RelayRegistry} from "@relay/index"; export const RelayGlobalKey = "adnbnRelay"; @@ -34,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 { @@ -166,6 +184,8 @@ export type RelayBatchRpcProxy = { export type RelayBatchRpcProxyObject = (() => Promise>>) & RelayBatchRpcProxy; +export type RelayTarget = TransportTarget; + export type RelayProxyTarget = TransportProxyTarget; export type RelayBatchProxyTarget = RelayBatchRpcProxy; @@ -179,7 +199,7 @@ export type RelayOptions = RelayConfig & EntrypointOptions; export type RelayOptionsMap = Map; -export type RelayEntrypointOptions = Partial; +export type RelayEntrypointOptions = Partial & Pick; export type RelayMainHandler = ( relay: T, @@ -187,12 +207,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 & + Partial & { + 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/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/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/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/locale.ts b/src/virtual/locale.ts new file mode 100644 index 00000000..048b3835 --- /dev/null +++ b/src/virtual/locale.ts @@ -0,0 +1,10 @@ +import {Language, type LocaleCatalogue} from "@typing/locale"; + +/** Package fallback; extension builds supply #adnbn/locale as a generated module. */ +const catalogue: LocaleCatalogue = {}; + +export const lang: Language = Language.English; +export const keys: readonly string[] = []; +export const languages: readonly Language[] = []; + +export default catalogue; diff --git a/src/virtual/modules.d.ts b/src/virtual/modules.d.ts new file mode 100644 index 00000000..ad38eea8 --- /dev/null +++ b/src/virtual/modules.d.ts @@ -0,0 +1,40 @@ +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/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; +} + +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/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/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/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/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/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/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 00000000..2e2b1531 --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,109 @@ +# Integration tests + +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 + +```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/ +│ │ ├── 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/ +│ ├── options/ +│ │ ├── options.integration.test.ts +│ │ ├── react/ +│ │ └── vanilla/ +│ └── utils/ +│ ├── BidiClient.ts +│ ├── browser.ts +│ ├── chrome.ts +│ ├── firefox.ts +│ └── site.ts +├── types/ +│ ├── registries.integration.test.ts +│ └── fixtures/registries/ +├── 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 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. + +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. + +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: + +```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 + +- `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. +- `browser/content/entrypoint-assets`: one Chrome MV3 case and two Firefox cases (MV2 and MV3) using the same application and probe assertions. They cover current asset getters, rejecting the full-map getter outside background, common chunks, dynamic imports, CSS/SVG resource URLs, world separation, and top/child frames. The Chrome case also reads the full-map readiness flag in background. +- `browser/content/isolation-shadow`: production Shadow DOM coverage in Chrome MV3 and Firefox MV2/MV3. Two shadow entrypoints and one ordinary entrypoint verify file-backed initial/lazy/shared CSS, independent runtime registries, strict CSP, local fonts declared with CSS `@font-face`, watch-driven remount, top documents, and child iframes. The shared CSS remains manifest CSS for the ordinary consumer and a web accessible resource for the shadow consumers. +- `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. + +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/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/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/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/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/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/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/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/frame-pages/package.json b/tests/integration/browser/content/frame-pages/package.json new file mode 100644 index 00000000..ceda674b --- /dev/null +++ b/tests/integration/browser/content/frame-pages/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-frame-pages", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../.." + } +} 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..83969dfb --- /dev/null +++ b/tests/integration/browser/content/frame-pages/src/contract.ts @@ -0,0 +1,57 @@ +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: {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: {type: "iframe", page: "missing"}}; +// @ts-expect-error generated registry also narrows the getter +getPageUrl("missing"); +// @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: {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: {type: "iframe", page: "panel"}, render: "UI", init: () => ({})}); +// @ts-expect-error Relay page/src variants are exclusive +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 new file mode 100644 index 00000000..77c109d4 --- /dev/null +++ b/tests/integration/browser/content/frame-pages/src/page.content.ts @@ -0,0 +1,13 @@ +import {ContentScriptIsolation, defineContentScriptAppend} from "adnbn"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/top.html"], + isolation: {type: ContentScriptIsolation.Iframe, 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..a4e0fdd5 --- /dev/null +++ b/tests/integration/browser/content/frame-pages/src/source.content.ts @@ -0,0 +1,14 @@ +import {ContentScriptIsolation, ContentScriptWorld, defineContentScriptAppend} from "adnbn"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/top.html"], + world: ContentScriptWorld.Main, + 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 => { + 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/isolated-styles-utils.ts b/tests/integration/browser/content/isolated-styles-utils.ts new file mode 100644 index 00000000..2690aad9 --- /dev/null +++ b/tests/integration/browser/content/isolated-styles-utils.ts @@ -0,0 +1,379 @@ +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 measured?: boolean; + readonly mounts?: number; + readonly instance?: string; + readonly kind?: string; + readonly mode?: string; + readonly closed?: boolean; + 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 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); + 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, + 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, + 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?.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; + } + 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.measured && !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 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-isolation-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, 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[]}>; + 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.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\/.+\.[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); + + 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"]) { + 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(); + 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); + 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; + } + + const report = {browser: version, manifestVersion, measurements}; + const reportPath = path.join( + rootDir, + ".cache", + "integration", + fixtureName + "-" + name + "-mv" + manifestVersion + ".json" + ); + await writeFile(reportPath, JSON.stringify(report, null, 2) + "\n"); + console.info(JSON.stringify(report, null, 2)); + expect(version).toMatch(new RegExp(`^${name === "chrome" ? "Chrome" : "Firefox"}\\/\\d+(?:\\.\\d+)+$`)); + 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/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/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/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/isolation-iframe/site/child.html b/tests/integration/browser/content/isolation-iframe/site/child.html new file mode 100644 index 00000000..3f10d88d --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/site/child.html @@ -0,0 +1,11 @@ + + + + + Shadow content child frame + + +
+
+ + diff --git a/tests/integration/browser/content/isolation-iframe/site/frames.html b/tests/integration/browser/content/isolation-iframe/site/frames.html new file mode 100644 index 00000000..f3b82a32 --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/site/frames.html @@ -0,0 +1,12 @@ + + + + + Shadow content frames + + +
+
+ + + diff --git a/tests/integration/browser/content/isolation-iframe/site/top.html b/tests/integration/browser/content/isolation-iframe/site/top.html new file mode 100644 index 00000000..f35ebe14 --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/site/top.html @@ -0,0 +1,14 @@ + + + + + Shadow content integration + + +

Shadow content integration

+
+
+
+

Outside page content

+ + diff --git a/tests/integration/browser/content/isolation-iframe/src/normal.content.ts b/tests/integration/browser/content/isolation-iframe/src/normal.content.ts new file mode 100644 index 00000000..8843b8de --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/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-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/isolation-iframe/src/probe.content/lazy.module.css b/tests/integration/browser/content/isolation-iframe/src/probe.content/lazy.module.css new file mode 100644 index 00000000..3aa1605c --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/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-iframe/src/probe.content/lazy.ts b/tests/integration/browser/content/isolation-iframe/src/probe.content/lazy.ts new file mode 100644 index 00000000..b127d013 --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/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-iframe/src/probe.content/probe.woff2 b/tests/integration/browser/content/isolation-iframe/src/probe.content/probe.woff2 new file mode 100644 index 00000000..00ed2579 Binary files /dev/null and b/tests/integration/browser/content/isolation-iframe/src/probe.content/probe.woff2 differ diff --git a/tests/integration/browser/content/isolation-iframe/src/probe.content/styles.module.css b/tests/integration/browser/content/isolation-iframe/src/probe.content/styles.module.css new file mode 100644 index 00000000..e2b9cebf --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/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/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/isolation-iframe/src/secondary.content/lazy.module.css b/tests/integration/browser/content/isolation-iframe/src/secondary.content/lazy.module.css new file mode 100644 index 00000000..807915dc --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/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-iframe/src/secondary.content/lazy.ts b/tests/integration/browser/content/isolation-iframe/src/secondary.content/lazy.ts new file mode 100644 index 00000000..b127d013 --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/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-iframe/src/secondary.content/styles.module.css b/tests/integration/browser/content/isolation-iframe/src/secondary.content/styles.module.css new file mode 100644 index 00000000..14457a9f --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/src/secondary.content/styles.module.css @@ -0,0 +1,3 @@ +.root { + color: rgb(119, 51, 34); +} 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/isolation-iframe/src/shared/styles.module.css b/tests/integration/browser/content/isolation-iframe/src/shared/styles.module.css new file mode 100644 index 00000000..f67dce95 --- /dev/null +++ b/tests/integration/browser/content/isolation-iframe/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/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/isolation-shadow.firefox.integration.test.ts b/tests/integration/browser/content/isolation-shadow.firefox.integration.test.ts new file mode 100644 index 00000000..31d7dcbb --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow.firefox.integration.test.ts @@ -0,0 +1,12 @@ +/** @jest-environment node */ + +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 runIsolatedStylesIntegration("firefox", version); + } +); diff --git a/tests/integration/browser/content/isolation-shadow.integration.test.ts b/tests/integration/browser/content/isolation-shadow.integration.test.ts new file mode 100644 index 00000000..8d53f1a7 --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow.integration.test.ts @@ -0,0 +1,9 @@ +/** @jest-environment node */ + +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 runIsolatedStylesIntegration("chrome", 3); +}); diff --git a/tests/integration/browser/content/isolation-shadow/README.md b/tests/integration/browser/content/isolation-shadow/README.md new file mode 100644 index 00000000..904d5805 --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/README.md @@ -0,0 +1,55 @@ +# Shadow DOM content integration + +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 uses a local WOFF2 declared in document CSS with `@font-face`. The secondary entrypoint +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 + +- 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 + 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. +- 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. + +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.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. + +## Run + +From the repository root: + +```sh +npm run build +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 +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/isolation-shadow/adnbn.config.ts b/tests/integration/browser/content/isolation-shadow/adnbn.config.ts new file mode 100644 index 00000000..d94639ac --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/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: "isolation-shadow@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-shadow/font.py b/tests/integration/browser/content/isolation-shadow/font.py new file mode 100644 index 00000000..b47cb83e --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/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/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/isolation-shadow/src/probe.content/index.ts b/tests/integration/browser/content/isolation-shadow/src/probe.content/index.ts new file mode 100644 index 00000000..72070843 --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/src/probe.content/index.ts @@ -0,0 +1,88 @@ +import {ContentScriptIsolation, ContentScriptAppend, ContentScriptWorld, defineContentScriptAppend} from "adnbn"; + +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; +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, + isolation: ContentScriptIsolation.Shadow, + 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); + }) + .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/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 00000000..00ed2579 Binary files /dev/null and b/tests/integration/browser/content/isolation-shadow/src/probe.content/probe.woff2 differ 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/isolation-shadow/src/secondary.content/index.ts b/tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts new file mode 100644 index 00000000..c4253b04 --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts @@ -0,0 +1,84 @@ +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: {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; + }, + 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"; + 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(); + + 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/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/options/embedded/tsconfig.json b/tests/integration/browser/content/isolation-shadow/tsconfig.json similarity index 100% rename from tests/integration/browser/options/embedded/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/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/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..15b6670e --- /dev/null +++ b/tests/integration/browser/content/isolation-spike/src/probe.tsx @@ -0,0 +1,158 @@ +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..91bf2685 --- /dev/null +++ b/tests/integration/browser/content/relay-styles.integration.test.ts @@ -0,0 +1,58 @@ +/** @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, + 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([]); + } finally { + await session?.close(); + await site.close(); + await fixture.dispose(); + } +}); 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..5a3359f1 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/README.md @@ -0,0 +1,18 @@ +# 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. +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. + +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/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..0c4688e0 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/src/control.relay.ts @@ -0,0 +1,21 @@ +import {defineRelay} from "adnbn"; +export default defineRelay({ + name: "control", + matches: ["http://127.0.0.1/*"], + 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. + 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..9bda8279 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/src/iframe.relay.ts @@ -0,0 +1,19 @@ +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", + + 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 new file mode 100644 index 00000000..7d3ad2e3 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/src/shadow.relay.ts @@ -0,0 +1,18 @@ +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: {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/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..a8d908f7 --- /dev/null +++ b/tests/integration/browser/content/relay-styles/src/shared/panel.ts @@ -0,0 +1,29 @@ +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!; + 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/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/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/locale/chunks.firefox.integration.test.ts b/tests/integration/browser/locale/chunks.firefox.integration.test.ts new file mode 100644 index 00000000..9b255b85 --- /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 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); + 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..ed336074 --- /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 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); + 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/dynamic.firefox.integration.test.ts b/tests/integration/browser/locale/dynamic.firefox.integration.test.ts new file mode 100644 index 00000000..7c9a1da4 --- /dev/null +++ b/tests/integration/browser/locale/dynamic.firefox.integration.test.ts @@ -0,0 +1,119 @@ +/** @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 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( + 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="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="isolated"]'); + 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 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="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([]); + } 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..54d9c4b6 --- /dev/null +++ b/tests/integration/browser/locale/dynamic.integration.test.ts @@ -0,0 +1,180 @@ +/** @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 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( + 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]:not([data-locale-context="main"])'); 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"); + } + 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('adnbn:locale'))['adnbn:locale'])()` + ) + ).toBeUndefined(); + 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('adnbn:locale'))['adnbn:locale']}; + })()` + ); + 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(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([]); + 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/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/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/offscreen/service/tsconfig.json b/tests/integration/browser/offscreen/service/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/browser/offscreen/service/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/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[] = []; + public readonly requests: 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 === "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"); + } + + 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..80a03289 --- /dev/null +++ b/tests/integration/browser/utils/browser.ts @@ -0,0 +1,92 @@ +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, + description = "the browser" +): 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 ${description}${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..4df8663d 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,89 +37,12 @@ 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(); 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)))); @@ -204,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}} @@ -258,44 +185,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/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.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 new file mode 100644 index 00000000..a8e15de6 --- /dev/null +++ b/tests/integration/browser/utils/site.ts @@ -0,0 +1,64 @@ +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, + contentSecurityPolicy: string | null = "default-src 'none'; frame-src 'self'; img-src 'self'", + headers: Readonly>>> = {} +): 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": 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 { + 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())); + // Chrome can leave speculative connections with no HTTP request. Drain them on teardown too. + server.closeAllConnections(); + }), + }; +}; 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..fd00f20f --- /dev/null +++ b/tests/integration/build/isolation/isolation-watch.integration.test.ts @@ -0,0 +1,185 @@ +/** @jest-environment node */ +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"; + +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 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"), + 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"); + // 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 isolation.page/isolation.src, but has no local render target" + ); + 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, + 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, + `CLI watch state "${state}"` + ); + for (const state of [ + "none", + "shadow", + "shadow-closed", + "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, + "CLI watch missing-isolation-css warning" + ); + } + 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) + ).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"); + 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( + 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"); + 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, + "CLI watch page access validation error" + ); + 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, + "CLI watch to restore valid page access" + ); + } 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/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/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..90dfd67c --- /dev/null +++ b/tests/integration/build/isolation/states/iframe.ts @@ -0,0 +1,10 @@ +import {defineContentScriptAppend} from "adnbn"; +import "./watch.css?isolation"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + isolation: {type: "iframe", 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..10cf03bd --- /dev/null +++ b/tests/integration/build/isolation/states/page-isolated.ts @@ -0,0 +1,7 @@ +import {defineContentScriptAppend} from "adnbn"; +import "./watch.css?isolation"; + +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + isolation: {type: "iframe", 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..6b903885 --- /dev/null +++ b/tests/integration/build/isolation/states/page.ts @@ -0,0 +1,9 @@ +import {defineContentScriptAppend} from "adnbn"; +import "./watch.css"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + 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 new file mode 100644 index 00000000..654a866b --- /dev/null +++ b/tests/integration/build/isolation/states/renamed.ts @@ -0,0 +1,9 @@ +import {defineContentScriptAppend} from "adnbn"; +import "./watch.css"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + 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/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..9d8c3b4e --- /dev/null +++ b/tests/integration/build/isolation/states/source.ts @@ -0,0 +1,9 @@ +import {defineContentScriptAppend} from "adnbn"; +import "./watch.css"; +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + isolation: {type: "iframe", src: "https://example.com/widget"}, + main() { + console.info("isolation-watch-source"); + }, +}); 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..fbc7297a --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/adnbn.config.ts @@ -0,0 +1,10 @@ +import {defineConfig} from "adnbn"; + +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"}}, +}); 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..4b01f923 --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/background.ts @@ -0,0 +1,5 @@ +import catalogue from "#adnbn/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..455394a2 --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/catalogue.d.ts @@ -0,0 +1,7 @@ +declare module "#adnbn/locale" { + export const lang: import("adnbn/locale").Language; + 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-fixture/src/isolated-secondary.content.ts b/tests/integration/build/locale/chunks-fixture/src/isolated-secondary.content.ts new file mode 100644 index 00000000..22127e8a --- /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 "#adnbn/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..ef4a2dc1 --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/isolated.content.ts @@ -0,0 +1,9 @@ +import {defineContentScript} from "adnbn"; +import catalogue from "#adnbn/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..e495fbe4 --- /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 "#adnbn/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..fb18b1e7 --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/main.content.ts @@ -0,0 +1,9 @@ +import {defineContentScript} from "adnbn"; +import catalogue from "#adnbn/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..f89f66b0 --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/options.ts @@ -0,0 +1,5 @@ +import {defineOptions} from "adnbn"; +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/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..a95d21cc --- /dev/null +++ b/tests/integration/build/locale/chunks-fixture/src/popup.ts @@ -0,0 +1,5 @@ +import {definePopup} from "adnbn"; +import catalogue from "#adnbn/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..9807a0cb --- /dev/null +++ b/tests/integration/build/locale/chunks.integration.test.ts @@ -0,0 +1,175 @@ +/** @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/locale-module"; +import {Language} from "@typing/locale"; +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: ["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}, +])("$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 keys = new Set(["greeting", "padding", "locale"]); + 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, 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")); + } + 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 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")); + } + } + 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]); + + 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: 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({ + 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[]; world: string}) => ({ + files: script.js, + main: script.world === "MAIN", + })) + ); + expect(consumers).toHaveLength(entries.length); + + 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(); + 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); + 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 : localeConsumers.length) + (splitMain ? 1 : mainConsumers.length) + ); + expect(mainFiles.size).toBe(splitMain ? 1 : mainConsumers.length); + expect(sharedFiles.size).toBe(split ? 1 : localeConsumers.length); + if (split) { + 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..05d223a6 --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/package.json @@ -0,0 +1,12 @@ +{ + "name": "adnbn-test-locale-chunks", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "*", + "react": "*", + "react-dom": "*", + "scheduler": "*" + } +} 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..affbaccd --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/src/contract.ts @@ -0,0 +1,38 @@ +import {DynamicLocale, Language, LocaleStorage, type LocaleRegistry, type LocaleStorageDriver} from "adnbn/locale"; + +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; + }); + // @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); + 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(); + const registryKeys: ReadonlySet = 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/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/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..e4a57be8 --- /dev/null +++ b/tests/integration/build/locale/dynamic-fixture/src/panel.ts @@ -0,0 +1,28 @@ +import {DynamicLocale, type Language, type LocaleStorageDriver} from "adnbn/locale"; + +export const createPanel = (context: string, storage?: LocaleStorageDriver | false): HTMLElement => { + const locale = new DynamicLocale(storage); + const panel = document.createElement("div"); + panel.dataset.localeContext = context; + const select = document.createElement("select"); + for (const [language, name] of locale.langNames()) 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(); + if (storage !== false) { + 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..92f6e5ee --- /dev/null +++ b/tests/integration/build/locale/dynamic.integration.test.ts @@ -0,0 +1,131 @@ +/** @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.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"); + } + 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).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", "main.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; + langs(): ReadonlySet; + langNames(): ReadonlyMap; + 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!.langs()]).toEqual(["en", "fr"]); + expect([...sandbox.nativeLocale!.langNames()]).toEqual([ + ["en", "English"], + ["fr", "Français"], + ]); + } finally { + 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/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..99ea1d7f --- /dev/null +++ b/tests/integration/build/locale/fixture/src/background.ts @@ -0,0 +1,8 @@ +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; +(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 new file mode 100644 index 00000000..455394a2 --- /dev/null +++ b/tests/integration/build/locale/fixture/src/catalogue.d.ts @@ -0,0 +1,7 @@ +declare module "#adnbn/locale" { + export const lang: import("adnbn/locale").Language; + 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/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..cdcd3b14 --- /dev/null +++ b/tests/integration/build/locale/fixture/src/locales/fr.json @@ -0,0 +1,10 @@ +{ + "app": { + "title": "Catalogue de traductions" + }, + "secondaryOnly": "Only in French", + "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..b1bc10a7 --- /dev/null +++ b/tests/integration/build/locale/locale.integration.test.ts @@ -0,0 +1,202 @@ +/** @jest-environment node */ + +import {spawn, type ChildProcess} from "child_process"; +import {copyFile, 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, + readLocaleKeys: undefined as (() => readonly string[]) | undefined, + readLocaleLanguage: undefined as (() => 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 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")); + 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, keys: sandbox.readLocaleKeys!(), lang, 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, 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}}", + 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("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 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 { + 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)); + // 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 updateLocale("de", "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 updateLocale("de", "de-updated.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 updateLocale("en", "en.json"); + await waitFor( + async () => { + const {catalogue, keys, languages} = await inspect(directory); + expect([...languages].sort()).toEqual(["de", "en", "fr"]); + 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"); + 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, 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 updateLocale("de", invalid); + 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 updateLocale("de", "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 { + if (watcher) await stop(watcher); + await fixture.dispose(); + } +}); 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/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/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/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" + } +} 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/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; + }, +}); 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..87cc67c3 --- /dev/null +++ b/tests/integration/build/locale/states/package-import.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { + getIcons, + getEntrypointAssets, + getEntrypointAssetsMap, + getOffscreen, + getOffscreens, + getPages, + getPopups, + getRelay, + getSandbox, + getSandboxes, + getSidebars, +} 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(() => 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 = []; +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/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(" { + 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. + 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", + isolation: {type: "none"}, + method: "messaging", + }, + }; + const expectedScripting = { + collector: { + name: "collector", + matches: ["https://example.com/*"], + runAt: "document_idle", + isolation: {type: "none"}, + 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"); + // 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 () => { + 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, 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, + `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); + 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..80d07e57 --- /dev/null +++ b/tests/integration/build/relay-runtime/states/background.ts @@ -0,0 +1,6 @@ +import {options} from "#adnbn/relay"; + +// 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/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/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/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/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 () => {}; 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. 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-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 new file mode 100644 index 00000000..c25babfd --- /dev/null +++ b/tests/integration/build/style-routing/src/background.js @@ -0,0 +1,3 @@ +import {getEntrypointAssetsMap} from "adnbn"; + +globalThis.assets = getEntrypointAssetsMap(); 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..d72f5514 --- /dev/null +++ b/tests/integration/build/style-routing/style-routing.integration.test.ts @@ -0,0 +1,226 @@ +/** @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, { + 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"; +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; + withoutMap?: boolean; +}>([ + ...[ + {browser: Browser.Chrome, commonChunks: true, withoutMap: 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, withoutMap}) => { + 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: withoutMap ? "./background-unused.js" : "./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")]}, + resolve: {alias: {adnbn$: path.join(root, "dist/index.js")}}, + 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 GenerateModulePlugin({[EntrypointAssetsModule.request]: createEntrypointModule()}), + new BuildAssetsMapPlugin({ + module: EntrypointAssetsModule, + 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)!; + 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$/), + 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/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 00000000..0a3e580f Binary files /dev/null and b/tests/integration/build/virtual-modules/fixture/src/icons/dataicons16.png differ 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/prepare.ts b/tests/integration/prepare.ts new file mode 100644 index 00000000..e6d07a05 --- /dev/null +++ b/tests/integration/prepare.ts @@ -0,0 +1,12 @@ +import path from "path"; +import {fileURLToPath} from "url"; + +import {findIntegrationFixtures, prepareIntegrationFixture} from "./utils/fixture"; + +const directory = fileURLToPath(new URL(".", import.meta.url)); +const projectRoot = path.resolve(directory, "..", ".."); + +for (const fixture of await findIntegrationFixtures(directory)) { + console.info(`Preparing ${path.relative(projectRoot, fixture)}`); + await prepareIntegrationFixture(projectRoot, fixture); +} diff --git a/tests/integration/typecheck.ts b/tests/integration/typecheck.ts new file mode 100644 index 00000000..415a0df3 --- /dev/null +++ b/tests/integration/typecheck.ts @@ -0,0 +1,17 @@ +import path from "path"; +import {fileURLToPath} from "url"; + +import {findIntegrationFixtures} from "./utils/fixture"; +import {run} from "./utils/process"; + +const directory = fileURLToPath(new URL(".", import.meta.url)); +const projectRoot = path.resolve(directory, "..", ".."); + +for (const fixture of await findIntegrationFixtures(directory)) { + console.info(`Typechecking ${path.relative(projectRoot, fixture)}`); + await run( + process.execPath, + [path.join(projectRoot, "node_modules", "typescript", "bin", "tsc"), "--noEmit", "--project", "tsconfig.json"], + fixture + ); +} 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 + ); +}); diff --git a/tests/integration/utils/fixture.ts b/tests/integration/utils/fixture.ts new file mode 100644 index 00000000..1f73a81f --- /dev/null +++ b/tests/integration/utils/fixture.ts @@ -0,0 +1,117 @@ +import {cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink} from "fs/promises"; +import path from "path"; + +import {run} from "./process"; + +export interface IntegrationFixtureBuildOptions { + browser?: string; + manifestVersion?: 2 | 3; +} + +export interface IntegrationFixture { + readonly directory: string; + build(options?: IntegrationFixtureBuildOptions): Promise; + 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/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; 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/raw-module-resolver.cjs b/tests/raw-module-resolver.cjs new file mode 100644 index 00000000..a28ef334 --- /dev/null +++ b/tests/raw-module-resolver.cjs @@ -0,0 +1,6 @@ +module.exports = (request, options) => { + 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/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); + }); +}); 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/**" ] diff --git a/tsconfig.json b/tsconfig.json index 164b8cf4..2e833b0a 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/*"] @@ -81,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" ] } diff --git a/tsconfig.tests.json b/tsconfig.tests.json deleted file mode 100644 index ede40a0d..00000000 --- a/tsconfig.tests.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": true - }, - "include": [ - "src/**/*.test.ts", - "src/**/*.test.tsx", - "src/**/*.d.ts", - "tests/**/*.test.ts", - "tests/jest*.ts" - ], - "exclude": [ - "src/**/tests/fixtures/**", - "dist", - "node_modules" - ] -} diff --git a/tsup.config.ts b/tsup.config.ts index ea2953c1..df87dc20 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -56,6 +56,22 @@ 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/chunk-loader/templates.ts", + "src/cli/bundler/plugins/isolated-styles/templates.ts", +]; + export default defineConfig([ { entry: [ @@ -66,6 +82,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, @@ -91,9 +108,26 @@ 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: [virtualEntrypointImportPlugin(), rawPlugin()], + esbuildOptions: options => { + options.outbase = "src"; + }, + 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"; 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; +}